mirror of
https://github.com/trailbaseio/trailbase
synced 2026-04-21 13:37:44 +00:00
Streamline JS/TS WASM HTTP handler, introduce HttpResponse.(status|ok|text|json).
This commit is contained in:
parent
69ee3775ea
commit
9365d86d81
22 changed files with 885 additions and 369 deletions
|
|
@ -2,22 +2,21 @@ import { defineConfig } from "trailbase-wasm";
|
|||
import {
|
||||
HttpError,
|
||||
HttpHandler,
|
||||
OutgoingResponse,
|
||||
Request,
|
||||
HttpRequest,
|
||||
HttpResponse,
|
||||
StatusCode,
|
||||
buildJsonResponse,
|
||||
} from "trailbase-wasm/http";
|
||||
import { execute, query, Transaction } from "trailbase-wasm/db";
|
||||
|
||||
export default defineConfig({
|
||||
httpHandlers: [
|
||||
HttpHandler.get("/fibonacci", (req: Request): string => {
|
||||
HttpHandler.get("/fibonacci", (req: HttpRequest): string => {
|
||||
const n = req.getQueryParam("n");
|
||||
return `${fibonacci(n ? parseInt(n) : 40)}\n`;
|
||||
}),
|
||||
HttpHandler.get("/json", jsonHandler),
|
||||
HttpHandler.post("/json", jsonHandler),
|
||||
HttpHandler.get("/fetch", async (req: Request): Promise<string> => {
|
||||
HttpHandler.get("/fetch", async (req: HttpRequest): Promise<string> => {
|
||||
const url = req.getQueryParam("url");
|
||||
if (url) {
|
||||
return await (await fetch(url)).text();
|
||||
|
|
@ -94,9 +93,9 @@ export default defineConfig({
|
|||
],
|
||||
});
|
||||
|
||||
function jsonHandler(req: Request): OutgoingResponse {
|
||||
function jsonHandler(req: HttpRequest): HttpResponse {
|
||||
const json = req.json();
|
||||
return buildJsonResponse(
|
||||
return HttpResponse.json(
|
||||
json ?? {
|
||||
int: 5,
|
||||
real: 4.2,
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ const ignoredStarlightCustomTailwindClasses = [
|
|||
|
||||
export default [
|
||||
{
|
||||
ignores: ["dist/", "node_modules/", ".astro/", "src/env.d.ts"],
|
||||
ignores: ["**/dist/", "**/node_modules/", ".astro/", "src/env.d.ts"],
|
||||
},
|
||||
jsPlugin.configs.recommended,
|
||||
...tsPlugin.configs.recommended,
|
||||
|
|
|
|||
3
docs/examples/wasm-guest-ts/.gitignore
vendored
Normal file
3
docs/examples/wasm-guest-ts/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
dist/
|
||||
node_modules/
|
||||
traildepot/
|
||||
30
docs/examples/wasm-guest-ts/eslint.config.mjs
Normal file
30
docs/examples/wasm-guest-ts/eslint.config.mjs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import pluginJs from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default [
|
||||
pluginJs.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
ignores: ["dist/", "node_modules/"],
|
||||
},
|
||||
{
|
||||
files: ["src/**/*.{js,mjs,cjs,mts,ts,tsx,jsx}"],
|
||||
rules: {
|
||||
// https://typescript-eslint.io/rules/no-explicit-any/
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"@typescript-eslint/no-wrapper-object-types": "warn",
|
||||
"@typescript-eslint/no-namespace": "off",
|
||||
"no-var": "off",
|
||||
// http://eslint.org/docs/rules/no-unused-vars
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
vars: "all",
|
||||
args: "after-used",
|
||||
argsIgnorePattern: "^_",
|
||||
varsIgnorePattern: "^_",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
29
docs/examples/wasm-guest-ts/package.json
Normal file
29
docs/examples/wasm-guest-ts/package.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"name": "docs-example-wasm-guest-ts",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build:wasm": "jco componentize dist/index.js -w node_modules/trailbase-wasm/wit -o dist/component.wasm",
|
||||
"build:wasm:aot": "jco componentize dist/index.js -w node_modules/trailbase-wasm/wit --aot -o dist/component.wasm",
|
||||
"build": "vite build && npm run build:wasm",
|
||||
"check": "tsc --noEmit --skipLibCheck && eslint",
|
||||
"dev": "node --experimental-strip-types hot-reload.ts",
|
||||
"format": "prettier -w src"
|
||||
},
|
||||
"dependencies": {
|
||||
"trailbase-wasm": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@bytecodealliance/jco": "^1.16.1",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@types/node": "^25.2.1",
|
||||
"commander": "^14.0.3",
|
||||
"eslint": "^9.39.2",
|
||||
"nano-spawn": "^2.0.0",
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.54.0",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
3
docs/examples/wasm-guest-ts/src/component.ts
Normal file
3
docs/examples/wasm-guest-ts/src/component.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import e from "./index";
|
||||
|
||||
export const { initEndpoint, incomingHandler, sqliteFunctionEndpoint } = e;
|
||||
21
docs/examples/wasm-guest-ts/src/index.ts
Normal file
21
docs/examples/wasm-guest-ts/src/index.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { defineConfig } from "trailbase-wasm";
|
||||
import { query } from "trailbase-wasm/db";
|
||||
import { HttpHandler, HttpResponse, StatusCode } from "trailbase-wasm/http";
|
||||
import type { HttpRequest } from "trailbase-wasm/http";
|
||||
|
||||
async function countRecordsHandler(req: HttpRequest): Promise<HttpResponse> {
|
||||
const table = req.getPathParam("table");
|
||||
if (!table) {
|
||||
return HttpResponse.status(
|
||||
StatusCode.BAD_REQUEST,
|
||||
`Table not found for '?table=${table}'`,
|
||||
);
|
||||
}
|
||||
|
||||
const rows = await query(`SELECT COUNT(*) FROM ${table}`, []);
|
||||
return HttpResponse.text(`count: ${rows[0][0]}`);
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
httpHandlers: [HttpHandler.get("/count/{table}", countRecordsHandler)],
|
||||
});
|
||||
13
docs/examples/wasm-guest-ts/tsconfig.json
Normal file
13
docs/examples/wasm-guest-ts/tsconfig.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2022" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
|
||||
"module": "es2022" /* Specify what module code is generated. */,
|
||||
"moduleResolution": "bundler",
|
||||
"paths": {},
|
||||
"outDir": "./dist/" /* Specify an output folder for all emitted files. */,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
|
||||
"strict": true /* Enable all strict type-checking options. */,
|
||||
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
||||
}
|
||||
}
|
||||
18
docs/examples/wasm-guest-ts/vite.config.ts
Normal file
18
docs/examples/wasm-guest-ts/vite.config.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
minify: false,
|
||||
lib: {
|
||||
entry: "./src/component.ts",
|
||||
name: "runtime",
|
||||
fileName: "index",
|
||||
formats: ["es"],
|
||||
},
|
||||
rollupOptions: {
|
||||
external: (source) =>
|
||||
source.startsWith("wasi:") || source.startsWith("trailbase:"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -2,40 +2,68 @@
|
|||
title: Custom APIs
|
||||
---
|
||||
|
||||
import { Aside } from "@astrojs/starlight/components";
|
||||
import { Aside, Code } from "@astrojs/starlight/components";
|
||||
import { githubPath } from "@/lib/github.ts"
|
||||
|
||||
On startup TrailBase will automatically load any WASM component, i.e. `*.wasm`
|
||||
files in `traildepot/wasm`.
|
||||
files, that it finds in `<traildepot>/wasm`.
|
||||
This can be used to implement arbitrary HTTP APIs with custom handlers.
|
||||
|
||||
## Example HTTP Endpoint
|
||||
|
||||
The following TypeScript WASM example illustrates a few things:
|
||||
## Examples
|
||||
|
||||
* How to register a parameterized route with `{table}`.
|
||||
* How to query the database.
|
||||
* How to return an HTTP error.
|
||||
At this point the documentation is embarrassingly "thin", take a look at the examples
|
||||
for further context. Some are provided here, more can be found in `/examples` including
|
||||
project templates:
|
||||
|
||||
```ts
|
||||
import { defineConfig } from "trailbase-wasm";
|
||||
import { Request, HttpError, HttpHandler, StatusCode } from "trailbase-wasm/http";
|
||||
import { query } from "trailbase-wasm/db";
|
||||
|
||||
async function handler(req: Request): Promise<string> {
|
||||
const table = req.getPathParam("table");
|
||||
if (table) {
|
||||
const rows = await query(`SELECT COUNT(*) FROM ${table}`, [])
|
||||
return `entries: ${rows[0][0]}`;
|
||||
}
|
||||
|
||||
throw new HttpError(
|
||||
StatusCode.BAD_REQUEST, "Missing '?table=' search query param");
|
||||
}
|
||||
* TypeScript: <a href={githubPath("examples/wasm-guest-ts")}><code>/examples/wasm-guest-ts</code></a>
|
||||
* JavaScript: <a href={githubPath("examples/wasm-guest-js")}><code>/examples/wasm-guest-js</code></a>
|
||||
* Rust: <a href={githubPath("examples/wasm-guest-rust")}><code>/examples/wasm-guest-rust</code></a>
|
||||
|
||||
export default defineConfig({
|
||||
httpHandlers: [ HttpHandler.get("/test/{table}", handler) ],
|
||||
});
|
||||
```
|
||||
### (Java|Type)Script
|
||||
|
||||
More examples can be found in the repository under `examples/wasm-guest-ts/`,
|
||||
`examples/wasm-guest-js/` and `examples/wasm-guest-rust/`.
|
||||
The following example demonstrates how to:
|
||||
|
||||
* register a parameterized route with `{table}`,
|
||||
* query the database,
|
||||
* return a string body or HTTP error.
|
||||
|
||||
import tsDocsExample from "@examples/wasm-guest-ts/src/index.ts?raw";
|
||||
|
||||
<Code
|
||||
code={tsDocsExample}
|
||||
lang="typescript"
|
||||
title={"docs/examples/wasm-guest-ts/src/index.ts"}
|
||||
mark={[]}
|
||||
/>
|
||||
|
||||
Another example reading query parameters and producing a JSON response.
|
||||
|
||||
import tsCoffeeExample from "@root/examples/coffee-vector-search/guests/typescript/src/index.ts?raw";
|
||||
|
||||
<Code
|
||||
code={tsCoffeeExample}
|
||||
lang="typescript"
|
||||
title={"examples/coffee-vector-search/guests/typescript/src/index.ts"}
|
||||
mark={[]}
|
||||
/>
|
||||
|
||||
### Rust
|
||||
|
||||
The following example demonstrates how to:
|
||||
|
||||
* register an HTTP route,
|
||||
* read query parameters,
|
||||
* query the database,
|
||||
* and return a JSON response or errors.
|
||||
|
||||
import rustExample from "@root/examples/coffee-vector-search/guests/rust/src/lib.rs?raw";
|
||||
|
||||
<Code
|
||||
code={rustExample}
|
||||
lang="rust"
|
||||
title={"examples/coffee-vector-search/guests/rust/src/lib.rs"}
|
||||
mark={[]}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -8,18 +8,8 @@ use trailbase_wasm::{Guest, export};
|
|||
|
||||
type SearchResponse = (String, f64, f64, f64, f64);
|
||||
|
||||
fn as_real(v: &Value) -> Result<f64, String> {
|
||||
if let Value::Real(f) = v {
|
||||
return Ok(*f);
|
||||
}
|
||||
return Err(format!("Not a real: {v:?}"));
|
||||
}
|
||||
|
||||
async fn search_handler(req: Request) -> Result<Json<Vec<SearchResponse>>, HttpError> {
|
||||
let mut aroma: i64 = 8;
|
||||
let mut flavor: i64 = 8;
|
||||
let mut acidity: i64 = 8;
|
||||
let mut sweetness: i64 = 8;
|
||||
let (mut aroma, mut flavor, mut acidity, mut sweetness) = (8, 8, 8, 8);
|
||||
|
||||
for (param, value) in req.url().query_pairs() {
|
||||
match param.as_ref() {
|
||||
|
|
@ -31,7 +21,7 @@ async fn search_handler(req: Request) -> Result<Json<Vec<SearchResponse>>, HttpE
|
|||
}
|
||||
}
|
||||
|
||||
// Query with vector-search for the closest match.
|
||||
// Query the closest match using vector-search.
|
||||
let results: Vec<SearchResponse> = query(
|
||||
r#"
|
||||
SELECT Owner, Aroma, Flavor, Acidity, Sweetness
|
||||
|
|
@ -39,9 +29,8 @@ async fn search_handler(req: Request) -> Result<Json<Vec<SearchResponse>>, HttpE
|
|||
ORDER BY vec_distance_L2(
|
||||
embedding, FORMAT("[%f, %f, %f, %f]", $1, $2, $3, $4))
|
||||
LIMIT 100
|
||||
"#
|
||||
.to_string(),
|
||||
vec![
|
||||
"#,
|
||||
[
|
||||
Value::Integer(aroma),
|
||||
Value::Integer(flavor),
|
||||
Value::Integer(acidity),
|
||||
|
|
@ -54,7 +43,7 @@ async fn search_handler(req: Request) -> Result<Json<Vec<SearchResponse>>, HttpE
|
|||
.map(|row| {
|
||||
// Convert to json response.
|
||||
let Value::Text(owner) = row[0].clone() else {
|
||||
panic!("unreachable");
|
||||
panic!("invariant");
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -70,13 +59,20 @@ async fn search_handler(req: Request) -> Result<Json<Vec<SearchResponse>>, HttpE
|
|||
return Ok(Json(results));
|
||||
}
|
||||
|
||||
// Implement the function exported in this world (see above).
|
||||
struct Endpoints;
|
||||
fn as_real(v: &Value) -> Result<f64, String> {
|
||||
return match v {
|
||||
Value::Real(f) => Ok(*f),
|
||||
_ => Err(format!("Not a real: {v:?}")),
|
||||
};
|
||||
}
|
||||
|
||||
impl Guest for Endpoints {
|
||||
// Lastly, implement and export a TrailBase component.
|
||||
struct GuestImpl;
|
||||
|
||||
impl Guest for GuestImpl {
|
||||
fn http_handlers() -> Vec<HttpRoute> {
|
||||
return vec![routing::get("/search", search_handler)];
|
||||
}
|
||||
}
|
||||
|
||||
export!(Endpoints);
|
||||
export!(GuestImpl);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { defineConfig } from "trailbase-wasm";
|
||||
import { Request, HttpHandler } from "trailbase-wasm/http";
|
||||
import { HttpHandler, HttpRequest, HttpResponse } from "trailbase-wasm/http";
|
||||
import { query } from "trailbase-wasm/db";
|
||||
|
||||
async function searchHandler(req: Request): Promise<string> {
|
||||
async function searchHandler(req: HttpRequest): Promise<HttpResponse> {
|
||||
// Get the query params from the url, e.g. '/search?aroma=4&acidity=7'.
|
||||
const aroma = req.getQueryParam("aroma") ?? 8;
|
||||
const flavor = req.getQueryParam("flavor") ?? 8;
|
||||
|
|
@ -19,7 +19,7 @@ async function searchHandler(req: Request): Promise<string> {
|
|||
[+aroma, +flavor, +acid, +sweet],
|
||||
);
|
||||
|
||||
return JSON.stringify(rows);
|
||||
return HttpResponse.json(rows);
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { defineConfig, addPeriodicCallback } from "trailbase-wasm";
|
||||
import { HttpHandler, buildJsonResponse } from "trailbase-wasm/http";
|
||||
import { HttpHandler, JsonResponse } from "trailbase-wasm/http";
|
||||
import { JobHandler } from "trailbase-wasm/job";
|
||||
import { query } from "trailbase-wasm/db";
|
||||
|
||||
|
|
@ -43,7 +43,7 @@ export default defineConfig({
|
|||
});
|
||||
|
||||
function jsonHandler(req) {
|
||||
return buildJsonResponse(
|
||||
return JsonResponse.from(
|
||||
req.json() ?? {
|
||||
int: 5,
|
||||
real: 4.2,
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
import { defineConfig, addPeriodicCallback } from "trailbase-wasm";
|
||||
import { HttpHandler, Request, buildJsonResponse } from "trailbase-wasm/http";
|
||||
import { HttpHandler, HttpRequest, HttpResponse } from "trailbase-wasm/http";
|
||||
import { JobHandler } from "trailbase-wasm/job";
|
||||
import { query } from "trailbase-wasm/db";
|
||||
|
||||
export default defineConfig({
|
||||
httpHandlers: [
|
||||
HttpHandler.get("/fibonacci", (req: Request) => {
|
||||
HttpHandler.get("/fibonacci", (req: HttpRequest) => {
|
||||
const n = req.getQueryParam("n");
|
||||
return fibonacci(n ? parseInt(n) : 40).toString();
|
||||
}),
|
||||
HttpHandler.get("/json", jsonHandler),
|
||||
HttpHandler.post("/json", jsonHandler),
|
||||
HttpHandler.get("/a", (req: Request) => {
|
||||
HttpHandler.get("/a", (req: HttpRequest) => {
|
||||
const n = req.getQueryParam("n");
|
||||
return "a".repeat(n ? parseInt(n) : 5000);
|
||||
}),
|
||||
|
|
@ -25,13 +25,13 @@ export default defineConfig({
|
|||
}
|
||||
});
|
||||
}),
|
||||
HttpHandler.get("/sleep", async (req: Request) => {
|
||||
HttpHandler.get("/sleep", async (req: HttpRequest) => {
|
||||
const param = req.getQueryParam("ms");
|
||||
const ms: number = param ? parseInt(param) : 500;
|
||||
await sleep(ms);
|
||||
return `slept: ${ms}ms`;
|
||||
}),
|
||||
HttpHandler.get("/count/{table}/", async (req: Request) => {
|
||||
HttpHandler.get("/count/{table}/", async (req: HttpRequest) => {
|
||||
const table = req.getPathParam("table");
|
||||
if (table) {
|
||||
const rows = await query(`SELECT COUNT(*) FROM ${table}`, []);
|
||||
|
|
@ -42,8 +42,8 @@ export default defineConfig({
|
|||
jobHandlers: [JobHandler.minutely("myjob", () => console.log("Hello Job!"))],
|
||||
});
|
||||
|
||||
function jsonHandler(req: Request) {
|
||||
return buildJsonResponse(
|
||||
function jsonHandler(req: HttpRequest) {
|
||||
return HttpResponse.json(
|
||||
req.json() ?? {
|
||||
int: 5,
|
||||
real: 4.2,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"name": "trailbase-wasm",
|
||||
"description": "WASM Guest Runtime for custom JS/TS endpoints in TrailBase",
|
||||
"homepage": "https://trailbase.io",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"license": "OSL-3.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
|
|
|||
|
|
@ -7,8 +7,13 @@ import {
|
|||
|
||||
import type { HttpContext } from "@common/HttpContext";
|
||||
import type { HttpHandlerInterface, ResponseType } from "./index";
|
||||
import { HttpError, StatusCode, buildResponse } from "./index";
|
||||
import { type Method, RequestImpl } from "./request";
|
||||
import { StatusCode } from "./index";
|
||||
import {
|
||||
HttpError,
|
||||
responseToOutgoingResponse,
|
||||
errorToOutgoingResponse,
|
||||
} from "./response";
|
||||
import { type Method, HttpRequestImpl } from "./request";
|
||||
import { JobHandlerInterface } from "../job";
|
||||
import { awaitPendingTimers } from "../timer";
|
||||
|
||||
|
|
@ -53,7 +58,7 @@ export function buildIncomingHttpHandler(args: {
|
|||
}
|
||||
|
||||
return await handler(
|
||||
new RequestImpl(
|
||||
new HttpRequestImpl(
|
||||
wasiMethodToMethod(req.method()),
|
||||
req.pathWithQuery() ?? "",
|
||||
context.path_params,
|
||||
|
|
@ -70,30 +75,10 @@ export function buildIncomingHttpHandler(args: {
|
|||
return async function (req: IncomingRequest, respOutparam: ResponseOutparam) {
|
||||
try {
|
||||
const resp: ResponseType = await handle(req);
|
||||
writeResponse(
|
||||
respOutparam,
|
||||
resp instanceof OutgoingResponse
|
||||
? resp
|
||||
: buildResponse(
|
||||
resp instanceof Uint8Array ? resp : encodeBytes(resp ?? ""),
|
||||
),
|
||||
);
|
||||
const outgoingResp = responseToOutgoingResponse(resp);
|
||||
writeResponse(respOutparam, outgoingResp);
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError) {
|
||||
writeResponse(
|
||||
respOutparam,
|
||||
buildResponse(encodeBytes(`${err.message}\n`), {
|
||||
status: err.statusCode,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
writeResponse(
|
||||
respOutparam,
|
||||
buildResponse(encodeBytes(`Other: ${err}\n`), {
|
||||
status: StatusCode.INTERNAL_SERVER_ERROR,
|
||||
}),
|
||||
);
|
||||
}
|
||||
writeResponse(respOutparam, errorToOutgoingResponse(err));
|
||||
} finally {
|
||||
await awaitPendingTimers();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
import { Fields, OutgoingBody, OutgoingResponse } from "wasi:http/types@0.2.3";
|
||||
|
||||
import { StatusCode } from "./status";
|
||||
import { Request, type Method } from "./request";
|
||||
import { encodeBytes } from "./incoming";
|
||||
import { HttpRequest } from "./request";
|
||||
import type { Method } from "./request";
|
||||
import type { ResponseType } from "./response";
|
||||
|
||||
// Override setInterval/setTimeout.
|
||||
import "../timer";
|
||||
|
||||
// Exports:
|
||||
export { OutgoingResponse } from "wasi:http/types@0.2.3";
|
||||
export { StatusCode } from "./status";
|
||||
export type { Method, Request, Scheme, User } from "./request";
|
||||
export type { Method, HttpRequest, Scheme, User } from "./request";
|
||||
export { HttpResponse, HttpError } from "./response";
|
||||
export type { ResponseType } from "./response";
|
||||
|
||||
export type ResponseType = string | Uint8Array | OutgoingResponse | void;
|
||||
export type HttpHandlerCallback = (
|
||||
req: Request,
|
||||
req: HttpRequest,
|
||||
) => ResponseType | Promise<ResponseType>;
|
||||
|
||||
export interface HttpHandlerInterface {
|
||||
|
|
@ -57,158 +57,3 @@ export class HttpHandler implements HttpHandlerInterface {
|
|||
return new HttpHandler(path, "put", handler);
|
||||
}
|
||||
}
|
||||
|
||||
export class HttpError extends Error {
|
||||
readonly statusCode: number;
|
||||
readonly headers: [string, string][] | undefined;
|
||||
|
||||
constructor(
|
||||
statusCode: number,
|
||||
message?: string,
|
||||
headers?: [string, string][],
|
||||
) {
|
||||
super(message);
|
||||
this.statusCode = statusCode;
|
||||
this.headers = headers;
|
||||
}
|
||||
|
||||
public override toString(): string {
|
||||
return `HttpError(${this.statusCode}, ${this.message})`;
|
||||
}
|
||||
}
|
||||
|
||||
export type ResponseOptions = {
|
||||
status?: StatusCode;
|
||||
headers?: [string, Uint8Array][];
|
||||
};
|
||||
|
||||
export function buildJsonResponse(
|
||||
body: object,
|
||||
opts?: ResponseOptions,
|
||||
): OutgoingResponse {
|
||||
return buildResponse(encodeBytes(JSON.stringify(body)), {
|
||||
...opts,
|
||||
|
||||
headers: [
|
||||
["Content-Type", encodeBytes("application/json")],
|
||||
...(opts?.headers ?? []),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export function buildResponse(
|
||||
body: Uint8Array,
|
||||
opts?: ResponseOptions,
|
||||
): OutgoingResponse {
|
||||
// NOTE: `outputStream.blockingWriteAndFlush` only writes up to 4kB, see documentation.
|
||||
if (body.length <= 4096) {
|
||||
return buildSmallResponse(body, opts);
|
||||
}
|
||||
return buildLargeResponse(body, opts);
|
||||
}
|
||||
|
||||
function buildSmallResponse(
|
||||
body: Uint8Array,
|
||||
opts?: ResponseOptions,
|
||||
): OutgoingResponse {
|
||||
const outgoingResponse = new OutgoingResponse(
|
||||
Fields.fromList(opts?.headers ?? []),
|
||||
);
|
||||
|
||||
const outgoingBody = outgoingResponse.body();
|
||||
{
|
||||
// Create a stream for the response body
|
||||
const outputStream = outgoingBody.write();
|
||||
outputStream.blockingWriteAndFlush(body);
|
||||
|
||||
outputStream[Symbol.dispose]?.();
|
||||
}
|
||||
|
||||
outgoingResponse.setStatusCode(opts?.status ?? StatusCode.OK);
|
||||
|
||||
OutgoingBody.finish(outgoingBody, undefined);
|
||||
|
||||
return outgoingResponse;
|
||||
}
|
||||
|
||||
function buildLargeResponse(
|
||||
body: Uint8Array,
|
||||
opts?: ResponseOptions,
|
||||
): OutgoingResponse {
|
||||
const outgoingResponse = new OutgoingResponse(
|
||||
Fields.fromList(opts?.headers ?? []),
|
||||
);
|
||||
|
||||
const outgoingBody = outgoingResponse.body();
|
||||
{
|
||||
const outputStream = outgoingBody.write();
|
||||
|
||||
// Retrieve a Preview 2 I/O pollable to coordinate writing to the output stream
|
||||
const pollable = outputStream.subscribe();
|
||||
|
||||
let written = 0n;
|
||||
let remaining = BigInt(body.length);
|
||||
while (remaining > 0) {
|
||||
// Wait for the stream to become writable
|
||||
pollable.block();
|
||||
|
||||
// Get the amount of bytes that we're allowed to write
|
||||
let writableByteCount = outputStream.checkWrite();
|
||||
if (remaining <= writableByteCount) {
|
||||
writableByteCount = BigInt(remaining);
|
||||
}
|
||||
|
||||
// If we are not allowed to write any more, but there are still bytes
|
||||
// remaining then flush and try again
|
||||
if (writableByteCount === 0n && remaining !== 0n) {
|
||||
outputStream.flush();
|
||||
continue;
|
||||
}
|
||||
|
||||
outputStream.write(
|
||||
new Uint8Array(body.buffer, Number(written), Number(writableByteCount)),
|
||||
);
|
||||
written += writableByteCount;
|
||||
remaining -= written;
|
||||
|
||||
// While we can track *when* to flush separately and implement our own logic,
|
||||
// the simplest way is to flush the written chunk immediately
|
||||
outputStream.flush();
|
||||
}
|
||||
|
||||
pollable[Symbol.dispose]?.();
|
||||
outputStream[Symbol.dispose]?.();
|
||||
}
|
||||
|
||||
outgoingResponse.setStatusCode(opts?.status ?? StatusCode.OK);
|
||||
|
||||
OutgoingBody.finish(outgoingBody, undefined);
|
||||
|
||||
return outgoingResponse;
|
||||
}
|
||||
|
||||
// function writeResponseOriginal(
|
||||
// responseOutparam: ResponseOutparam,
|
||||
// status: number,
|
||||
// body: Uint8Array,
|
||||
// ) {
|
||||
// /* eslint-disable prefer-const */
|
||||
// const outgoingResponse = new OutgoingResponse(new Fields());
|
||||
//
|
||||
// let outgoingBody = outgoingResponse.body();
|
||||
// {
|
||||
// // Create a stream for the response body
|
||||
// let outputStream = outgoingBody.write();
|
||||
// outputStream.blockingWriteAndFlush(body);
|
||||
//
|
||||
// // eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// // @ts-ignore: This is required in order to dispose the stream before we return
|
||||
// outputStream[Symbol.dispose]();
|
||||
// //outputStream[Symbol.dispose]?.();
|
||||
// }
|
||||
//
|
||||
// outgoingResponse.setStatusCode(status);
|
||||
// OutgoingBody.finish(outgoingBody, undefined);
|
||||
//
|
||||
// ResponseOutparam.set(responseOutparam, { tag: "ok", val: outgoingResponse });
|
||||
// }
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export type User = {
|
|||
csrf: string;
|
||||
};
|
||||
|
||||
export interface Request {
|
||||
export interface HttpRequest {
|
||||
path(): string;
|
||||
method(): Method;
|
||||
scheme(): Scheme | undefined;
|
||||
|
|
@ -36,7 +36,7 @@ export interface Request {
|
|||
json(): object | undefined;
|
||||
}
|
||||
|
||||
export class RequestImpl implements Request {
|
||||
export class HttpRequestImpl implements HttpRequest {
|
||||
constructor(
|
||||
private readonly _method: Method,
|
||||
private readonly _path: string,
|
||||
|
|
|
|||
219
guests/typescript/src/http/response.ts
Normal file
219
guests/typescript/src/http/response.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
import { Fields, OutgoingBody, OutgoingResponse } from "wasi:http/types@0.2.3";
|
||||
import { StatusCode } from "./status";
|
||||
import { encodeBytes } from "./incoming";
|
||||
|
||||
export type ResponseType =
|
||||
| string
|
||||
| Uint8Array
|
||||
| HttpResponse
|
||||
| OutgoingResponse
|
||||
| void;
|
||||
|
||||
export class HttpResponse {
|
||||
protected constructor(
|
||||
public readonly status: StatusCode,
|
||||
public body?: Uint8Array,
|
||||
public headers: [string, Uint8Array][] = [],
|
||||
) {}
|
||||
|
||||
public static status(
|
||||
status: StatusCode | number,
|
||||
body?: string | Uint8Array,
|
||||
): HttpResponse {
|
||||
return new HttpResponse(
|
||||
status,
|
||||
typeof body === "string" ? encodeBytes(body) : body,
|
||||
);
|
||||
}
|
||||
|
||||
public static ok(body?: string | Uint8Array): HttpResponse {
|
||||
return new HttpResponse(
|
||||
StatusCode.OK,
|
||||
typeof body === "string" ? encodeBytes(body) : body,
|
||||
);
|
||||
}
|
||||
|
||||
public static text(body: string | Uint8Array): HttpResponse {
|
||||
return new HttpResponse(
|
||||
StatusCode.OK,
|
||||
typeof body === "string" ? encodeBytes(body) : body,
|
||||
[["Content-Type", encodeBytes("text/plain; charset=utf-8")]],
|
||||
);
|
||||
}
|
||||
|
||||
public static json(value: object): HttpResponse {
|
||||
return new HttpResponse(StatusCode.OK, encodeBytes(JSON.stringify(value)), [
|
||||
["Content-Type", encodeBytes("application/json")],
|
||||
]);
|
||||
}
|
||||
|
||||
public setBody(body: string | Uint8Array): HttpResponse {
|
||||
this.body = typeof body === "string" ? encodeBytes(body) : body;
|
||||
return this;
|
||||
}
|
||||
|
||||
public setHeader(key: string, value: string | Uint8Array): HttpResponse {
|
||||
this.headers.push([
|
||||
key,
|
||||
typeof value === "string" ? encodeBytes(value) : value,
|
||||
]);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
export class HttpError extends Error {
|
||||
public constructor(
|
||||
public readonly status: StatusCode,
|
||||
message?: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public static from(status: StatusCode | number, message?: string): HttpError {
|
||||
return new HttpError(status, message);
|
||||
}
|
||||
|
||||
public override toString(): string {
|
||||
return `HttpError(${this.status}, ${this.message})`;
|
||||
}
|
||||
}
|
||||
|
||||
export function responseToOutgoingResponse(
|
||||
resp: ResponseType,
|
||||
): OutgoingResponse {
|
||||
if (resp instanceof OutgoingResponse) {
|
||||
return resp;
|
||||
} else if (resp instanceof HttpResponse) {
|
||||
return buildResponse({
|
||||
status: resp.status,
|
||||
headers: resp.headers,
|
||||
body: resp.body ?? new Uint8Array(),
|
||||
});
|
||||
} else if (resp instanceof Uint8Array) {
|
||||
return buildResponse({
|
||||
status: StatusCode.OK,
|
||||
headers: [],
|
||||
body: resp,
|
||||
});
|
||||
} else if (typeof resp === "string") {
|
||||
return buildResponse({
|
||||
status: StatusCode.OK,
|
||||
headers: [],
|
||||
body: encodeBytes(resp),
|
||||
});
|
||||
} else {
|
||||
// void case.
|
||||
return buildResponse({
|
||||
status: StatusCode.OK,
|
||||
headers: [],
|
||||
body: new Uint8Array(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function errorToOutgoingResponse(err: unknown): OutgoingResponse {
|
||||
if (err instanceof HttpError) {
|
||||
return buildResponse({
|
||||
status: err.status,
|
||||
headers: [["Content-Type", encodeBytes("text/plain; charset=utf-8")]],
|
||||
body: err.message ? encodeBytes(err.message) : new Uint8Array(),
|
||||
});
|
||||
}
|
||||
return buildResponse({
|
||||
body: encodeBytes(`uncaught: ${err}`),
|
||||
status: StatusCode.INTERNAL_SERVER_ERROR,
|
||||
headers: [],
|
||||
});
|
||||
}
|
||||
|
||||
type ResponseOptions = {
|
||||
status: StatusCode;
|
||||
headers: [string, Uint8Array][];
|
||||
body: Uint8Array;
|
||||
};
|
||||
|
||||
function buildResponse(opts: ResponseOptions): OutgoingResponse {
|
||||
// NOTE: `outputStream.blockingWriteAndFlush` only writes up to 4kB, see documentation.
|
||||
if (opts.body.length <= 4096) {
|
||||
return buildSmallResponse(opts);
|
||||
}
|
||||
return buildLargeResponse(opts);
|
||||
}
|
||||
|
||||
function buildSmallResponse({
|
||||
status,
|
||||
headers,
|
||||
body,
|
||||
}: ResponseOptions): OutgoingResponse {
|
||||
const outgoingResponse = new OutgoingResponse(Fields.fromList(headers));
|
||||
|
||||
const outgoingBody = outgoingResponse.body();
|
||||
{
|
||||
// Create a stream for the response body
|
||||
const outputStream = outgoingBody.write();
|
||||
outputStream.blockingWriteAndFlush(body);
|
||||
|
||||
outputStream[Symbol.dispose]?.();
|
||||
}
|
||||
|
||||
outgoingResponse.setStatusCode(status);
|
||||
|
||||
OutgoingBody.finish(outgoingBody, undefined);
|
||||
|
||||
return outgoingResponse;
|
||||
}
|
||||
|
||||
function buildLargeResponse({
|
||||
status,
|
||||
headers,
|
||||
body,
|
||||
}: ResponseOptions): OutgoingResponse {
|
||||
const outgoingResponse = new OutgoingResponse(Fields.fromList(headers));
|
||||
|
||||
const outgoingBody = outgoingResponse.body();
|
||||
{
|
||||
const outputStream = outgoingBody.write();
|
||||
|
||||
// Retrieve a Preview 2 I/O pollable to coordinate writing to the output stream
|
||||
const pollable = outputStream.subscribe();
|
||||
|
||||
let written = 0n;
|
||||
let remaining = BigInt(body.length);
|
||||
while (remaining > 0) {
|
||||
// Wait for the stream to become writable
|
||||
pollable.block();
|
||||
|
||||
// Get the amount of bytes that we're allowed to write
|
||||
let writableByteCount = outputStream.checkWrite();
|
||||
if (remaining <= writableByteCount) {
|
||||
writableByteCount = BigInt(remaining);
|
||||
}
|
||||
|
||||
// If we are not allowed to write any more, but there are still bytes
|
||||
// remaining then flush and try again
|
||||
if (writableByteCount === 0n && remaining !== 0n) {
|
||||
outputStream.flush();
|
||||
continue;
|
||||
}
|
||||
|
||||
outputStream.write(
|
||||
new Uint8Array(body.buffer, Number(written), Number(writableByteCount)),
|
||||
);
|
||||
written += writableByteCount;
|
||||
remaining -= written;
|
||||
|
||||
// While we can track *when* to flush separately and implement our own logic,
|
||||
// the simplest way is to flush the written chunk immediately
|
||||
outputStream.flush();
|
||||
}
|
||||
|
||||
pollable[Symbol.dispose]?.();
|
||||
outputStream[Symbol.dispose]?.();
|
||||
}
|
||||
|
||||
outgoingResponse.setStatusCode(status);
|
||||
|
||||
OutgoingBody.finish(outgoingBody, undefined);
|
||||
|
||||
return outgoingResponse;
|
||||
}
|
||||
|
|
@ -29,8 +29,8 @@ export default defineConfig({
|
|||
// NOTE: Needs to be in `rollupOptions` rather than vite's plugins to apply to each input.
|
||||
dts({
|
||||
rollupTypes: true,
|
||||
// NOTE: Inlucde .d.ts files generated by `jco`.
|
||||
copyDtsFiles: true,
|
||||
// NOTE: Include .d.ts files generated by `jco`.
|
||||
copyDtsFiles: false,
|
||||
// NOTE: The .d.ts files generated by `jco` contain dynamic imports.
|
||||
staticImport: true,
|
||||
}),
|
||||
|
|
|
|||
544
pnpm-lock.yaml
544
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -5,6 +5,7 @@ packages:
|
|||
- 'crates/auth-ui/ui'
|
||||
- 'docs'
|
||||
- 'docs/examples/record_api_ts'
|
||||
- 'docs/examples/wasm-guest-ts'
|
||||
- 'examples/blog/web'
|
||||
- 'examples/coffee-vector-search'
|
||||
- 'examples/coffee-vector-search/guests/typescript'
|
||||
|
|
|
|||
Loading…
Reference in a new issue