From ee3f3f79e3cd0fa6df1866b4b8018605d8c6f0ad Mon Sep 17 00:00:00 2001 From: Jason Larabie Date: Fri, 1 May 2026 14:23:59 -0700 Subject: [PATCH 1/5] Initial commit for TypeScript http handlers mirrored from Rust --- .../src/lib/autogen/types.ts | 46 ++-- .../bindings-typescript/src/lib/http_types.ts | 13 +- crates/bindings-typescript/src/lib/schema.ts | 20 +- .../src/server/http.test-d.ts | 59 +++++ crates/bindings-typescript/src/server/http.ts | 25 +- .../src/server/http_api.ts | 204 +++++++++++++++ .../src/server/http_handlers.ts | 241 ++++++------------ .../src/server/http_internal.ts | 27 +- .../src/server/http_router.ts | 182 +++++++++++++ .../src/server/http_wire.ts | 83 ++++++ .../bindings-typescript/src/server/index.ts | 12 +- .../bindings-typescript/src/server/runtime.ts | 108 +++++++- .../bindings-typescript/src/server/schema.ts | 76 +++++- .../bindings-typescript/src/server/views.ts | 1 + 14 files changed, 866 insertions(+), 231 deletions(-) create mode 100644 crates/bindings-typescript/src/server/http.test-d.ts create mode 100644 crates/bindings-typescript/src/server/http_api.ts create mode 100644 crates/bindings-typescript/src/server/http_router.ts create mode 100644 crates/bindings-typescript/src/server/http_wire.ts diff --git a/crates/bindings-typescript/src/lib/autogen/types.ts b/crates/bindings-typescript/src/lib/autogen/types.ts index 068bf3c95ea..c0855af29bf 100644 --- a/crates/bindings-typescript/src/lib/autogen/types.ts +++ b/crates/bindings-typescript/src/lib/autogen/types.ts @@ -107,15 +107,6 @@ export const HttpMethod = __t.enum('HttpMethod', { }); export type HttpMethod = __Infer; -// The tagged union or sum type for the algebraic type `MethodOrAny`. -export const MethodOrAny = __t.enum('MethodOrAny', { - Any: __t.unit(), - get Method() { - return HttpMethod; - }, -}); -export type MethodOrAny = __Infer; - export const HttpRequest = __t.object('HttpRequest', { get method() { return HttpMethod; @@ -167,6 +158,15 @@ export const Lifecycle = __t.enum('Lifecycle', { }); export type Lifecycle = __Infer; +// The tagged union or sum type for the algebraic type `MethodOrAny`. +export const MethodOrAny = __t.enum('MethodOrAny', { + Any: __t.unit(), + get Method() { + return HttpMethod; + }, +}); +export type MethodOrAny = __Infer; + // The tagged union or sum type for the algebraic type `MiscModuleExport`. export const MiscModuleExport = __t.enum('MiscModuleExport', { get TypeAlias() { @@ -248,6 +248,20 @@ export const RawConstraintDefV9 = __t.object('RawConstraintDefV9', { }); export type RawConstraintDefV9 = __Infer; +export const RawHttpHandlerDefV10 = __t.object('RawHttpHandlerDefV10', { + sourceName: __t.string(), +}); +export type RawHttpHandlerDefV10 = __Infer; + +export const RawHttpRouteDefV10 = __t.object('RawHttpRouteDefV10', { + handlerFunction: __t.string(), + get method() { + return MethodOrAny; + }, + path: __t.string(), +}); +export type RawHttpRouteDefV10 = __Infer; + // The tagged union or sum type for the algebraic type `RawIndexAlgorithm`. export const RawIndexAlgorithm = __t.enum('RawIndexAlgorithm', { BTree: __t.array(__t.u16()), @@ -332,20 +346,6 @@ export const RawModuleDefV10 = __t.object('RawModuleDefV10', { }); export type RawModuleDefV10 = __Infer; -export const RawHttpHandlerDefV10 = __t.object('RawHttpHandlerDefV10', { - sourceName: __t.string(), -}); -export type RawHttpHandlerDefV10 = __Infer; - -export const RawHttpRouteDefV10 = __t.object('RawHttpRouteDefV10', { - handlerFunction: __t.string(), - get method() { - return MethodOrAny; - }, - path: __t.string(), -}); -export type RawHttpRouteDefV10 = __Infer; - // The tagged union or sum type for the algebraic type `RawModuleDefV10Section`. export const RawModuleDefV10Section = __t.enum('RawModuleDefV10Section', { get Typespace() { diff --git a/crates/bindings-typescript/src/lib/http_types.ts b/crates/bindings-typescript/src/lib/http_types.ts index 914a6f789ac..74542cc6a13 100644 --- a/crates/bindings-typescript/src/lib/http_types.ts +++ b/crates/bindings-typescript/src/lib/http_types.ts @@ -1,8 +1,19 @@ -export { +import { HttpHeaderPair, HttpHeaders, HttpMethod, HttpRequest, HttpResponse, HttpVersion, + MethodOrAny, } from './autogen/types'; + +export { + HttpHeaderPair, + HttpHeaders, + HttpMethod, + HttpRequest, + HttpResponse, + HttpVersion, + MethodOrAny, +}; diff --git a/crates/bindings-typescript/src/lib/schema.ts b/crates/bindings-typescript/src/lib/schema.ts index b5e74e19fef..ab480c93db5 100644 --- a/crates/bindings-typescript/src/lib/schema.ts +++ b/crates/bindings-typescript/src/lib/schema.ts @@ -189,14 +189,14 @@ export class ModuleContext { typespace: { types: [] }, tables: [], reducers: [], - httpHandlers: [], - httpRoutes: [], types: [], rowLevelSecurity: [], schedules: [], procedures: [], views: [], lifeCycleReducers: [], + httpHandlers: [], + httpRoutes: [], caseConversionPolicy: { tag: 'SnakeCase' }, explicitNames: { entries: [], @@ -221,6 +221,14 @@ export class ModuleContext { push(module.tables && { tag: 'Tables', value: module.tables }); push(module.reducers && { tag: 'Reducers', value: module.reducers }); push(module.procedures && { tag: 'Procedures', value: module.procedures }); + push(module.views && { tag: 'Views', value: module.views }); + push(module.schedules && { tag: 'Schedules', value: module.schedules }); + push( + module.lifeCycleReducers && { + tag: 'LifeCycleReducers', + value: module.lifeCycleReducers, + } + ); push( module.httpHandlers && { tag: 'HttpHandlers', @@ -233,14 +241,6 @@ export class ModuleContext { value: module.httpRoutes, } ); - push(module.views && { tag: 'Views', value: module.views }); - push(module.schedules && { tag: 'Schedules', value: module.schedules }); - push( - module.lifeCycleReducers && { - tag: 'LifeCycleReducers', - value: module.lifeCycleReducers, - } - ); push( module.rowLevelSecurity && { tag: 'RowLevelSecurity', diff --git a/crates/bindings-typescript/src/server/http.test-d.ts b/crates/bindings-typescript/src/server/http.test-d.ts new file mode 100644 index 00000000000..4219cf690a5 --- /dev/null +++ b/crates/bindings-typescript/src/server/http.test-d.ts @@ -0,0 +1,59 @@ +import { table } from '../lib/table'; +import t from '../lib/type_builders'; +import { + type HandlerContext, + Request, + Response, + Router, + schema, +} from './index'; + +const person = table( + {}, + { + id: t.u32().primaryKey(), + name: t.string(), + } +); + +const stdb = schema({ person }); + +const hello = stdb.httpHandler((ctx, req) => { + void ctx.identity; + void ctx.random; + req.text(); + req.json(); + + ctx.withTx(tx => { + tx.db.person.insert({ id: 1, name: 'alice' }); + }); + + return new Response('hello', { + headers: { 'content-type': 'text/plain' }, + status: 200, + }); +}); + +const _typedHello: (ctx: HandlerContext, req: Request) => Response = ( + ctx, + req +) => { + void ctx.timestamp; + return new Response(req.text()); +}; + +const routes = stdb.httpRouter( + Router.new() + .get('/hello', hello) + .post('/hello-post', hello) + .nest('/api', Router.new().any('/v1', hello)) + .merge(Router.new().get('', hello)) +); + +void routes; + +// @ts-expect-error handlers must return Response +stdb.httpHandler((_ctx, _req) => 123); + +// @ts-expect-error handlers must take a Request as the second argument +stdb.httpHandler((_ctx, _req: number) => new Response('bad')); diff --git a/crates/bindings-typescript/src/server/http.ts b/crates/bindings-typescript/src/server/http.ts index 1a2595ed4f1..15ce11e5b8d 100644 --- a/crates/bindings-typescript/src/server/http.ts +++ b/crates/bindings-typescript/src/server/http.ts @@ -1,2 +1,23 @@ -export { Headers, SyncResponse } from './http_internal'; -export type { BodyInit, HeadersInit, ResponseInit } from './http_internal'; +export { + Headers, + Request, + Response, + type BodyInit, + type HeadersInit, + type RequestInit, + type ResponseInit, +} from './http_api'; +export { + type HandlerContext, + type HandlerFn, + type HttpHandlerExport, + type HttpHandlerOpts, + makeHttpHandlerExport, + makeHttpRouterExport, +} from './http_handlers'; +export { Router } from './http_router'; +export { + makeHandlerHttpClient, + requestFromWire, + responseIntoWire, +} from './http_wire'; diff --git a/crates/bindings-typescript/src/server/http_api.ts b/crates/bindings-typescript/src/server/http_api.ts new file mode 100644 index 00000000000..a6044b074a9 --- /dev/null +++ b/crates/bindings-typescript/src/server/http_api.ts @@ -0,0 +1,204 @@ +import { Headers } from 'headers-polyfill'; +import status from 'statuses'; +import type { HttpVersion } from '../lib/http_types'; + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder('utf-8'); + +export { Headers }; + +export type BodyInit = ArrayBuffer | ArrayBufferView | string; +export type HeadersInit = [string, string][] | Record | Headers; + +export interface RequestInit { + body?: BodyInit | null; + headers?: HeadersInit; + method?: string; + version?: HttpVersion; +} + +export interface ResponseInit { + headers?: HeadersInit; + status?: number; + statusText?: string; + version?: HttpVersion; +} + +type RequestInner = { + headers: Headers; + method: string; + uri: string; + version: HttpVersion; +}; + +type ResponseInner = { + headers: Headers; + status: number; + statusText: string; + version: HttpVersion; +}; + +export const makeRequest = Symbol('makeRequest'); +export const makeResponse = Symbol('makeResponse'); + +export function coerceBody( + body?: BodyInit | null +): string | ArrayBuffer | null { + if (body == null) { + return null; + } + if (typeof body === 'string') { + return body; + } + // TODO(http): This currently drops byteOffset/byteLength for ArrayBufferView + // inputs and can widen a sliced view to its full backing buffer. Fix in + // both http_api.ts and http_internal.ts together so inbound/outbound HTTP + // body handling stays aligned. + return new Uint8Array(body as any).buffer; +} + +export function bodyToBytes( + body: string | ArrayBuffer | null +): Uint8Array { + if (body == null) { + return new Uint8Array(); + } + if (typeof body === 'string') { + return textEncoder.encode(body); + } + return new Uint8Array(body); +} + +export function bodyToText(body: string | ArrayBuffer | null): string { + if (body == null) { + return ''; + } + if (typeof body === 'string') { + return body; + } + return textDecoder.decode(body); +} + +function defaultStatusText(code: number) { + try { + return status(code); + } catch { + return ''; + } +} + +export class Request { + #body: string | ArrayBuffer | null; + #inner: RequestInner; + + constructor(url: URL | string, init: RequestInit = {}) { + this.#body = coerceBody(init.body); + this.#inner = { + headers: new Headers(init.headers as any), + method: init.method?.toUpperCase() ?? 'GET', + uri: '' + url, + version: init.version ?? { tag: 'Http11' }, + }; + } + + static [makeRequest](body: BodyInit | null, inner: RequestInner) { + const me = new Request(inner.uri); + me.#body = coerceBody(body); + me.#inner = inner; + return me; + } + + get headers(): Headers { + return this.#inner.headers; + } + + get method(): string { + return this.#inner.method; + } + + get uri(): string { + return this.#inner.uri; + } + + get url(): string { + return this.#inner.uri; + } + + get version(): HttpVersion { + return this.#inner.version; + } + + arrayBuffer(): ArrayBuffer { + return this.bytes().buffer; + } + + bytes(): Uint8Array { + return bodyToBytes(this.#body); + } + + json(): any { + return JSON.parse(this.text()); + } + + text(): string { + return bodyToText(this.#body); + } +} + +export class Response { + #body: string | ArrayBuffer | null; + #inner: ResponseInner; + + constructor(body?: BodyInit | null, init?: ResponseInit) { + this.#body = coerceBody(body); + const statusCode = init?.status ?? 200; + this.#inner = { + headers: new Headers(init?.headers as any), + status: statusCode, + statusText: init?.statusText ?? defaultStatusText(statusCode), + version: init?.version ?? { tag: 'Http11' }, + }; + } + + static [makeResponse](body: BodyInit | null, inner: ResponseInner) { + const me = new Response(body); + me.#inner = inner; + return me; + } + + get headers(): Headers { + return this.#inner.headers; + } + + get status(): number { + return this.#inner.status; + } + + get statusText() { + return this.#inner.statusText; + } + + get ok(): boolean { + return 200 <= this.#inner.status && this.#inner.status <= 299; + } + + get version(): HttpVersion { + return this.#inner.version; + } + + arrayBuffer(): ArrayBuffer { + return this.bytes().buffer; + } + + bytes(): Uint8Array { + return bodyToBytes(this.#body); + } + + json(): any { + return JSON.parse(this.text()); + } + + text(): string { + return bodyToText(this.#body); + } +} diff --git a/crates/bindings-typescript/src/server/http_handlers.ts b/crates/bindings-typescript/src/server/http_handlers.ts index 46a2d7051d0..7f5f0070ed4 100644 --- a/crates/bindings-typescript/src/server/http_handlers.ts +++ b/crates/bindings-typescript/src/server/http_handlers.ts @@ -1,192 +1,107 @@ -import { - HttpRequest, - HttpResponse, - type MethodOrAny, - type HttpMethod, -} from '../lib/autogen/types'; -import BinaryReader from '../lib/binary_reader'; -import BinaryWriter from '../lib/binary_writer'; -import { bsatnBaseSize } from '../lib/util'; -import { - Headers, - SyncResponse, - deserializeHeaders, - serializeHeaders, -} from './http_internal'; +import type { Identity } from '../lib/identity'; +import type { UntypedSchemaDef } from '../lib/schema'; +import type { Timestamp } from '../lib/timestamp'; +import type { Uuid } from '../lib/uuid'; +import type { TransactionCtx } from './procedures'; +import type { HttpClient } from './http_internal'; +import type { Random } from './rng'; import { exportContext, registerExport, type ModuleExport, type SchemaInner, } from './schema'; - -export interface Request { - readonly method: string; - readonly url: string; - readonly headers: Headers; - readonly body: Uint8Array; +import type { Request, Response } from './http_api'; +import type { Router } from './http_router'; + +export interface HandlerContext { + readonly timestamp: Timestamp; + readonly http: HttpClient; + readonly identity: Identity; + readonly random: Random; + withTx(body: (ctx: TransactionCtx) => T): T; + newUuidV4(): Uuid; + newUuidV7(): Uuid; } -export type HttpHandler = (request: Request) => SyncResponse; -export type HttpHandlerExport = HttpHandler & ModuleExport; - -type HttpMethodName = 'GET' | 'POST'; - -type Route = { - method: HttpMethodName; - path: string; - handler: HttpHandlerExport; -}; +export type HandlerFn = ( + ctx: HandlerContext, + req: Request +) => Response; -export type HttpHandlers = HttpHandler[]; +export const httpHandlerFn = Symbol('SpacetimeDB.httpHandlerFn'); -const responseBaseSize = bsatnBaseSize( - { types: [] }, - HttpResponse.algebraicType -); - -const routesSymbol = Symbol('SpacetimeDB.http.routes'); -const httpHandlerSymbol = Symbol('SpacetimeDB.http.handler'); - -type RouterWithRoutes = Router & { - [routesSymbol]: Route[]; -}; - -const METHODS: Record = { - GET: { tag: 'Method', value: { tag: 'Get' } }, - POST: { tag: 'Method', value: { tag: 'Post' } }, -}; - -function methodToString(method: HttpMethod): string { - switch (method.tag) { - case 'Get': - return 'GET'; - case 'Head': - return 'HEAD'; - case 'Post': - return 'POST'; - case 'Put': - return 'PUT'; - case 'Delete': - return 'DELETE'; - case 'Connect': - return 'CONNECT'; - case 'Options': - return 'OPTIONS'; - case 'Trace': - return 'TRACE'; - case 'Patch': - return 'PATCH'; - case 'Extension': - return method.value; - } +export interface HttpHandlerExport< + S extends UntypedSchemaDef = UntypedSchemaDef, +> extends ModuleExport { + [httpHandlerFn]: HandlerFn; } -export class Router { - [routesSymbol]: Route[] = []; +const exportedHttpHandlerObjects = new WeakSet(); - get(path: string, handler: HttpHandlerExport): this { - // TODO(v8-http-handlers): Validate route path and duplicate registrations. - this[routesSymbol].push({ method: 'GET', path, handler }); - return this; - } - - post(path: string, handler: HttpHandlerExport): this { - // TODO(v8-http-handlers): Validate route path and duplicate registrations. - this[routesSymbol].push({ method: 'POST', path, handler }); - return this; - } +export interface HttpHandlerOpts { + name: string; } -function registerHttpHandler( +export function makeHttpHandlerExport( ctx: SchemaInner, - exportName: string, - fn: HttpHandler -): void { - ctx.defineFunction(exportName); - ctx.moduleDef.httpHandlers.push({ sourceName: exportName }); - ctx.httpHandlers.push(fn); -} - -function makeHttpHandlerExport( - ctx: SchemaInner, - fn: HttpHandler -): HttpHandlerExport { - const handlerExport = fn as HttpHandlerExport & { - [httpHandlerSymbol]?: true; - }; - - handlerExport[exportContext] = ctx; - handlerExport[httpHandlerSymbol] = true; - handlerExport[registerExport] = (ctx, exportName) => { - // TODO(v8-http-handlers): Reject duplicate registration of the same function object. - registerHttpHandler(ctx, exportName, fn); - ctx.functionExports.set(handlerExport, exportName); + opts: HttpHandlerOpts | undefined, + fn: HandlerFn +): HttpHandlerExport { + const handlerExport = { + [httpHandlerFn]: fn, + [exportContext]: ctx, + [registerExport](ctx: SchemaInner, exportName: string) { + if (exportedHttpHandlerObjects.has(handlerExport)) { + throw new TypeError( + `HTTP handler '${exportName}' was exported more than once` + ); + } + exportedHttpHandlerObjects.add(handlerExport); + registerHttpHandler(ctx, exportName, fn, opts); + ctx.httpHandlerExports.set( + handlerExport as HttpHandlerExport, + exportName + ); + }, }; - - return handlerExport; + return handlerExport as HttpHandlerExport; } -function makeHttpRouterExport(ctx: SchemaInner, router: Router): ModuleExport { +export function makeHttpRouterExport( + ctx: SchemaInner, + router: Router +): ModuleExport { return { [exportContext]: ctx, - [registerExport](ctx, _exportName) { - for (const route of (router as RouterWithRoutes)[routesSymbol]) { - // TODO(v8-http-handlers): Verify that handlers referenced by routers come from the same schema. - const handlerName = ctx.functionExports.get(route.handler); - if (handlerName === undefined) { - throw new TypeError( - 'HTTP router references a handler that was not exported as an HTTP handler' - ); - } - ctx.moduleDef.httpRoutes.push({ - handlerFunction: handlerName, - method: METHODS[route.method], - path: route.path, - }); - } + [registerExport](ctx: SchemaInner) { + ctx.pendingHttpRoutes.push(...router.intoRoutes()); }, }; } -export function makeHttpNamespace(ctx: SchemaInner) { - return Object.freeze({ - Router, - handler(fn: HttpHandler): HttpHandlerExport { - return makeHttpHandlerExport(ctx, fn); - }, - router(router: Router): ModuleExport { - if (!(router instanceof Router)) { - throw new TypeError('spacetime.http.router expects a Router instance'); - } - return makeHttpRouterExport(ctx, router); - }, - }); -} +function registerHttpHandler( + ctx: SchemaInner, + exportName: string, + fn: HandlerFn, + opts?: HttpHandlerOpts +) { + ctx.defineHttpHandler(exportName); + ctx.moduleDef.httpHandlers.push({ sourceName: exportName }); -export function deserializeHttpHandlerRequest( - requestBuf: Uint8Array, - requestBody: Uint8Array -): Request { - const request = HttpRequest.deserialize(new BinaryReader(requestBuf)); - return Object.freeze({ - method: methodToString(request.method), - url: request.uri, - headers: deserializeHeaders(request.headers), - body: requestBody, - }); -} + if (opts?.name != null) { + ctx.moduleDef.explicitNames.entries.push({ + tag: 'Function', + value: { + sourceName: exportName, + canonicalName: opts.name, + }, + }); + } -export function serializeHttpHandlerResponse( - response: SyncResponse -): [Uint8Array, Uint8Array] { - const responseWire: HttpResponse = { - code: response.status, - headers: serializeHeaders(response.headers), - version: { tag: 'Http11' }, - }; + if (!fn.name) { + Object.defineProperty(fn, 'name', { value: exportName, writable: false }); + } - const responseBuf = new BinaryWriter(responseBaseSize); - HttpResponse.serialize(responseBuf, responseWire); - return [responseBuf.getBuffer(), response.bytes()]; + ctx.httpHandlers.push(fn as HandlerFn); } diff --git a/crates/bindings-typescript/src/server/http_internal.ts b/crates/bindings-typescript/src/server/http_internal.ts index d9e77128fb6..331b6eaa3b4 100644 --- a/crates/bindings-typescript/src/server/http_internal.ts +++ b/crates/bindings-typescript/src/server/http_internal.ts @@ -27,7 +27,7 @@ export interface ResponseInit { const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder('utf-8' /* { fatal: true } */); -export function deserializeHeaders(headers: HttpHeaders): Headers { +function deserializeHeaders(headers: HttpHeaders): Headers { return new Headers( headers.entries.map(({ name, value }): [string, string] => [ name, @@ -36,15 +36,6 @@ export function deserializeHeaders(headers: HttpHeaders): Headers { ); } -export function serializeHeaders(headers: Headers): HttpHeaders { - return { - // anys because the typings are wonky - see comment in SyncResponse.constructor - entries: headersToList(headers as any) - .flatMap(([k, v]) => (Array.isArray(v) ? v.map(v => [k, v]) : [[k, v]])) - .map(([name, value]) => ({ name, value: textEncoder.encode(value) })), - }; -} - const makeResponse = Symbol('makeResponse'); // based on deno's type of the same name @@ -67,6 +58,9 @@ export class SyncResponse { } else if (typeof body === 'string') { this.#body = body; } else { + // TODO(http): This currently drops byteOffset/byteLength for + // ArrayBufferView inputs and can widen a sliced view to its full backing + // buffer. Keep this aligned with the matching note in http_api.ts. // this call is fine, the typings are just weird this.#body = new Uint8Array(body as any).buffer; } @@ -173,7 +167,12 @@ function fetch(url: URL | string, init: RequestOptions = {}) { tag: 'Extension', value: init.method!, }; - const headers = serializeHeaders(new Headers(init.headers as any)); + const headers: HttpHeaders = { + // anys because the typings are wonky - see comment in SyncResponse.constructor + entries: headersToList(new Headers(init.headers as any) as any) + .flatMap(([k, v]) => (Array.isArray(v) ? v.map(v => [k, v]) : [[k, v]])) + .map(([name, value]) => ({ name, value: textEncoder.encode(value) })), + }; const uri = '' + url; const request: HttpRequest = freeze({ method, @@ -189,7 +188,11 @@ function fetch(url: URL | string, init: RequestOptions = {}) { ? new Uint8Array() : typeof init.body === 'string' ? init.body - : new Uint8Array(init.body as any); + : // TODO(http): This preserves the current behavior for now, but for + // ArrayBufferView inputs it can send the full backing buffer rather + // than the intended slice. Fix together with the shared body handling + // in http_api.ts. + new Uint8Array(init.body as any); const [responseBuf, responseBody] = sys.procedure_http_request( requestBuf.getBuffer(), body diff --git a/crates/bindings-typescript/src/server/http_router.ts b/crates/bindings-typescript/src/server/http_router.ts new file mode 100644 index 00000000000..ad7704fcc12 --- /dev/null +++ b/crates/bindings-typescript/src/server/http_router.ts @@ -0,0 +1,182 @@ +import type { HttpMethod, MethodOrAny } from '../lib/http_types'; +import type { HttpHandlerExport } from './http_handlers'; + +type RouteSpec = { + handler: HttpHandlerExport; + method: MethodOrAny; + path: string; +}; + +const ACCEPTABLE_ROUTE_PATH_CHARS_HUMAN_DESCRIPTION = + 'ASCII lowercase letters, digits and `-_~/`'; + +function characterIsAcceptableForRoutePath(c: string) { + return ( + (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || + c === '-' || + c === '_' || + c === '~' || + c === '/' + ); +} + +function assertValidPath(path: string) { + if (path !== '' && !path.startsWith('/')) { + throw new TypeError(`Route paths must start with \`/\`: ${path}`); + } + if (![...path].every(characterIsAcceptableForRoutePath)) { + throw new TypeError( + `Route paths may contain only ${ACCEPTABLE_ROUTE_PATH_CHARS_HUMAN_DESCRIPTION}: ${path}` + ); + } +} + +function routesOverlap(a: RouteSpec, b: RouteSpec) { + const methodsMatch = (left: HttpMethod, right: HttpMethod) => { + if (left.tag !== right.tag) { + return false; + } + if (left.tag === 'Extension' && right.tag === 'Extension') { + return left.value === right.value; + } + return true; + }; + + return ( + a.path === b.path && + (a.method.tag === 'Any' || + b.method.tag === 'Any' || + (a.method.tag === 'Method' && + b.method.tag === 'Method' && + methodsMatch(a.method.value, b.method.value))) + ); +} + +function joinPaths(prefix: string, suffix: string) { + if (prefix === '/') { + return suffix; + } + if (suffix === '/') { + return prefix; + } + const joinedPrefix = prefix.replace(/\/+$/, ''); + const joinedSuffix = suffix.replace(/^\/+/, ''); + return `${joinedPrefix}/${joinedSuffix}`; +} + +export class Router { + #routes: RouteSpec[]; + + private constructor(routes: RouteSpec[] = []) { + this.#routes = routes; + } + + static new() { + return new Router(); + } + + get(path: string, handler: HttpHandlerExport) { + return this.addRoute( + { tag: 'Method', value: { tag: 'Get' } }, + path, + handler + ); + } + + head(path: string, handler: HttpHandlerExport) { + return this.addRoute( + { tag: 'Method', value: { tag: 'Head' } }, + path, + handler + ); + } + + options(path: string, handler: HttpHandlerExport) { + return this.addRoute( + { tag: 'Method', value: { tag: 'Options' } }, + path, + handler + ); + } + + put(path: string, handler: HttpHandlerExport) { + return this.addRoute( + { tag: 'Method', value: { tag: 'Put' } }, + path, + handler + ); + } + + delete(path: string, handler: HttpHandlerExport) { + return this.addRoute( + { tag: 'Method', value: { tag: 'Delete' } }, + path, + handler + ); + } + + post(path: string, handler: HttpHandlerExport) { + return this.addRoute( + { tag: 'Method', value: { tag: 'Post' } }, + path, + handler + ); + } + + patch(path: string, handler: HttpHandlerExport) { + return this.addRoute( + { tag: 'Method', value: { tag: 'Patch' } }, + path, + handler + ); + } + + any(path: string, handler: HttpHandlerExport) { + return this.addRoute({ tag: 'Any' }, path, handler); + } + + nest(path: string, subRouter: Router) { + assertValidPath(path); + if (this.#routes.some(route => route.path.startsWith(path))) { + throw new TypeError( + `Cannot nest router at \`${path}\`; existing routes overlap with nested path` + ); + } + + let merged = new Router(this.#routes); + for (const route of subRouter.#routes) { + merged = merged.addRoute( + route.method, + joinPaths(path, route.path), + route.handler + ); + } + return merged; + } + + merge(otherRouter: Router) { + let merged = new Router(this.#routes); + for (const route of otherRouter.#routes) { + merged = merged.addRoute(route.method, route.path, route.handler); + } + return merged; + } + + intoRoutes() { + return this.#routes.slice(); + } + + private addRoute( + method: MethodOrAny, + path: string, + handler: HttpHandlerExport + ) { + assertValidPath(path); + const candidate = { method, path, handler }; + if (this.#routes.some(route => routesOverlap(route, candidate))) { + throw new TypeError(`Route conflict for \`${path}\``); + } + return new Router([...this.#routes, candidate]); + } +} diff --git a/crates/bindings-typescript/src/server/http_wire.ts b/crates/bindings-typescript/src/server/http_wire.ts new file mode 100644 index 00000000000..c54183a566b --- /dev/null +++ b/crates/bindings-typescript/src/server/http_wire.ts @@ -0,0 +1,83 @@ +import { headersToList } from 'headers-polyfill'; +import type { + HttpHeaders, + HttpMethod, + HttpRequest, + HttpResponse, +} from '../lib/http_types'; +import { type HttpClient, httpClient } from './http_internal'; +import { Headers, Request, Response, makeRequest } from './http_api'; + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder('utf-8'); + +function deserializeMethod(method: HttpMethod): string { + switch (method.tag) { + case 'Get': + return 'GET'; + case 'Head': + return 'HEAD'; + case 'Post': + return 'POST'; + case 'Put': + return 'PUT'; + case 'Delete': + return 'DELETE'; + case 'Connect': + return 'CONNECT'; + case 'Options': + return 'OPTIONS'; + case 'Trace': + return 'TRACE'; + case 'Patch': + return 'PATCH'; + case 'Extension': + return method.value; + } +} + +function deserializeHeaders(headers: HttpHeaders): Headers { + return new Headers( + headers.entries.map(({ name, value }): [string, string] => [ + name, + textDecoder.decode(value), + ]) + ); +} + +function serializeHeaders(headers: Headers): HttpHeaders { + return { + entries: headersToList(headers as any) + .flatMap(([k, v]) => (Array.isArray(v) ? v.map(v => [k, v]) : [[k, v]])) + .map(([name, value]) => ({ name, value: textEncoder.encode(value) })), + }; +} + +export function requestFromWire( + request: HttpRequest, + body: Uint8Array +): Request { + return Request[makeRequest](body, { + headers: deserializeHeaders(request.headers), + method: deserializeMethod(request.method), + uri: request.uri, + version: request.version, + }); +} + +export function responseIntoWire( + response: Response +): [HttpResponse, Uint8Array] { + return [ + { + headers: serializeHeaders(response.headers), + version: response.version, + code: response.status, + }, + response.bytes(), + ]; +} + +export function makeHandlerHttpClient(): HttpClient { + return httpClient; +} diff --git a/crates/bindings-typescript/src/server/index.ts b/crates/bindings-typescript/src/server/index.ts index 2e92090c004..f10bc575d47 100644 --- a/crates/bindings-typescript/src/server/index.ts +++ b/crates/bindings-typescript/src/server/index.ts @@ -22,6 +22,16 @@ export type { Uuid } from '../lib/uuid'; export type { Random } from './rng'; export type { ViewExport, ViewCtx, AnonymousViewCtx } from './views'; export { Range, type Bound } from './range'; -export { SyncResponse } from './http'; +export { + Headers, + Request, + Response, + Router, + type BodyInit, + type HeadersInit, + type RequestInit, + type ResponseInit, +} from './http'; +export type { HandlerContext, HttpHandlerExport } from './http'; import './polyfills'; // Ensure polyfills are loaded diff --git a/crates/bindings-typescript/src/server/runtime.ts b/crates/bindings-typescript/src/server/runtime.ts index f265c4f533a..08a6c012322 100644 --- a/crates/bindings-typescript/src/server/runtime.ts +++ b/crates/bindings-typescript/src/server/runtime.ts @@ -28,9 +28,11 @@ import { } from '../lib/indexes'; import { callProcedure } from './procedures'; import { - deserializeHttpHandlerRequest, - serializeHttpHandlerResponse, -} from './http_handlers'; + makeHandlerHttpClient, + requestFromWire, + responseIntoWire, +} from './http_wire'; +import { type HandlerContext } from './http_handlers'; import { type AuthCtx, type JsonObject, @@ -39,7 +41,7 @@ import { } from '../lib/reducers'; import { type UntypedSchemaDef } from '../lib/schema'; import { type RowType, type Table, type TableMethods } from '../lib/table'; -import { hasOwn } from '../lib/util'; +import { bsatnBaseSize, hasOwn } from '../lib/util'; import { type AnonymousViewCtx, type ViewCtx } from './views'; import { isRowTypedQuery, makeQueryBuilder, toSql } from './query'; import type { DbView } from './db_view'; @@ -47,6 +49,7 @@ import { getErrorConstructor, SenderError } from './errors'; import { Range, type Bound } from './range'; import { makeRandom, type Random } from './rng'; import type { SchemaInner } from './schema'; +import { HttpRequest, HttpResponse } from '../lib/http_types'; const { freeze } = Object; @@ -421,20 +424,107 @@ class ModuleHooksImpl implements ModuleHooks { __call_http_handler__( id: u32, - _timestamp: bigint, + timestamp: bigint, request: Uint8Array, body: Uint8Array ): [response: Uint8Array, body: Uint8Array] { - const handler = this.#schema.httpHandlers[id]; - const inboundRequest = deserializeHttpHandlerRequest(request, body); - const response = callUserFunction(handler, inboundRequest); - return serializeHttpHandlerResponse(response); + const moduleCtx = this.#schema; + const handler = moduleCtx.httpHandlers[id]; + const ctx = new HandlerContextImpl( + new Timestamp(timestamp), + () => this.#dbView + ); + const requestMetadata = HttpRequest.deserialize(new BinaryReader(request)); + const response = callUserFunction( + handler, + ctx, + requestFromWire(requestMetadata, body) + ); + const [responseMetadata, responseBody] = responseIntoWire(response); + const responseBuf = new BinaryWriter( + bsatnBaseSize(moduleCtx.typespace, HttpResponse.algebraicType) + ); + HttpResponse.serialize(responseBuf, responseMetadata); + return [responseBuf.getBuffer(), responseBody]; } } const BINARY_WRITER = new BinaryWriter(0); const BINARY_READER = new BinaryReader(new Uint8Array()); +class HandlerContextImpl + implements HandlerContext +{ + #identity: Identity | undefined; + #uuidCounter: { value: number } | undefined; + #random: Random | undefined; + #dbView: () => DbView; + + readonly http = makeHandlerHttpClient(); + + constructor( + readonly timestamp: Timestamp, + dbView: () => DbView + ) { + this.#dbView = dbView; + } + + get identity() { + return (this.#identity ??= new Identity(sys.identity())); + } + + get random() { + return (this.#random ??= makeRandom(this.timestamp)); + } + + withTx(body: (ctx: any) => T): T { + const run = () => { + const timestamp = sys.procedure_start_mut_tx(); + + try { + return body( + new ReducerCtxImpl( + Identity.zero(), + new Timestamp(timestamp), + null, + this.#dbView() + ) + ); + } catch (e) { + sys.procedure_abort_mut_tx(); + throw e; + } + }; + + let res = run(); + try { + sys.procedure_commit_mut_tx(); + return res; + } catch { + // ignore the commit error + } + console.warn('committing anonymous transaction failed'); + res = run(); + try { + sys.procedure_commit_mut_tx(); + return res; + } catch (e) { + throw new Error('transaction retry failed again', { cause: e }); + } + } + + newUuidV4(): Uuid { + const bytes = this.random.fill(new Uint8Array(16)); + return Uuid.fromRandomBytesV4(bytes); + } + + newUuidV7(): Uuid { + const bytes = this.random.fill(new Uint8Array(4)); + const counter = (this.#uuidCounter ??= { value: 0 }); + return Uuid.fromCounterV7(counter, this.timestamp, bytes); + } +} + function makeTableView( typespace: Typespace, table: RawTableDefV10 diff --git a/crates/bindings-typescript/src/server/schema.ts b/crates/bindings-typescript/src/server/schema.ts index ea48021d966..262243d4dfc 100644 --- a/crates/bindings-typescript/src/server/schema.ts +++ b/crates/bindings-typescript/src/server/schema.ts @@ -14,6 +14,14 @@ import { } from '../lib/schema'; import type { UntypedTableSchema } from '../lib/table_schema'; import { ColumnBuilder, TypeBuilder } from '../lib/type_builders'; +import { + makeHttpHandlerExport, + makeHttpRouterExport, + type HandlerFn, + type HttpHandlerExport, + type HttpHandlerOpts, +} from './http_handlers'; +import { Router } from './http_router'; import { makeProcedureExport, type ProcedureExport, @@ -27,11 +35,6 @@ import { type ReducerOpts, type Reducers, } from './reducers'; -import { - makeHttpNamespace, - type HttpHandlerExport, - type HttpHandlers, -} from './http_handlers'; import { makeHooks } from './runtime'; import { @@ -52,22 +55,25 @@ export class SchemaInner< > extends ModuleContext { schemaType: S; existingFunctions = new Set(); + existingHttpHandlers = new Set(); reducers: Reducers = []; - httpHandlers: HttpHandlers = []; procedures: Procedures = []; views: Views = []; anonViews: AnonViews = []; + httpHandlers: HandlerFn[] = []; /** * Maps ReducerExport objects to the name of the reducer. * Used for resolving the reducers of scheduled tables. */ functionExports: Map< | ReducerExport - | HttpHandlerExport | ProcedureExport, string > = new Map(); + httpHandlerExports: Map, string> = + new Map(); pendingSchedules: PendingSchedule[] = []; + pendingHttpRoutes: PendingHttpRoute[] = []; constructor(getSchemaType: (ctx: SchemaInner) => S) { super(); @@ -77,12 +83,21 @@ export class SchemaInner< defineFunction(name: string) { if (this.existingFunctions.has(name)) { throw new TypeError( - `There is already a reducer or procedure with the name '${name}'` + `There is already a reducer, procedure, or view with the name '${name}'` ); } this.existingFunctions.add(name); } + defineHttpHandler(name: string) { + if (this.existingHttpHandlers.has(name)) { + throw new TypeError( + `There is already an HTTP handler with the name '${name}'` + ); + } + this.existingHttpHandlers.add(name); + } + resolveSchedules() { for (const { reducer, scheduleAtCol, tableName } of this.pendingSchedules) { const functionName = this.functionExports.get(reducer()); @@ -98,9 +113,30 @@ export class SchemaInner< }); } } + + resolveHttpRoutes() { + for (const route of this.pendingHttpRoutes) { + const handlerFunction = this.httpHandlerExports.get(route.handler); + if (handlerFunction === undefined) { + throw new TypeError( + `HTTP route for path '${route.path}' refers to a handler that was not exported.` + ); + } + this.moduleDef.httpRoutes.push({ + handlerFunction, + method: route.method, + path: route.path, + }); + } + } } type PendingSchedule = UntypedTableSchema['schedule'] & { tableName: string }; +type PendingHttpRoute = { + handler: HttpHandlerExport; + method: import('../lib/http_types').MethodOrAny; + path: string; +}; /** * The Schema class represents the database schema for a SpacetimeDB application. @@ -138,12 +174,10 @@ type PendingSchedule = UntypedTableSchema['schedule'] & { tableName: string }; // be the type of the user table. export class Schema implements ModuleDefaultExport { #ctx: SchemaInner; - readonly http; constructor(ctx: SchemaInner) { // TODO: TableSchema and TableDef should really be unified this.#ctx = ctx; - this.http = makeHttpNamespace(this.#ctx); } [moduleHooks](exports: object) { @@ -162,6 +196,7 @@ export class Schema implements ModuleDefaultExport { moduleExport[registerExport](registeredSchema, name); } registeredSchema.resolveSchedules(); + registeredSchema.resolveHttpRoutes(); return makeHooks(registeredSchema); } @@ -467,6 +502,27 @@ export class Schema implements ModuleDefaultExport { return makeProcedureExport(this.#ctx, opts, params, ret, fn); } + httpHandler(fn: HandlerFn): HttpHandlerExport; + httpHandler(opts: HttpHandlerOpts, fn: HandlerFn): HttpHandlerExport; + httpHandler( + ...args: [HandlerFn] | [HttpHandlerOpts, HandlerFn] + ): HttpHandlerExport { + let opts: HttpHandlerOpts | undefined, fn: HandlerFn; + switch (args.length) { + case 1: + [fn] = args; + break; + case 2: + [opts, fn] = args; + break; + } + return makeHttpHandlerExport(this.#ctx, opts, fn); + } + + httpRouter(router: Router): ModuleExport { + return makeHttpRouterExport(this.#ctx, router); + } + /** * Bundle multiple reducers, procedures, etc into one value to export. * The name they will be exported with is their corresponding key in the `exports` argument. diff --git a/crates/bindings-typescript/src/server/views.ts b/crates/bindings-typescript/src/server/views.ts index accd0c92563..6d2b475b177 100644 --- a/crates/bindings-typescript/src/server/views.ts +++ b/crates/bindings-typescript/src/server/views.ts @@ -143,6 +143,7 @@ export function registerView< ? AnonymousViewFn : ViewFn ) { + ctx.defineFunction(exportName); const paramsBuilder = new RowBuilder(params, toPascalCase(exportName)); // Register return types if they are product types From ab7260178fe2ca1eeb10f42ac7d53ebf81ac30ec Mon Sep 17 00:00:00 2001 From: Jason Larabie Date: Thu, 7 May 2026 10:58:47 -0700 Subject: [PATCH 2/5] Refactor to clean up spread of the logic and roll http_internal into http_shared to avoid circular references --- .../bindings-typescript/src/lib/http_types.ts | 19 -- .../src/server/http.test-d.ts | 39 ++- crates/bindings-typescript/src/server/http.ts | 17 +- .../src/server/http_api.ts | 204 ------------ .../src/server/http_handlers.ts | 307 +++++++++++++++++- .../src/server/http_internal.ts | 166 +--------- .../src/server/http_router.ts | 182 ----------- .../src/server/http_wire.ts | 83 ----- .../bindings-typescript/src/server/index.ts | 2 +- .../bindings-typescript/src/server/runtime.ts | 40 ++- .../bindings-typescript/src/server/schema.ts | 14 +- .../tests/http_headers.test.ts | 2 +- .../tests/smoketests/http_routes.rs | 193 ++++++++++- 13 files changed, 573 insertions(+), 695 deletions(-) delete mode 100644 crates/bindings-typescript/src/lib/http_types.ts delete mode 100644 crates/bindings-typescript/src/server/http_api.ts delete mode 100644 crates/bindings-typescript/src/server/http_router.ts delete mode 100644 crates/bindings-typescript/src/server/http_wire.ts diff --git a/crates/bindings-typescript/src/lib/http_types.ts b/crates/bindings-typescript/src/lib/http_types.ts deleted file mode 100644 index 74542cc6a13..00000000000 --- a/crates/bindings-typescript/src/lib/http_types.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { - HttpHeaderPair, - HttpHeaders, - HttpMethod, - HttpRequest, - HttpResponse, - HttpVersion, - MethodOrAny, -} from './autogen/types'; - -export { - HttpHeaderPair, - HttpHeaders, - HttpMethod, - HttpRequest, - HttpResponse, - HttpVersion, - MethodOrAny, -}; diff --git a/crates/bindings-typescript/src/server/http.test-d.ts b/crates/bindings-typescript/src/server/http.test-d.ts index 4219cf690a5..80299ef6a85 100644 --- a/crates/bindings-typescript/src/server/http.test-d.ts +++ b/crates/bindings-typescript/src/server/http.test-d.ts @@ -3,7 +3,7 @@ import t from '../lib/type_builders'; import { type HandlerContext, Request, - Response, + SyncResponse, Router, schema, } from './index'; @@ -28,32 +28,53 @@ const hello = stdb.httpHandler((ctx, req) => { tx.db.person.insert({ id: 1, name: 'alice' }); }); - return new Response('hello', { + return new SyncResponse('hello', { headers: { 'content-type': 'text/plain' }, status: 200, }); }); -const _typedHello: (ctx: HandlerContext, req: Request) => Response = ( +const _typedHello: (ctx: HandlerContext, req: Request) => SyncResponse = ( ctx, req ) => { void ctx.timestamp; - return new Response(req.text()); + return new SyncResponse(req.text()); }; +const named = stdb.httpHandler({ name: 'hello' }, (_ctx, _req) => { + return new SyncResponse('named'); +}); + const routes = stdb.httpRouter( - Router.new() + new Router() .get('/hello', hello) + .get('/named', named) .post('/hello-post', hello) - .nest('/api', Router.new().any('/v1', hello)) - .merge(Router.new().get('', hello)) + .nest('/api', new Router().any('/v1', hello)) + .merge(new Router().get('', hello)) ); void routes; -// @ts-expect-error handlers must return Response +// @ts-expect-error handlers must return SyncResponse stdb.httpHandler((_ctx, _req) => 123); +// @ts-expect-error handlers must take HandlerContext as the first argument +stdb.httpHandler((_ctx: number, _req: Request) => new SyncResponse('bad')); + // @ts-expect-error handlers must take a Request as the second argument -stdb.httpHandler((_ctx, _req: number) => new Response('bad')); +stdb.httpHandler((_ctx, _req: number) => new SyncResponse('bad')); + +stdb.httpHandler((ctx, req) => { + // @ts-expect-error HTTP handlers do not expose sender directly + void ctx.sender; + // @ts-expect-error HTTP handlers do not expose connectionId directly + void ctx.connectionId; + // @ts-expect-error HTTP handlers do not expose db directly + void ctx.db; + return new SyncResponse(req.text()); +}); + +// @ts-expect-error routers must reference exported http handlers, not raw functions +new Router().get('/raw', (_ctx, _req) => new SyncResponse('bad')); diff --git a/crates/bindings-typescript/src/server/http.ts b/crates/bindings-typescript/src/server/http.ts index 15ce11e5b8d..62f68906f1b 100644 --- a/crates/bindings-typescript/src/server/http.ts +++ b/crates/bindings-typescript/src/server/http.ts @@ -1,23 +1,14 @@ export { - Headers, - Request, - Response, type BodyInit, type HeadersInit, type RequestInit, type ResponseInit, -} from './http_api'; -export { + Headers, + Request, + SyncResponse, + Router, type HandlerContext, type HandlerFn, type HttpHandlerExport, type HttpHandlerOpts, - makeHttpHandlerExport, - makeHttpRouterExport, } from './http_handlers'; -export { Router } from './http_router'; -export { - makeHandlerHttpClient, - requestFromWire, - responseIntoWire, -} from './http_wire'; diff --git a/crates/bindings-typescript/src/server/http_api.ts b/crates/bindings-typescript/src/server/http_api.ts deleted file mode 100644 index a6044b074a9..00000000000 --- a/crates/bindings-typescript/src/server/http_api.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { Headers } from 'headers-polyfill'; -import status from 'statuses'; -import type { HttpVersion } from '../lib/http_types'; - -const textEncoder = new TextEncoder(); -const textDecoder = new TextDecoder('utf-8'); - -export { Headers }; - -export type BodyInit = ArrayBuffer | ArrayBufferView | string; -export type HeadersInit = [string, string][] | Record | Headers; - -export interface RequestInit { - body?: BodyInit | null; - headers?: HeadersInit; - method?: string; - version?: HttpVersion; -} - -export interface ResponseInit { - headers?: HeadersInit; - status?: number; - statusText?: string; - version?: HttpVersion; -} - -type RequestInner = { - headers: Headers; - method: string; - uri: string; - version: HttpVersion; -}; - -type ResponseInner = { - headers: Headers; - status: number; - statusText: string; - version: HttpVersion; -}; - -export const makeRequest = Symbol('makeRequest'); -export const makeResponse = Symbol('makeResponse'); - -export function coerceBody( - body?: BodyInit | null -): string | ArrayBuffer | null { - if (body == null) { - return null; - } - if (typeof body === 'string') { - return body; - } - // TODO(http): This currently drops byteOffset/byteLength for ArrayBufferView - // inputs and can widen a sliced view to its full backing buffer. Fix in - // both http_api.ts and http_internal.ts together so inbound/outbound HTTP - // body handling stays aligned. - return new Uint8Array(body as any).buffer; -} - -export function bodyToBytes( - body: string | ArrayBuffer | null -): Uint8Array { - if (body == null) { - return new Uint8Array(); - } - if (typeof body === 'string') { - return textEncoder.encode(body); - } - return new Uint8Array(body); -} - -export function bodyToText(body: string | ArrayBuffer | null): string { - if (body == null) { - return ''; - } - if (typeof body === 'string') { - return body; - } - return textDecoder.decode(body); -} - -function defaultStatusText(code: number) { - try { - return status(code); - } catch { - return ''; - } -} - -export class Request { - #body: string | ArrayBuffer | null; - #inner: RequestInner; - - constructor(url: URL | string, init: RequestInit = {}) { - this.#body = coerceBody(init.body); - this.#inner = { - headers: new Headers(init.headers as any), - method: init.method?.toUpperCase() ?? 'GET', - uri: '' + url, - version: init.version ?? { tag: 'Http11' }, - }; - } - - static [makeRequest](body: BodyInit | null, inner: RequestInner) { - const me = new Request(inner.uri); - me.#body = coerceBody(body); - me.#inner = inner; - return me; - } - - get headers(): Headers { - return this.#inner.headers; - } - - get method(): string { - return this.#inner.method; - } - - get uri(): string { - return this.#inner.uri; - } - - get url(): string { - return this.#inner.uri; - } - - get version(): HttpVersion { - return this.#inner.version; - } - - arrayBuffer(): ArrayBuffer { - return this.bytes().buffer; - } - - bytes(): Uint8Array { - return bodyToBytes(this.#body); - } - - json(): any { - return JSON.parse(this.text()); - } - - text(): string { - return bodyToText(this.#body); - } -} - -export class Response { - #body: string | ArrayBuffer | null; - #inner: ResponseInner; - - constructor(body?: BodyInit | null, init?: ResponseInit) { - this.#body = coerceBody(body); - const statusCode = init?.status ?? 200; - this.#inner = { - headers: new Headers(init?.headers as any), - status: statusCode, - statusText: init?.statusText ?? defaultStatusText(statusCode), - version: init?.version ?? { tag: 'Http11' }, - }; - } - - static [makeResponse](body: BodyInit | null, inner: ResponseInner) { - const me = new Response(body); - me.#inner = inner; - return me; - } - - get headers(): Headers { - return this.#inner.headers; - } - - get status(): number { - return this.#inner.status; - } - - get statusText() { - return this.#inner.statusText; - } - - get ok(): boolean { - return 200 <= this.#inner.status && this.#inner.status <= 299; - } - - get version(): HttpVersion { - return this.#inner.version; - } - - arrayBuffer(): ArrayBuffer { - return this.bytes().buffer; - } - - bytes(): Uint8Array { - return bodyToBytes(this.#body); - } - - json(): any { - return JSON.parse(this.text()); - } - - text(): string { - return bodyToText(this.#body); - } -} diff --git a/crates/bindings-typescript/src/server/http_handlers.ts b/crates/bindings-typescript/src/server/http_handlers.ts index 7f5f0070ed4..5ca9d923915 100644 --- a/crates/bindings-typescript/src/server/http_handlers.ts +++ b/crates/bindings-typescript/src/server/http_handlers.ts @@ -1,4 +1,9 @@ import type { Identity } from '../lib/identity'; +import type { + HttpMethod, + HttpVersion, + MethodOrAny, +} from '../lib/autogen/types'; import type { UntypedSchemaDef } from '../lib/schema'; import type { Timestamp } from '../lib/timestamp'; import type { Uuid } from '../lib/uuid'; @@ -11,8 +16,190 @@ import { type ModuleExport, type SchemaInner, } from './schema'; -import type { Request, Response } from './http_api'; -import type { Router } from './http_router'; +import { + Headers, + makeResponse, + SyncResponse, + textDecoder, + textEncoder, + type BodyInit, + type HeadersInit, + type ResponseInit, +} from './http_shared'; + +export { Headers }; +export { SyncResponse }; +export type { BodyInit, HeadersInit, ResponseInit }; +export { makeResponse }; +export const httpHandlerFn = Symbol('SpacetimeDB.httpHandlerFn'); + +export interface RequestInit { + body?: BodyInit | null; + headers?: HeadersInit; + method?: string; + version?: HttpVersion; +} + +type RequestInner = { + headers: Headers; + method: string; + uri: string; + version: HttpVersion; +}; + +type RouteSpec = { + handler: HttpHandlerExport; + method: MethodOrAny; + path: string; +}; + +const ACCEPTABLE_ROUTE_PATH_CHARS_HUMAN_DESCRIPTION = + 'ASCII lowercase letters, digits and `-_~/`'; + +export const makeRequest = Symbol('makeRequest'); + +function coerceRequestBody(body?: BodyInit | null): string | Uint8Array | null { + if (body == null) { + return null; + } + if (typeof body === 'string') { + return body; + } + return new Uint8Array(body as any); +} + +function requestBodyToBytes(body: string | Uint8Array | null): Uint8Array { + if (body == null) { + return new Uint8Array(); + } + if (typeof body === 'string') { + return textEncoder.encode(body); + } + return body; +} + +function requestBodyToText(body: string | Uint8Array | null): string { + if (body == null) { + return ''; + } + if (typeof body === 'string') { + return body; + } + return textDecoder.decode(body); +} + +function characterIsAcceptableForRoutePath(c: string) { + return ( + (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || + c === '-' || + c === '_' || + c === '~' || + c === '/' + ); +} + +function assertValidPath(path: string) { + if (path !== '' && !path.startsWith('/')) { + throw new TypeError(`Route paths must start with \`/\`: ${path}`); + } + if (![...path].every(characterIsAcceptableForRoutePath)) { + throw new TypeError( + `Route paths may contain only ${ACCEPTABLE_ROUTE_PATH_CHARS_HUMAN_DESCRIPTION}: ${path}` + ); + } +} + +function routesOverlap(a: RouteSpec, b: RouteSpec) { + const methodsMatch = (left: HttpMethod, right: HttpMethod) => { + if (left.tag !== right.tag) { + return false; + } + if (left.tag === 'Extension' && right.tag === 'Extension') { + return left.value === right.value; + } + return true; + }; + + return ( + a.path === b.path && + (a.method.tag === 'Any' || + b.method.tag === 'Any' || + (a.method.tag === 'Method' && + b.method.tag === 'Method' && + methodsMatch(a.method.value, b.method.value))) + ); +} + +function joinPaths(prefix: string, suffix: string) { + if (prefix === '/') { + return suffix; + } + if (suffix === '/') { + return prefix; + } + const joinedPrefix = prefix.replace(/\/+$/, ''); + const joinedSuffix = suffix.replace(/^\/+/, ''); + return `${joinedPrefix}/${joinedSuffix}`; +} + +export class Request { + #body: string | Uint8Array | null; + #inner: RequestInner; + + constructor(url: URL | string, init: RequestInit = {}) { + this.#body = coerceRequestBody(init.body); + this.#inner = { + headers: new Headers(init.headers as any), + method: init.method ?? 'GET', + uri: '' + url, + version: init.version ?? { tag: 'Http11' }, + }; + } + + static [makeRequest](body: BodyInit | null, inner: RequestInner) { + const me = new Request(inner.uri); + me.#body = coerceRequestBody(body); + me.#inner = inner; + return me; + } + + get headers(): Headers { + return this.#inner.headers; + } + + get method(): string { + return this.#inner.method; + } + + get uri(): string { + return this.#inner.uri; + } + + get url(): string { + return this.#inner.uri; + } + + get version(): HttpVersion { + return this.#inner.version; + } + + arrayBuffer(): ArrayBuffer { + return this.bytes().buffer as ArrayBuffer; + } + + bytes(): Uint8Array { + return requestBodyToBytes(this.#body); + } + + json(): any { + return JSON.parse(this.text()); + } + + text(): string { + return requestBodyToText(this.#body); + } +} export interface HandlerContext { readonly timestamp: Timestamp; @@ -27,9 +214,7 @@ export interface HandlerContext { export type HandlerFn = ( ctx: HandlerContext, req: Request -) => Response; - -export const httpHandlerFn = Symbol('SpacetimeDB.httpHandlerFn'); +) => SyncResponse; export interface HttpHandlerExport< S extends UntypedSchemaDef = UntypedSchemaDef, @@ -43,6 +228,118 @@ export interface HttpHandlerOpts { name: string; } +export class Router { + #routes: RouteSpec[]; + + constructor(routes: RouteSpec[] = []) { + this.#routes = routes; + } + + get(path: string, handler: HttpHandlerExport) { + return this.addRoute( + { tag: 'Method', value: { tag: 'Get' } }, + path, + handler + ); + } + + head(path: string, handler: HttpHandlerExport) { + return this.addRoute( + { tag: 'Method', value: { tag: 'Head' } }, + path, + handler + ); + } + + options(path: string, handler: HttpHandlerExport) { + return this.addRoute( + { tag: 'Method', value: { tag: 'Options' } }, + path, + handler + ); + } + + put(path: string, handler: HttpHandlerExport) { + return this.addRoute( + { tag: 'Method', value: { tag: 'Put' } }, + path, + handler + ); + } + + delete(path: string, handler: HttpHandlerExport) { + return this.addRoute( + { tag: 'Method', value: { tag: 'Delete' } }, + path, + handler + ); + } + + post(path: string, handler: HttpHandlerExport) { + return this.addRoute( + { tag: 'Method', value: { tag: 'Post' } }, + path, + handler + ); + } + + patch(path: string, handler: HttpHandlerExport) { + return this.addRoute( + { tag: 'Method', value: { tag: 'Patch' } }, + path, + handler + ); + } + + any(path: string, handler: HttpHandlerExport) { + return this.addRoute({ tag: 'Any' }, path, handler); + } + + nest(path: string, subRouter: Router) { + assertValidPath(path); + if (this.#routes.some(route => route.path.startsWith(path))) { + throw new TypeError( + `Cannot nest router at \`${path}\`; existing routes overlap with nested path` + ); + } + + let merged = new Router(this.#routes); + for (const route of subRouter.#routes) { + merged = merged.addRoute( + route.method, + joinPaths(path, route.path), + route.handler + ); + } + return merged; + } + + merge(otherRouter: Router) { + let merged = new Router(this.#routes); + for (const route of otherRouter.#routes) { + merged = merged.addRoute(route.method, route.path, route.handler); + } + return merged; + } + + intoRoutes() { + return this.#routes.slice(); + } + + private addRoute( + method: MethodOrAny, + path: string, + handler: HttpHandlerExport + ) { + assertValidPath(path); + const candidate = { method, path, handler }; + if (this.#routes.some(route => routesOverlap(route, candidate))) { + throw new TypeError(`Route conflict for \`${path}\``); + } + return new Router([...this.#routes, candidate]); + } +} + export function makeHttpHandlerExport( ctx: SchemaInner, opts: HttpHandlerOpts | undefined, diff --git a/crates/bindings-typescript/src/server/http_internal.ts b/crates/bindings-typescript/src/server/http_internal.ts index 331b6eaa3b4..1ba1f31a5bf 100644 --- a/crates/bindings-typescript/src/server/http_internal.ts +++ b/crates/bindings-typescript/src/server/http_internal.ts @@ -1,136 +1,25 @@ -import { Headers, headersToList } from 'headers-polyfill'; -import status from 'statuses'; import BinaryReader from '../lib/binary_reader'; import BinaryWriter from '../lib/binary_writer'; -import { - HttpHeaders, - HttpMethod, - HttpRequest, - HttpResponse, -} from '../lib/http_types'; +import status from 'statuses'; +import { HttpRequest, HttpResponse } from '../lib/autogen/types'; import type { TimeDuration } from '../lib/time_duration'; import { bsatnBaseSize } from '../lib/util'; +import { + type BodyInit, + type HeadersInit, + deserializeHeaders, + Headers, + makeResponse, + serializeHeaders, + serializeMethod, + SyncResponse, +} from './http_shared'; import { sys } from './runtime'; export { Headers }; const { freeze } = Object; -export type BodyInit = ArrayBuffer | ArrayBufferView | string; -export type HeadersInit = [string, string][] | Record | Headers; -export interface ResponseInit { - headers?: HeadersInit; - status?: number; - statusText?: string; -} - -const textEncoder = new TextEncoder(); -const textDecoder = new TextDecoder('utf-8' /* { fatal: true } */); - -function deserializeHeaders(headers: HttpHeaders): Headers { - return new Headers( - headers.entries.map(({ name, value }): [string, string] => [ - name, - textDecoder.decode(value), - ]) - ); -} - -const makeResponse = Symbol('makeResponse'); - -// based on deno's type of the same name -interface InnerResponse { - type: 'basic' | 'cors' | 'default' | 'error' | 'opaque' | 'opaqueredirect'; - url: string | null; - status: number; - statusText: string; - headers: Headers; - aborted: boolean; -} - -export class SyncResponse { - #body: string | ArrayBuffer | null; - #inner: InnerResponse; - - constructor(body?: BodyInit | null, init?: ResponseInit) { - if (body == null) { - this.#body = null; - } else if (typeof body === 'string') { - this.#body = body; - } else { - // TODO(http): This currently drops byteOffset/byteLength for - // ArrayBufferView inputs and can widen a sliced view to its full backing - // buffer. Keep this aligned with the matching note in http_api.ts. - // this call is fine, the typings are just weird - this.#body = new Uint8Array(body as any).buffer; - } - - // there's a type mismatch - headers-polyfill's typing doesn't expect its - // own `Headers` type, even though the actual code handles it correctly. - this.#inner = { - headers: new Headers(init?.headers as any), - status: init?.status ?? 200, - statusText: init?.statusText ?? '', - type: 'default', - url: null, - aborted: false, - }; - } - - static [makeResponse](body: BodyInit | null, inner: InnerResponse) { - const me = new SyncResponse(body); - me.#inner = inner; - return me; - } - - get headers(): Headers { - return this.#inner.headers; - } - get status(): number { - return this.#inner.status; - } - get statusText() { - return this.#inner.statusText; - } - get ok(): boolean { - return 200 <= this.#inner.status && this.#inner.status <= 299; - } - get url(): string { - return this.#inner.url ?? ''; - } - get type() { - return this.#inner.type; - } - - arrayBuffer(): ArrayBuffer { - return this.bytes().buffer; - } - - bytes(): Uint8Array { - if (this.#body == null) { - return new Uint8Array(); - } else if (typeof this.#body === 'string') { - return textEncoder.encode(this.#body); - } else { - return new Uint8Array(this.#body); - } - } - - json(): any { - return JSON.parse(this.text()); - } - - text(): string { - if (this.#body == null) { - return ''; - } else if (typeof this.#body === 'string') { - return this.#body; - } else { - return textDecoder.decode(this.#body); - } - } -} - export interface RequestOptions { /** A BodyInit object or null to set request's body. */ body?: BodyInit | null; @@ -150,29 +39,9 @@ export interface HttpClient { const requestBaseSize = bsatnBaseSize({ types: [] }, HttpRequest.algebraicType); -const methods = new Map([ - ['GET', { tag: 'Get' }], - ['HEAD', { tag: 'Head' }], - ['POST', { tag: 'Post' }], - ['PUT', { tag: 'Put' }], - ['DELETE', { tag: 'Delete' }], - ['CONNECT', { tag: 'Connect' }], - ['OPTIONS', { tag: 'Options' }], - ['TRACE', { tag: 'Trace' }], - ['PATCH', { tag: 'Patch' }], -]); - function fetch(url: URL | string, init: RequestOptions = {}) { - const method = methods.get(init.method?.toUpperCase() ?? 'GET') ?? { - tag: 'Extension', - value: init.method!, - }; - const headers: HttpHeaders = { - // anys because the typings are wonky - see comment in SyncResponse.constructor - entries: headersToList(new Headers(init.headers as any) as any) - .flatMap(([k, v]) => (Array.isArray(v) ? v.map(v => [k, v]) : [[k, v]])) - .map(([name, value]) => ({ name, value: textEncoder.encode(value) })), - }; + const method = serializeMethod(init.method); + const headers = serializeHeaders(new Headers(init.headers as any)); const uri = '' + url; const request: HttpRequest = freeze({ method, @@ -188,11 +57,7 @@ function fetch(url: URL | string, init: RequestOptions = {}) { ? new Uint8Array() : typeof init.body === 'string' ? init.body - : // TODO(http): This preserves the current behavior for now, but for - // ArrayBufferView inputs it can send the full backing buffer rather - // than the intended slice. Fix together with the shared body handling - // in http_api.ts. - new Uint8Array(init.body as any); + : new Uint8Array(init.body as any); const [responseBuf, responseBody] = sys.procedure_http_request( requestBuf.getBuffer(), body @@ -205,6 +70,7 @@ function fetch(url: URL | string, init: RequestOptions = {}) { statusText: status(response.code), headers: deserializeHeaders(response.headers), aborted: false, + version: response.version, }); } diff --git a/crates/bindings-typescript/src/server/http_router.ts b/crates/bindings-typescript/src/server/http_router.ts deleted file mode 100644 index ad7704fcc12..00000000000 --- a/crates/bindings-typescript/src/server/http_router.ts +++ /dev/null @@ -1,182 +0,0 @@ -import type { HttpMethod, MethodOrAny } from '../lib/http_types'; -import type { HttpHandlerExport } from './http_handlers'; - -type RouteSpec = { - handler: HttpHandlerExport; - method: MethodOrAny; - path: string; -}; - -const ACCEPTABLE_ROUTE_PATH_CHARS_HUMAN_DESCRIPTION = - 'ASCII lowercase letters, digits and `-_~/`'; - -function characterIsAcceptableForRoutePath(c: string) { - return ( - (c >= 'a' && c <= 'z') || - (c >= '0' && c <= '9') || - c === '-' || - c === '_' || - c === '~' || - c === '/' - ); -} - -function assertValidPath(path: string) { - if (path !== '' && !path.startsWith('/')) { - throw new TypeError(`Route paths must start with \`/\`: ${path}`); - } - if (![...path].every(characterIsAcceptableForRoutePath)) { - throw new TypeError( - `Route paths may contain only ${ACCEPTABLE_ROUTE_PATH_CHARS_HUMAN_DESCRIPTION}: ${path}` - ); - } -} - -function routesOverlap(a: RouteSpec, b: RouteSpec) { - const methodsMatch = (left: HttpMethod, right: HttpMethod) => { - if (left.tag !== right.tag) { - return false; - } - if (left.tag === 'Extension' && right.tag === 'Extension') { - return left.value === right.value; - } - return true; - }; - - return ( - a.path === b.path && - (a.method.tag === 'Any' || - b.method.tag === 'Any' || - (a.method.tag === 'Method' && - b.method.tag === 'Method' && - methodsMatch(a.method.value, b.method.value))) - ); -} - -function joinPaths(prefix: string, suffix: string) { - if (prefix === '/') { - return suffix; - } - if (suffix === '/') { - return prefix; - } - const joinedPrefix = prefix.replace(/\/+$/, ''); - const joinedSuffix = suffix.replace(/^\/+/, ''); - return `${joinedPrefix}/${joinedSuffix}`; -} - -export class Router { - #routes: RouteSpec[]; - - private constructor(routes: RouteSpec[] = []) { - this.#routes = routes; - } - - static new() { - return new Router(); - } - - get(path: string, handler: HttpHandlerExport) { - return this.addRoute( - { tag: 'Method', value: { tag: 'Get' } }, - path, - handler - ); - } - - head(path: string, handler: HttpHandlerExport) { - return this.addRoute( - { tag: 'Method', value: { tag: 'Head' } }, - path, - handler - ); - } - - options(path: string, handler: HttpHandlerExport) { - return this.addRoute( - { tag: 'Method', value: { tag: 'Options' } }, - path, - handler - ); - } - - put(path: string, handler: HttpHandlerExport) { - return this.addRoute( - { tag: 'Method', value: { tag: 'Put' } }, - path, - handler - ); - } - - delete(path: string, handler: HttpHandlerExport) { - return this.addRoute( - { tag: 'Method', value: { tag: 'Delete' } }, - path, - handler - ); - } - - post(path: string, handler: HttpHandlerExport) { - return this.addRoute( - { tag: 'Method', value: { tag: 'Post' } }, - path, - handler - ); - } - - patch(path: string, handler: HttpHandlerExport) { - return this.addRoute( - { tag: 'Method', value: { tag: 'Patch' } }, - path, - handler - ); - } - - any(path: string, handler: HttpHandlerExport) { - return this.addRoute({ tag: 'Any' }, path, handler); - } - - nest(path: string, subRouter: Router) { - assertValidPath(path); - if (this.#routes.some(route => route.path.startsWith(path))) { - throw new TypeError( - `Cannot nest router at \`${path}\`; existing routes overlap with nested path` - ); - } - - let merged = new Router(this.#routes); - for (const route of subRouter.#routes) { - merged = merged.addRoute( - route.method, - joinPaths(path, route.path), - route.handler - ); - } - return merged; - } - - merge(otherRouter: Router) { - let merged = new Router(this.#routes); - for (const route of otherRouter.#routes) { - merged = merged.addRoute(route.method, route.path, route.handler); - } - return merged; - } - - intoRoutes() { - return this.#routes.slice(); - } - - private addRoute( - method: MethodOrAny, - path: string, - handler: HttpHandlerExport - ) { - assertValidPath(path); - const candidate = { method, path, handler }; - if (this.#routes.some(route => routesOverlap(route, candidate))) { - throw new TypeError(`Route conflict for \`${path}\``); - } - return new Router([...this.#routes, candidate]); - } -} diff --git a/crates/bindings-typescript/src/server/http_wire.ts b/crates/bindings-typescript/src/server/http_wire.ts deleted file mode 100644 index c54183a566b..00000000000 --- a/crates/bindings-typescript/src/server/http_wire.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { headersToList } from 'headers-polyfill'; -import type { - HttpHeaders, - HttpMethod, - HttpRequest, - HttpResponse, -} from '../lib/http_types'; -import { type HttpClient, httpClient } from './http_internal'; -import { Headers, Request, Response, makeRequest } from './http_api'; - -const textEncoder = new TextEncoder(); -const textDecoder = new TextDecoder('utf-8'); - -function deserializeMethod(method: HttpMethod): string { - switch (method.tag) { - case 'Get': - return 'GET'; - case 'Head': - return 'HEAD'; - case 'Post': - return 'POST'; - case 'Put': - return 'PUT'; - case 'Delete': - return 'DELETE'; - case 'Connect': - return 'CONNECT'; - case 'Options': - return 'OPTIONS'; - case 'Trace': - return 'TRACE'; - case 'Patch': - return 'PATCH'; - case 'Extension': - return method.value; - } -} - -function deserializeHeaders(headers: HttpHeaders): Headers { - return new Headers( - headers.entries.map(({ name, value }): [string, string] => [ - name, - textDecoder.decode(value), - ]) - ); -} - -function serializeHeaders(headers: Headers): HttpHeaders { - return { - entries: headersToList(headers as any) - .flatMap(([k, v]) => (Array.isArray(v) ? v.map(v => [k, v]) : [[k, v]])) - .map(([name, value]) => ({ name, value: textEncoder.encode(value) })), - }; -} - -export function requestFromWire( - request: HttpRequest, - body: Uint8Array -): Request { - return Request[makeRequest](body, { - headers: deserializeHeaders(request.headers), - method: deserializeMethod(request.method), - uri: request.uri, - version: request.version, - }); -} - -export function responseIntoWire( - response: Response -): [HttpResponse, Uint8Array] { - return [ - { - headers: serializeHeaders(response.headers), - version: response.version, - code: response.status, - }, - response.bytes(), - ]; -} - -export function makeHandlerHttpClient(): HttpClient { - return httpClient; -} diff --git a/crates/bindings-typescript/src/server/index.ts b/crates/bindings-typescript/src/server/index.ts index f10bc575d47..a840be4a59d 100644 --- a/crates/bindings-typescript/src/server/index.ts +++ b/crates/bindings-typescript/src/server/index.ts @@ -25,7 +25,7 @@ export { Range, type Bound } from './range'; export { Headers, Request, - Response, + SyncResponse, Router, type BodyInit, type HeadersInit, diff --git a/crates/bindings-typescript/src/server/runtime.ts b/crates/bindings-typescript/src/server/runtime.ts index 08a6c012322..9fca0f7419b 100644 --- a/crates/bindings-typescript/src/server/runtime.ts +++ b/crates/bindings-typescript/src/server/runtime.ts @@ -28,11 +28,17 @@ import { } from '../lib/indexes'; import { callProcedure } from './procedures'; import { - makeHandlerHttpClient, - requestFromWire, - responseIntoWire, -} from './http_wire'; -import { type HandlerContext } from './http_handlers'; + type HandlerContext, + Request, + SyncResponse, + makeRequest, +} from './http_handlers'; +import { httpClient } from './http_internal'; +import { + deserializeHeaders, + deserializeMethod, + serializeHeaders, +} from './http_shared'; import { type AuthCtx, type JsonObject, @@ -49,12 +55,32 @@ import { getErrorConstructor, SenderError } from './errors'; import { Range, type Bound } from './range'; import { makeRandom, type Random } from './rng'; import type { SchemaInner } from './schema'; -import { HttpRequest, HttpResponse } from '../lib/http_types'; +import { HttpRequest, HttpResponse } from '../lib/autogen/types'; const { freeze } = Object; export const sys = { ..._syscalls2_0, ..._syscalls2_1 }; +function requestFromWire(request: HttpRequest, body: Uint8Array): Request { + return Request[makeRequest](body, { + headers: deserializeHeaders(request.headers), + method: deserializeMethod(request.method), + uri: request.uri, + version: request.version, + }); +} + +function responseIntoWire(response: SyncResponse): [HttpResponse, Uint8Array] { + return [ + { + headers: serializeHeaders(response.headers), + version: response.version, + code: response.status, + }, + response.bytes(), + ]; +} + export function parseJsonObject(json: string): JsonObject { let value: unknown; @@ -460,7 +486,7 @@ class HandlerContextImpl #random: Random | undefined; #dbView: () => DbView; - readonly http = makeHandlerHttpClient(); + readonly http = httpClient; constructor( readonly timestamp: Timestamp, diff --git a/crates/bindings-typescript/src/server/schema.ts b/crates/bindings-typescript/src/server/schema.ts index 262243d4dfc..d9f20be3025 100644 --- a/crates/bindings-typescript/src/server/schema.ts +++ b/crates/bindings-typescript/src/server/schema.ts @@ -1,5 +1,9 @@ import { moduleHooks, type ModuleDefaultExport } from 'spacetime:sys@2.0'; -import { CaseConversionPolicy, Lifecycle } from '../lib/autogen/types'; +import { + CaseConversionPolicy, + Lifecycle, + type MethodOrAny, +} from '../lib/autogen/types'; import { type ParamsAsObject, type ParamsObj, @@ -15,13 +19,13 @@ import { import type { UntypedTableSchema } from '../lib/table_schema'; import { ColumnBuilder, TypeBuilder } from '../lib/type_builders'; import { - makeHttpHandlerExport, - makeHttpRouterExport, + Router, type HandlerFn, type HttpHandlerExport, type HttpHandlerOpts, + makeHttpHandlerExport, + makeHttpRouterExport, } from './http_handlers'; -import { Router } from './http_router'; import { makeProcedureExport, type ProcedureExport, @@ -134,7 +138,7 @@ export class SchemaInner< type PendingSchedule = UntypedTableSchema['schedule'] & { tableName: string }; type PendingHttpRoute = { handler: HttpHandlerExport; - method: import('../lib/http_types').MethodOrAny; + method: MethodOrAny; path: string; }; diff --git a/crates/bindings-typescript/tests/http_headers.test.ts b/crates/bindings-typescript/tests/http_headers.test.ts index 0cba0d90b2e..8024ca18fc9 100644 --- a/crates/bindings-typescript/tests/http_headers.test.ts +++ b/crates/bindings-typescript/tests/http_headers.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'vitest'; import { BinaryReader, BinaryWriter } from '../src'; -import { HttpResponse } from '../src/lib/http_types'; +import { HttpResponse } from '../src/lib/autogen/types'; describe('HttpResponse header round-trip', () => { test('headers survive BSATN serialize/deserialize', () => { diff --git a/crates/smoketests/tests/smoketests/http_routes.rs b/crates/smoketests/tests/smoketests/http_routes.rs index 02a9218c947..786c1fb2270 100644 --- a/crates/smoketests/tests/smoketests/http_routes.rs +++ b/crates/smoketests/tests/smoketests/http_routes.rs @@ -230,31 +230,90 @@ fn router() -> Router { } "#; -const TS_HTTP_ROUTES_MODULE: &str = r#"import { schema, SyncResponse } from "spacetimedb/server"; +const TS_HTTP_ROUTES_MODULE: &str = r#"import { Request, Router, SyncResponse, schema, table, t } from "spacetimedb/server"; + +const entry = table( + { name: "entry", public: true }, + { + id: t.u32().primaryKey(), + value: t.string(), + } +); -const spacetimedb = schema({}); +const spacetimedb = schema({ entry }); export default spacetimedb; -export const get_echo = spacetimedb.http.handler( - req => - new SyncResponse(req.headers.get("x-echo") ?? "", { - headers: { "x-method": "GET" }, - }) +export const get_echo = spacetimedb.httpHandler((_ctx, req) => + new SyncResponse(req.headers.get("x-echo") ?? "", { + headers: { "x-method": "GET" }, + }) +); + +export const post_echo = spacetimedb.httpHandler((_ctx, req) => + new SyncResponse(req.bytes(), { + status: 201, + headers: { "x-method": "POST" }, + }) +); + +export const root_empty = spacetimedb.httpHandler((_ctx, _req) => + new SyncResponse("empty") +); + +export const root_slash = spacetimedb.httpHandler((_ctx, _req) => + new SyncResponse("slash") +); + +export const foo = spacetimedb.httpHandler((_ctx, _req) => + new SyncResponse("foo") +); + +export const foo_slash = spacetimedb.httpHandler((_ctx, _req) => + new SyncResponse("foo-slash") ); -export const post_echo = spacetimedb.http.handler( - req => - new SyncResponse(req.body, { - status: 201, - headers: { "x-method": "POST" }, - }) +export const any_handler = spacetimedb.httpHandler((_ctx, req) => + new SyncResponse(req.method) ); -export const router = spacetimedb.http.router( - new spacetimedb.http.Router() +export const echo_uri = spacetimedb.httpHandler((_ctx, req) => + new SyncResponse(req.uri) +); + +export const reverse_bytes = spacetimedb.httpHandler((_ctx, req) => { + const bytes = req.bytes(); + bytes.reverse(); + return new SyncResponse(bytes); +}); + +export const reverse_words = spacetimedb.httpHandler((_ctx, req) => { + const reversed = req.text().split(" ").reverse().join(" "); + return new SyncResponse(reversed); +}); + +export const insert_entry = spacetimedb.httpHandler((ctx, _req) => { + const count = ctx.withTx(tx => { + const id = Number(tx.db.entry.count()); + tx.db.entry.insert({ id, value: "inserted" }); + return tx.db.entry.count(); + }); + return new SyncResponse(String(count)); +}); + +export const router = spacetimedb.httpRouter( + new Router() .get("/echo", get_echo) .post("/echo", post_echo) + .get("", root_empty) + .get("/", root_slash) + .get("/foo", foo) + .get("/foo/", foo_slash) + .any("/any", any_handler) + .get("/echo-uri", echo_uri) + .post("/insert", insert_entry) + .post("/reverse-bytes", reverse_bytes) + .post("/reverse-words", reverse_words) ); "#; @@ -533,7 +592,6 @@ fn handle_request_body() { #[test] fn typescript_http_routes_end_to_end() { require_pnpm!(); - let mut test = Smoketest::builder().autopublish(false).build(); let identity = test .publish_typescript_module_source( @@ -579,6 +637,109 @@ fn typescript_http_routes_end_to_end() { resp.bytes().expect("typescript post echo body").as_ref(), payload.as_slice() ); + + let resp = client.get(base.clone()).send().expect("typescript empty root failed"); + assert!(resp.status().is_success()); + assert_eq!(resp.text().expect("typescript empty root body"), "empty"); + + let resp = client + .get(format!("{base}/")) + .send() + .expect("typescript slash root failed"); + assert!(resp.status().is_success()); + assert_eq!(resp.text().expect("typescript slash root body"), "slash"); + + let resp = client.get(format!("{base}/foo")).send().expect("typescript foo failed"); + assert!(resp.status().is_success()); + assert_eq!(resp.text().expect("typescript foo body"), "foo"); + + let resp = client + .get(format!("{base}/foo/")) + .send() + .expect("typescript foo slash failed"); + assert!(resp.status().is_success()); + assert_eq!(resp.text().expect("typescript foo slash body"), "foo-slash"); + + let resp = client + .get(format!("{base}//")) + .send() + .expect("typescript double slash failed"); + assert_eq!(resp.status().as_u16(), 404); + assert_eq!( + resp.text().expect("typescript double slash body"), + NO_SUCH_ROUTE_BODY + ); + + let resp = client + .get(format!("{base}//foo")) + .send() + .expect("typescript double slash foo failed"); + assert_eq!(resp.status().as_u16(), 404); + assert_eq!( + resp.text().expect("typescript double slash foo body"), + NO_SUCH_ROUTE_BODY + ); + + let resp = client + .put(format!("{base}/any")) + .send() + .expect("typescript any failed"); + assert!(resp.status().is_success()); + assert_eq!(resp.text().expect("typescript any body"), "PUT"); + + let url = format!("{base}/echo-uri?alpha=beta"); + let resp = client + .get(&url) + .send() + .expect("typescript echo-uri failed"); + assert!(resp.status().is_success()); + assert_eq!(resp.text().expect("typescript echo-uri body"), url); + + let resp = client + .post(format!("{base}/reverse-bytes")) + .body(vec![0xFF, 0x00, 0xFE, 0x7F]) + .send() + .expect("typescript reverse-bytes failed"); + assert!(resp.status().is_success()); + assert_eq!( + resp.bytes().expect("typescript reverse-bytes body").as_ref(), + [0x7F, 0xFE, 0x00, 0xFF] + ); + + let resp = client + .post(format!("{base}/reverse-words")) + .body("red green blue") + .send() + .expect("typescript reverse-words failed"); + assert!(resp.status().is_success()); + assert_eq!( + resp.text().expect("typescript reverse-words body"), + "blue green red" + ); + + let resp = client + .post(format!("{base}/insert")) + .send() + .expect("typescript insert failed"); + assert!(resp.status().is_success()); + assert_eq!(resp.text().expect("typescript insert body"), "1"); + + let resp = client + .get(format!("{base}/missing")) + .send() + .expect("typescript missing route failed"); + assert_eq!(resp.status().as_u16(), 404); + assert_eq!( + resp.text().expect("typescript missing route body"), + NO_SUCH_ROUTE_BODY + ); + + let resp = client + .get(format!("{base}/echo-uri")) + .header("authorization", "Bearer not-a-jwt") + .send() + .expect("typescript auth bypass route failed"); + assert!(resp.status().is_success()); } /// Validates the Rust example from `docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md`. From c2fd767c4ace29441e48a7e84f38740f875a10cf Mon Sep 17 00:00:00 2001 From: Jason Larabie Date: Thu, 7 May 2026 13:36:42 -0700 Subject: [PATCH 3/5] Final clean up --- .../src/server/http_handlers.ts | 1 - .../src/server/http_shared.ts | 186 +++++++++++++++ .../tests/http_handlers.test.ts | 214 ++++++++++++++++++ 3 files changed, 400 insertions(+), 1 deletion(-) create mode 100644 crates/bindings-typescript/src/server/http_shared.ts create mode 100644 crates/bindings-typescript/tests/http_handlers.test.ts diff --git a/crates/bindings-typescript/src/server/http_handlers.ts b/crates/bindings-typescript/src/server/http_handlers.ts index 5ca9d923915..2772ca88752 100644 --- a/crates/bindings-typescript/src/server/http_handlers.ts +++ b/crates/bindings-typescript/src/server/http_handlers.ts @@ -302,7 +302,6 @@ export class Router { `Cannot nest router at \`${path}\`; existing routes overlap with nested path` ); } - let merged = new Router(this.#routes); for (const route of subRouter.#routes) { merged = merged.addRoute( diff --git a/crates/bindings-typescript/src/server/http_shared.ts b/crates/bindings-typescript/src/server/http_shared.ts new file mode 100644 index 00000000000..ea422b7ecd1 --- /dev/null +++ b/crates/bindings-typescript/src/server/http_shared.ts @@ -0,0 +1,186 @@ +import { Headers, headersToList } from 'headers-polyfill'; +import type { + HttpHeaders, + HttpMethod, + HttpVersion, +} from '../lib/autogen/types'; + +export { Headers }; + +export type BodyInit = ArrayBuffer | ArrayBufferView | string; +export type HeadersInit = [string, string][] | Record | Headers; + +export const textEncoder = new TextEncoder(); +export const textDecoder = new TextDecoder('utf-8'); + +export function deserializeMethod(method: HttpMethod): string { + switch (method.tag) { + case 'Get': + return 'GET'; + case 'Head': + return 'HEAD'; + case 'Post': + return 'POST'; + case 'Put': + return 'PUT'; + case 'Delete': + return 'DELETE'; + case 'Connect': + return 'CONNECT'; + case 'Options': + return 'OPTIONS'; + case 'Trace': + return 'TRACE'; + case 'Patch': + return 'PATCH'; + case 'Extension': + return method.value; + } +} + +const methods = new Map([ + ['GET', { tag: 'Get' }], + ['HEAD', { tag: 'Head' }], + ['POST', { tag: 'Post' }], + ['PUT', { tag: 'Put' }], + ['DELETE', { tag: 'Delete' }], + ['CONNECT', { tag: 'Connect' }], + ['OPTIONS', { tag: 'Options' }], + ['TRACE', { tag: 'Trace' }], + ['PATCH', { tag: 'Patch' }], +]); + +export function serializeMethod(method?: string): HttpMethod { + return ( + methods.get(method?.toUpperCase() ?? 'GET') ?? { + tag: 'Extension', + value: method!, + } + ); +} + +export function serializeHeaders(headers: Headers): HttpHeaders { + return { + entries: headersToList(headers as any) + .flatMap(([k, v]) => (Array.isArray(v) ? v.map(v => [k, v]) : [[k, v]])) + .map(([name, value]) => ({ name, value: textEncoder.encode(value) })), + }; +} + +export function deserializeHeaders(headers: HttpHeaders): Headers { + return new Headers( + headers.entries.map(({ name, value }): [string, string] => [ + name, + textDecoder.decode(value), + ]) + ); +} + +export interface ResponseInit { + headers?: HeadersInit; + status?: number; + statusText?: string; + version?: HttpVersion; +} + +export interface InnerResponse { + type: 'basic' | 'cors' | 'default' | 'error' | 'opaque' | 'opaqueredirect'; + url: string | null; + status: number; + statusText: string; + headers: Headers; + aborted: boolean; + version: HttpVersion; +} + +export const makeResponse = Symbol('makeResponse'); + +export class SyncResponse { + #body: string | ArrayBuffer | null; + #inner: InnerResponse; + + constructor(body?: BodyInit | null, init?: ResponseInit) { + if (body == null) { + this.#body = null; + } else if (typeof body === 'string') { + this.#body = body; + } else { + // this call is fine, the typings are just weird + this.#body = new Uint8Array(body as any).buffer; + } + + // there's a type mismatch - headers-polyfill's typing doesn't expect its + // own `Headers` type, even though the actual code handles it correctly. + this.#inner = { + headers: new Headers(init?.headers as any), + status: init?.status ?? 200, + statusText: init?.statusText ?? '', + type: 'default', + url: null, + aborted: false, + version: init?.version ?? { tag: 'Http11' }, + }; + } + + static [makeResponse](body: BodyInit | null, inner: InnerResponse) { + const me = new SyncResponse(body); + me.#inner = inner; + return me; + } + + get headers(): Headers { + return this.#inner.headers; + } + + get status(): number { + return this.#inner.status; + } + + get statusText() { + return this.#inner.statusText; + } + + get ok(): boolean { + return 200 <= this.#inner.status && this.#inner.status <= 299; + } + + get url(): string { + return this.#inner.url ?? ''; + } + + get type() { + return this.#inner.type; + } + + get version(): HttpVersion { + return this.#inner.version; + } + + arrayBuffer(): ArrayBuffer { + return this.bytes().buffer; + } + + bytes(): Uint8Array { + if (this.#body == null) { + return new Uint8Array(); + } else if (typeof this.#body === 'string') { + return textEncoder.encode(this.#body); + } else { + return new Uint8Array(this.#body); + } + } + + json(): any { + return JSON.parse(this.text()); + } + + text(): string { + if (this.#body == null) { + return ''; + } else if (typeof this.#body === 'string') { + return this.#body; + } else { + return textDecoder.decode(this.#body); + } + } +} diff --git a/crates/bindings-typescript/tests/http_handlers.test.ts b/crates/bindings-typescript/tests/http_handlers.test.ts new file mode 100644 index 00000000000..fe3fe949a9d --- /dev/null +++ b/crates/bindings-typescript/tests/http_handlers.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it, vi } from 'vitest'; + +const registerExport = Symbol('SpacetimeDB.registerExport'); +const exportContext = Symbol('SpacetimeDB.exportContext'); + +vi.mock('../src/server/schema', () => ({ + exportContext, + registerExport, +})); + +vi.mock('../src/server/http_internal', () => ({ + httpClient: {}, +})); + +describe('http request/response api', async () => { + const { Request, SyncResponse } = await import('../src/server/http_handlers'); + + it('preserves the provided request method string', () => { + const request = new Request('https://example.test/items', { + method: 'MyMethod', + }); + + expect(request.method).toBe('MyMethod'); + }); + + it('reads request text, json, and bytes', () => { + const request = new Request('https://example.test/items', { + method: 'POST', + body: JSON.stringify({ ok: true }), + }); + + expect(request.text()).toBe('{"ok":true}'); + expect(request.json()).toEqual({ ok: true }); + expect(Array.from(request.bytes())).toEqual( + Array.from(new TextEncoder().encode('{"ok":true}')) + ); + }); + + it('defaults response status text to empty string', () => { + const response = new SyncResponse('created', { status: 201 }); + + expect(response.status).toBe(201); + expect(response.statusText).toBe(''); + expect(response.ok).toBe(true); + }); + + it('marks non-2xx responses as not ok', () => { + const response = new SyncResponse('teapot', { status: 418 }); + + expect(response.ok).toBe(false); + expect(response.text()).toBe('teapot'); + }); + + it('supports array buffer bodies', () => { + const response = new SyncResponse(new TextEncoder().encode('bytes')); + + expect(response.text()).toBe('bytes'); + expect(Array.from(response.bytes())).toEqual( + Array.from(new TextEncoder().encode('bytes')) + ); + }); +}); + +describe('http handler exports', async () => { + const { SyncResponse } = await import('../src/server/http_handlers'); + const { makeHttpHandlerExport } = await import('../src/server/http_handlers'); + + function makeCtx() { + return { + moduleDef: { + httpHandlers: [] as Array<{ sourceName: string }>, + explicitNames: { entries: [] as unknown[] }, + }, + httpHandlers: [] as Array, + httpHandlerExports: new Map(), + defineHttpHandler(name: string) { + if (this.httpHandlers.some(() => false)) { + throw new TypeError(name); + } + }, + }; + } + + it('rejects exporting the same handler object more than once', () => { + const ctx = makeCtx(); + const handler = makeHttpHandlerExport(ctx as never, undefined, () => { + return new SyncResponse('ok'); + }); + + handler[registerExport](ctx as never, 'hello'); + + expect(() => handler[registerExport](ctx as never, 'helloAgain')).toThrow( + "HTTP handler 'helloAgain' was exported more than once" + ); + }); + + it('allows distinct handler export objects for distinct handlers', () => { + const ctx = makeCtx(); + const first = makeHttpHandlerExport(ctx as never, undefined, () => { + return new SyncResponse('first'); + }); + const second = makeHttpHandlerExport(ctx as never, undefined, () => { + return new SyncResponse('second'); + }); + + expect(() => { + first[registerExport](ctx as never, 'first'); + second[registerExport](ctx as never, 'second'); + }).not.toThrow(); + }); + + it('records the originating schema context on the export', () => { + const ctx = makeCtx(); + const handler = makeHttpHandlerExport(ctx as never, undefined, () => { + return new SyncResponse('ok'); + }); + + expect((handler as Record)[exportContext]).toBe(ctx); + }); +}); + +describe('http router', async () => { + const { Router } = await import('../src/server/http_handlers'); + type HttpHandlerExport = + import('../src/server/http_handlers').HttpHandlerExport; + + function handler(): HttpHandlerExport { + return {} as HttpHandlerExport; + } + + it('accepts strict root and slash root routes as distinct', () => { + expect(() => + new Router().get('', handler()).get('/', handler()).get('/foo', handler()) + ).not.toThrow(); + }); + + it('rejects paths without a leading slash unless they are empty root', () => { + expect(() => new Router().get('foo', handler())).toThrow( + 'Route paths must start with `/`: foo' + ); + }); + + it('rejects invalid path characters', () => { + expect(() => new Router().get('/Hello', handler())).toThrow( + 'Route paths may contain only ASCII lowercase letters, digits and `-_~/`: /Hello' + ); + }); + + it('allows distinct methods on the same path', () => { + expect(() => + new Router().get('/echo', handler()).post('/echo', handler()) + ).not.toThrow(); + }); + + it('rejects duplicate same-method same-path routes', () => { + expect(() => + new Router().get('/echo', handler()).get('/echo', handler()) + ).toThrow('Route conflict for `/echo`'); + }); + + it('rejects any() routes that overlap a method-specific route', () => { + expect(() => + new Router().get('/echo', handler()).any('/echo', handler()) + ).toThrow('Route conflict for `/echo`'); + }); + + it('treats trailing slash variants as distinct non-root routes', () => { + expect(() => + new Router().get('/foo', handler()).get('/foo/', handler()) + ).not.toThrow(); + }); + + it('nests paths by joining prefixes and suffixes', () => { + const nested = new Router() + .nest('/api', new Router().get('/users', handler()).get('/', handler())) + .intoRoutes(); + + expect(nested).toHaveLength(2); + expect(nested.map(route => route.path)).toEqual(['/api/users', '/api']); + }); + + it('rejects nesting when an existing route overlaps the nested prefix', () => { + expect(() => + new Router().get('/api/users', handler()).nest('/api', new Router()) + ).toThrow( + 'Cannot nest router at `/api`; existing routes overlap with nested path' + ); + }); + + it('treats sibling prefixes as overlapping nested paths', () => { + expect(() => + new Router().get('/foobar', handler()).nest('/foo', new Router()) + ).toThrow( + 'Cannot nest router at `/foo`; existing routes overlap with nested path' + ); + }); + + it('preserves Rust trailing-slash behavior for nested empty paths', () => { + const nested = new Router().nest( + '/prefix', + new Router().get('', handler()) + ); + + expect(nested.intoRoutes().map(route => route.path)).toEqual(['/prefix/']); + }); + + it('rejects merge() conflicts', () => { + expect(() => + new Router() + .get('/echo', handler()) + .merge(new Router().get('/echo', handler())) + ).toThrow('Route conflict for `/echo`'); + }); +}); From 4193af0aaa3e5880048982430da19713ad683867 Mon Sep 17 00:00:00 2001 From: Jason Larabie Date: Fri, 8 May 2026 06:52:18 -0700 Subject: [PATCH 4/5] Linting repairs --- .../tests/smoketests/http_routes.rs | 25 ++++--------------- 1 file changed, 5 insertions(+), 20 deletions(-) diff --git a/crates/smoketests/tests/smoketests/http_routes.rs b/crates/smoketests/tests/smoketests/http_routes.rs index 786c1fb2270..6d904084e6a 100644 --- a/crates/smoketests/tests/smoketests/http_routes.rs +++ b/crates/smoketests/tests/smoketests/http_routes.rs @@ -665,10 +665,7 @@ fn typescript_http_routes_end_to_end() { .send() .expect("typescript double slash failed"); assert_eq!(resp.status().as_u16(), 404); - assert_eq!( - resp.text().expect("typescript double slash body"), - NO_SUCH_ROUTE_BODY - ); + assert_eq!(resp.text().expect("typescript double slash body"), NO_SUCH_ROUTE_BODY); let resp = client .get(format!("{base}//foo")) @@ -680,18 +677,12 @@ fn typescript_http_routes_end_to_end() { NO_SUCH_ROUTE_BODY ); - let resp = client - .put(format!("{base}/any")) - .send() - .expect("typescript any failed"); + let resp = client.put(format!("{base}/any")).send().expect("typescript any failed"); assert!(resp.status().is_success()); assert_eq!(resp.text().expect("typescript any body"), "PUT"); let url = format!("{base}/echo-uri?alpha=beta"); - let resp = client - .get(&url) - .send() - .expect("typescript echo-uri failed"); + let resp = client.get(&url).send().expect("typescript echo-uri failed"); assert!(resp.status().is_success()); assert_eq!(resp.text().expect("typescript echo-uri body"), url); @@ -712,10 +703,7 @@ fn typescript_http_routes_end_to_end() { .send() .expect("typescript reverse-words failed"); assert!(resp.status().is_success()); - assert_eq!( - resp.text().expect("typescript reverse-words body"), - "blue green red" - ); + assert_eq!(resp.text().expect("typescript reverse-words body"), "blue green red"); let resp = client .post(format!("{base}/insert")) @@ -729,10 +717,7 @@ fn typescript_http_routes_end_to_end() { .send() .expect("typescript missing route failed"); assert_eq!(resp.status().as_u16(), 404); - assert_eq!( - resp.text().expect("typescript missing route body"), - NO_SUCH_ROUTE_BODY - ); + assert_eq!(resp.text().expect("typescript missing route body"), NO_SUCH_ROUTE_BODY); let resp = client .get(format!("{base}/echo-uri")) From b02bf6ea0aa181911c8c68d1e38a562181e77af7 Mon Sep 17 00:00:00 2001 From: Jason Larabie Date: Fri, 8 May 2026 07:12:34 -0700 Subject: [PATCH 5/5] Expand testing for duplicate names slightly --- .../tests/http_handlers.test.ts | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/crates/bindings-typescript/tests/http_handlers.test.ts b/crates/bindings-typescript/tests/http_handlers.test.ts index fe3fe949a9d..fef87c4d922 100644 --- a/crates/bindings-typescript/tests/http_handlers.test.ts +++ b/crates/bindings-typescript/tests/http_handlers.test.ts @@ -71,12 +71,16 @@ describe('http handler exports', async () => { httpHandlers: [] as Array<{ sourceName: string }>, explicitNames: { entries: [] as unknown[] }, }, + existingHttpHandlers: new Set(), httpHandlers: [] as Array, httpHandlerExports: new Map(), defineHttpHandler(name: string) { - if (this.httpHandlers.some(() => false)) { - throw new TypeError(name); + if (this.existingHttpHandlers.has(name)) { + throw new TypeError( + `There is already an HTTP handler with the name '${name}'` + ); } + this.existingHttpHandlers.add(name); }, }; } @@ -109,6 +113,22 @@ describe('http handler exports', async () => { }).not.toThrow(); }); + it('rejects duplicate exported handler names', () => { + const ctx = makeCtx(); + const first = makeHttpHandlerExport(ctx as never, undefined, () => { + return new SyncResponse('first'); + }); + const second = makeHttpHandlerExport(ctx as never, undefined, () => { + return new SyncResponse('second'); + }); + + first[registerExport](ctx as never, 'hello'); + + expect(() => second[registerExport](ctx as never, 'hello')).toThrow( + "There is already an HTTP handler with the name 'hello'" + ); + }); + it('records the originating schema context on the export', () => { const ctx = makeCtx(); const handler = makeHttpHandlerExport(ctx as never, undefined, () => {