From 5bc0c779630680d003c0b2dfe65153d3618c368d Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Fri, 1 May 2026 14:42:10 -0400 Subject: [PATCH 1/4] TypeScript HTTP handlers This commit adds host support for registering HTTP handlers in V8 modules, and a minimal draft of TypeScript bindings support for the same. The TypeScript bindings support is fully vibe-coded and unreviewed, and is present only to allow a new smoketest, which is added to the `http_routes` suite. The host changes were also AI-assisted, but I reviewed and polished them. --- .../src/lib/autogen/types.ts | 29 +++++++ crates/bindings-typescript/src/lib/schema.ts | 14 ++++ crates/bindings-typescript/src/sdk/logger.ts | 2 +- .../src/server/http_internal.ts | 18 +++-- .../bindings-typescript/src/server/index.ts | 1 + .../bindings-typescript/src/server/runtime.ts | 16 ++++ .../bindings-typescript/src/server/schema.ts | 9 +++ .../bindings-typescript/src/server/sys.d.ts | 7 ++ crates/core/src/host/module_host.rs | 3 +- crates/core/src/host/v8/mod.rs | 57 +++++++++---- crates/core/src/host/v8/syscall/common.rs | 60 +++++++++++++- crates/core/src/host/v8/syscall/hooks.rs | 6 ++ crates/core/src/host/v8/syscall/mod.rs | 2 +- crates/core/src/host/v8/syscall/v2.rs | 14 ++++ .../tests/smoketests/http_routes.rs | 81 ++++++++++++++++++- 15 files changed, 291 insertions(+), 28 deletions(-) diff --git a/crates/bindings-typescript/src/lib/autogen/types.ts b/crates/bindings-typescript/src/lib/autogen/types.ts index 0ce535eca0f..068bf3c95ea 100644 --- a/crates/bindings-typescript/src/lib/autogen/types.ts +++ b/crates/bindings-typescript/src/lib/autogen/types.ts @@ -107,6 +107,15 @@ 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; @@ -323,6 +332,20 @@ 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() { @@ -358,6 +381,12 @@ export const RawModuleDefV10Section = __t.enum('RawModuleDefV10Section', { get ExplicitNames() { return ExplicitNames; }, + get HttpHandlers() { + return __t.array(RawHttpHandlerDefV10); + }, + get HttpRoutes() { + return __t.array(RawHttpRouteDefV10); + }, }); export type RawModuleDefV10Section = __Infer; diff --git a/crates/bindings-typescript/src/lib/schema.ts b/crates/bindings-typescript/src/lib/schema.ts index be9edc9e113..b5e74e19fef 100644 --- a/crates/bindings-typescript/src/lib/schema.ts +++ b/crates/bindings-typescript/src/lib/schema.ts @@ -189,6 +189,8 @@ export class ModuleContext { typespace: { types: [] }, tables: [], reducers: [], + httpHandlers: [], + httpRoutes: [], types: [], rowLevelSecurity: [], schedules: [], @@ -219,6 +221,18 @@ 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.httpHandlers && { + tag: 'HttpHandlers', + value: module.httpHandlers, + } + ); + push( + module.httpRoutes && { + tag: 'HttpRoutes', + value: module.httpRoutes, + } + ); push(module.views && { tag: 'Views', value: module.views }); push(module.schedules && { tag: 'Schedules', value: module.schedules }); push( diff --git a/crates/bindings-typescript/src/sdk/logger.ts b/crates/bindings-typescript/src/sdk/logger.ts index 860292cc26c..7374e8b3510 100644 --- a/crates/bindings-typescript/src/sdk/logger.ts +++ b/crates/bindings-typescript/src/sdk/logger.ts @@ -73,7 +73,7 @@ const SENSITIVE_KEYS = new Set([ ]); export const stringify = (value: unknown): string | undefined => - ssStringify(value, (key, current) => { + ssStringify(value, (key: string, current: unknown) => { if (SENSITIVE_KEYS.has(key)) { return '[REDACTED]'; } diff --git a/crates/bindings-typescript/src/server/http_internal.ts b/crates/bindings-typescript/src/server/http_internal.ts index a58031e3a08..d9e77128fb6 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 } */); -function deserializeHeaders(headers: HttpHeaders): Headers { +export function deserializeHeaders(headers: HttpHeaders): Headers { return new Headers( headers.entries.map(({ name, value }): [string, string] => [ name, @@ -36,6 +36,15 @@ 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 @@ -164,12 +173,7 @@ function fetch(url: URL | string, init: RequestOptions = {}) { 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 headers = serializeHeaders(new Headers(init.headers as any)); const uri = '' + url; const request: HttpRequest = freeze({ method, diff --git a/crates/bindings-typescript/src/server/index.ts b/crates/bindings-typescript/src/server/index.ts index 7954ca407e8..2e92090c004 100644 --- a/crates/bindings-typescript/src/server/index.ts +++ b/crates/bindings-typescript/src/server/index.ts @@ -22,5 +22,6 @@ 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'; 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 e05d4c7f3e5..f265c4f533a 100644 --- a/crates/bindings-typescript/src/server/runtime.ts +++ b/crates/bindings-typescript/src/server/runtime.ts @@ -27,6 +27,10 @@ import { type UniqueIndex, } from '../lib/indexes'; import { callProcedure } from './procedures'; +import { + deserializeHttpHandlerRequest, + serializeHttpHandlerResponse, +} from './http_handlers'; import { type AuthCtx, type JsonObject, @@ -414,6 +418,18 @@ class ModuleHooksImpl implements ModuleHooks { () => this.#dbView ); } + + __call_http_handler__( + id: u32, + _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 BINARY_WRITER = new BinaryWriter(0); diff --git a/crates/bindings-typescript/src/server/schema.ts b/crates/bindings-typescript/src/server/schema.ts index b9eb258762b..ea48021d966 100644 --- a/crates/bindings-typescript/src/server/schema.ts +++ b/crates/bindings-typescript/src/server/schema.ts @@ -27,6 +27,11 @@ import { type ReducerOpts, type Reducers, } from './reducers'; +import { + makeHttpNamespace, + type HttpHandlerExport, + type HttpHandlers, +} from './http_handlers'; import { makeHooks } from './runtime'; import { @@ -48,6 +53,7 @@ export class SchemaInner< schemaType: S; existingFunctions = new Set(); reducers: Reducers = []; + httpHandlers: HttpHandlers = []; procedures: Procedures = []; views: Views = []; anonViews: AnonViews = []; @@ -57,6 +63,7 @@ export class SchemaInner< */ functionExports: Map< | ReducerExport + | HttpHandlerExport | ProcedureExport, string > = new Map(); @@ -131,10 +138,12 @@ 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) { diff --git a/crates/bindings-typescript/src/server/sys.d.ts b/crates/bindings-typescript/src/server/sys.d.ts index 71599c8f92a..f0315867cb3 100644 --- a/crates/bindings-typescript/src/server/sys.d.ts +++ b/crates/bindings-typescript/src/server/sys.d.ts @@ -37,6 +37,13 @@ declare module 'spacetime:sys@2.0' { timestamp: bigint, args: Uint8Array ): Uint8Array; + + __call_http_handler__( + id: u32, + timestamp: bigint, + request: Uint8Array, + body: Uint8Array + ): [response: Uint8Array, body: Uint8Array]; } export function register_hooks(hooks: ModuleHooks); diff --git a/crates/core/src/host/module_host.rs b/crates/core/src/host/module_host.rs index 81c6dd20623..c615484b411 100644 --- a/crates/core/src/host/module_host.rs +++ b/crates/core/src/host/module_host.rs @@ -1967,8 +1967,7 @@ impl ModuleHost { "http handler", params, async move |params, inst| inst.call_http_handler(params).await, - // TODO(v8-http-handlers): Do something useful here. - async move |_params, _inst| Err(HttpHandlerCallError::UnsupportedHostType), + async move |params, inst| inst.call_http_handler(params).await, ) .await .map_err(HttpHandlerCallError::from)? diff --git a/crates/core/src/host/v8/mod.rs b/crates/core/src/host/v8/mod.rs index b6ffdfdf862..536e847a267 100644 --- a/crates/core/src/host/v8/mod.rs +++ b/crates/core/src/host/v8/mod.rs @@ -6,23 +6,24 @@ use self::error::{ use self::ser::serialize_to_js; use self::string::{str_from_ident, IntoJsString}; use self::syscall::{ - call_call_procedure, call_call_reducer, call_call_view, call_call_view_anon, call_describe_module, get_hooks, - process_thrown_exception, resolve_sys_module, FnRet, HookFunctions, + call_call_http_handler, call_call_procedure, call_call_reducer, call_call_view, call_call_view_anon, + call_describe_module, get_hooks, process_thrown_exception, resolve_sys_module, FnRet, HookFunctions, }; use super::module_common::{build_common_module_from_raw, run_describer, ModuleCommon}; -use super::module_host::{CallProcedureParams, CallReducerParams, ModuleInfo, ModuleWithInstance}; +use super::module_host::{CallHttpHandlerParams, CallProcedureParams, CallReducerParams, ModuleInfo, ModuleWithInstance}; use super::UpdateDatabaseResult; use crate::client::ClientActorId; use crate::config::V8HeapPolicyConfig; use crate::host::host_controller::CallProcedureReturn; use crate::host::instance_env::{ChunkPool, InstanceEnv, TxSlot}; use crate::host::module_host::{ - call_identity_connected, init_database, ClientConnectedError, ViewCallError, ViewCommand, ViewCommandResult, + call_identity_connected, init_database, ClientConnectedError, HttpHandlerCallError, ViewCallError, ViewCommand, + ViewCommandResult, }; use crate::host::scheduler::{CallScheduledFunctionResult, ScheduledFunctionParams}; use crate::host::wasm_common::instrumentation::CallTimes; use crate::host::wasm_common::module_host_actor::{ - AnonymousViewOp, DescribeError, EnergyStats, ExecutionError, ExecutionResult, ExecutionStats, ExecutionTimings, + AnonymousViewOp, DescribeError, ExecutionError, ExecutionResult, ExecutionStats, ExecutionTimings, HttpHandlerExecuteResult, HttpHandlerOp, InstanceCommon, InstanceOp, ProcedureExecuteResult, ProcedureOp, ReducerExecuteResult, ReducerOp, ViewExecuteResult, ViewOp, WasmInstance, }; @@ -717,6 +718,15 @@ impl JsInstance { .unwrap_or_else(|_| panic!("worker should stay live while calling a procedure")) } + pub async fn call_http_handler( + &self, + params: CallHttpHandlerParams, + ) -> Result<(spacetimedb_lib::http::Response, bytes::Bytes), HttpHandlerCallError> { + self.send_request(|reply_tx| JsWorkerRequest::CallHttpHandler { reply_tx, params }) + .await + .unwrap_or_else(|_| panic!("worker should stay live while calling an http handler")) + } + pub async fn call_view(&self, cmd: ViewCommand) -> ViewCommandResult { self.send_request(|reply_tx| JsWorkerRequest::CallView { reply_tx, cmd }) .await @@ -774,6 +784,11 @@ enum JsWorkerRequest { reply_tx: JsReplyTx, params: CallProcedureParams, }, + /// See [`JsInstance::call_http_handler`]. + CallHttpHandler { + reply_tx: JsReplyTx>, + params: CallHttpHandlerParams, + }, /// See [`JsInstance::clear_all_clients`]. ClearAllClients(JsReplyTx>), /// See [`JsInstance::call_identity_connected`]. @@ -1506,6 +1521,17 @@ async fn spawn_instance_worker( send_worker_reply("call_procedure", reply_tx, res); should_exit = trapped; } + JsWorkerRequest::CallHttpHandler { reply_tx, params } => { + let (res, trapped) = instance_common + .call_http_handler(params, &mut inst) + .now_or_never() + .expect("our call_http_handler implementation is not actually async"); + if trapped { + worker_state_in_thread.mark_trapped(); + } + send_worker_reply("call_http_handler", reply_tx, res); + should_exit = trapped; + } JsWorkerRequest::ClearAllClients(reply_tx) => { let res = instance_common.clear_all_clients(); send_worker_reply("clear_all_clients", reply_tx, res); @@ -1774,17 +1800,18 @@ impl WasmInstance for V8Instance<'_, '_, '_> { async fn call_http_handler( &mut self, - _op: HttpHandlerOp, - _budget: FunctionBudget, + op: HttpHandlerOp, + budget: FunctionBudget, ) -> (HttpHandlerExecuteResult, Option) { - let result = ExecutionResult { - stats: ExecutionStats { - energy: EnergyStats::ZERO, - timings: ExecutionTimings::zero(), - memory_allocation: 0, - }, - call_result: Err(anyhow::anyhow!("HTTP handlers are not supported for JS modules")), - }; + let result = common_call(self, budget, op, |scope, hooks, op| { + call_call_http_handler(scope, hooks, op) + }) + .map_result(|call_result| { + call_result.map_err(|e| match e { + ExecutionError::User(e) => anyhow::Error::msg(e), + ExecutionError::Recoverable(e) | ExecutionError::Trap(e) => e, + }) + }); (result, None) } } diff --git a/crates/core/src/host/v8/syscall/common.rs b/crates/core/src/host/v8/syscall/common.rs index 5e96ef9a1ab..3d93e0b2679 100644 --- a/crates/core/src/host/v8/syscall/common.rs +++ b/crates/core/src/host/v8/syscall/common.rs @@ -17,7 +17,8 @@ use crate::database_logger::{LogLevel, Record}; use crate::error::NodesError; use crate::host::instance_env::InstanceEnv; use crate::host::wasm_common::module_host_actor::{ - deserialize_view_rows, run_query_for_view, AnonymousViewOp, ProcedureOp, ViewOp, ViewResult, ViewReturnData, + deserialize_view_rows, run_query_for_view, AnonymousViewOp, HttpHandlerOp, ProcedureOp, ViewOp, ViewResult, + ViewReturnData, }; use crate::host::wasm_common::{RowIterIdx, TimingSpan, TimingSpanIdx}; use anyhow::Context; @@ -65,6 +66,63 @@ pub fn call_call_procedure( Ok(Bytes::copy_from_slice(bytes)) } +/// Calls the `__call_http_handler__` function `fun`. +pub fn call_call_http_handler( + scope: &mut PinScope<'_, '_>, + hooks: &HookFunctions<'_>, + op: HttpHandlerOp, +) -> Result<(Bytes, Bytes), ErrorOrException> { + let fun = hooks + .call_http_handler + .context("`__call_http_handler__` was never defined")?; + + let HttpHandlerOp { + id, + name: _, + timestamp, + request_bytes, + request_body_bytes, + } = op; + + let handler_id = serialize_to_js(scope, &id.0)?; + let timestamp = serialize_to_js(scope, ×tamp.to_micros_since_unix_epoch())?; + let request = serialize_to_js(scope, &request_bytes)?; + let request_body = serialize_to_js(scope, &request_body_bytes)?; + let args = &[handler_id, timestamp, request, request_body]; + + let ret = call_recv_fun(scope, fun, hooks.recv, args)?; + let ret = cast!(scope, ret, v8::Array, "tuple return from `__call_http_handler__`").map_err(|e| e.throw(scope))?; + + if ret.length() != 2 { + return Err(TypeError("`__call_http_handler__` must return a two-element array") + .throw(scope) + .into()); + } + + let response = ret.get_index(scope, 0).ok_or_else(exception_already_thrown)?; + let response = cast!( + scope, + response, + v8::Uint8Array, + "response bytes return from `__call_http_handler__`" + ) + .map_err(|e| e.throw(scope))?; + + let body = ret.get_index(scope, 1).ok_or_else(exception_already_thrown)?; + let body = cast!( + scope, + body, + v8::Uint8Array, + "response body bytes return from `__call_http_handler__`" + ) + .map_err(|e| e.throw(scope))?; + + Ok(( + Bytes::copy_from_slice(response.get_contents(&mut [])), + Bytes::copy_from_slice(body.get_contents(&mut [])), + )) +} + /// Calls the registered `__describe_module__` function hook. pub fn call_describe_module( scope: &mut PinScope<'_, '_>, diff --git a/crates/core/src/host/v8/syscall/hooks.rs b/crates/core/src/host/v8/syscall/hooks.rs index d2fef8ac88f..e29ca903bd1 100644 --- a/crates/core/src/host/v8/syscall/hooks.rs +++ b/crates/core/src/host/v8/syscall/hooks.rs @@ -58,6 +58,9 @@ pub(in super::super) fn set_registered_hooks(scope: &mut PinScope<'_, '_>, hooks if let Some(call_procedure) = hooks.call_procedure { to_register.push((ModuleHookKey::CallProcedure, call_procedure)); } + if let Some(call_http_handler) = hooks.call_http_handler { + to_register.push((ModuleHookKey::CallHttpHandler, call_http_handler)); + } if let Some(get_error_constructor) = hooks.get_error_constructor { to_register.push((ModuleHookKey::GetErrorConstructor, get_error_constructor)); } @@ -80,6 +83,7 @@ pub(in super::super) enum ModuleHookKey { CallView, CallAnonymousView, CallProcedure, + CallHttpHandler, GetErrorConstructor, SenderErrorClass, } @@ -143,6 +147,7 @@ pub(in super::super) struct HookFunctions<'scope> { pub call_view: Option>, pub call_view_anon: Option>, pub call_procedure: Option>, + pub call_http_handler: Option>, } /// Returns the hook function previously registered in [`register_hooks`]. @@ -172,5 +177,6 @@ pub(in super::super) fn get_registered_hooks<'scope>( call_view: get(ModuleHookKey::CallView), call_view_anon: get(ModuleHookKey::CallAnonymousView), call_procedure: get(ModuleHookKey::CallProcedure), + call_http_handler: get(ModuleHookKey::CallHttpHandler), }) } diff --git a/crates/core/src/host/v8/syscall/mod.rs b/crates/core/src/host/v8/syscall/mod.rs index 7def5bf19ce..304553d56d0 100644 --- a/crates/core/src/host/v8/syscall/mod.rs +++ b/crates/core/src/host/v8/syscall/mod.rs @@ -115,7 +115,7 @@ pub(super) fn call_call_view_anon( } } -pub use self::common::{call_call_procedure, call_describe_module}; +pub use self::common::{call_call_http_handler, call_call_procedure, call_describe_module}; /// Get the hooks for the module. /// diff --git a/crates/core/src/host/v8/syscall/v2.rs b/crates/core/src/host/v8/syscall/v2.rs index fd8645f00a2..f49d2260549 100644 --- a/crates/core/src/host/v8/syscall/v2.rs +++ b/crates/core/src/host/v8/syscall/v2.rs @@ -402,6 +402,19 @@ pub fn get_hooks_from_default_export<'scope>( let call_view = get_hook_function(scope, hooks, str_from_ident!(__call_view__))?; let call_view_anon = get_hook_function(scope, hooks, str_from_ident!(__call_view_anon__))?; let call_procedure = get_hook_function(scope, hooks, str_from_ident!(__call_procedure__))?; + // `call_http_handler` is optional, unlike the other hooks. + // This is because HTTP handler support was added after the initial release of TypeScript modules, + // and so we need to continue supporting precompiled TypeScript and JS modules + // which used an earlier version of the bindings package, + // prior to the inclusion of `__call_http_handler__`. + let call_http_handler = { + let key = str_from_ident!(__call_http_handler__).string(scope); + let value = hooks.get(scope, key.into()).ok_or_else(exception_already_thrown)?; + (!value.is_null_or_undefined()) + .then(|| cast!(scope, value, Function, "module function hook `__call_http_handler__`")) + .transpose() + .map_err(|e| e.throw(scope))? + }; // Cache hooks in context slots so syscall-time code can reconstruct them. let hooks = HookFunctions { @@ -414,6 +427,7 @@ pub fn get_hooks_from_default_export<'scope>( call_view: Some(call_view), call_view_anon: Some(call_view_anon), call_procedure: Some(call_procedure), + call_http_handler, }; set_registered_hooks(scope, &hooks)?; Ok(Some(hooks)) diff --git a/crates/smoketests/tests/smoketests/http_routes.rs b/crates/smoketests/tests/smoketests/http_routes.rs index 40abde11f8e..02a9218c947 100644 --- a/crates/smoketests/tests/smoketests/http_routes.rs +++ b/crates/smoketests/tests/smoketests/http_routes.rs @@ -1,5 +1,5 @@ use regex::Regex; -use spacetimedb_smoketests::{workspace_root, Smoketest}; +use spacetimedb_smoketests::{require_pnpm, workspace_root, Smoketest}; use std::{fs, path::Path}; const MODULE_CODE: &str = r#" @@ -230,6 +230,34 @@ fn router() -> Router { } "#; +const TS_HTTP_ROUTES_MODULE: &str = r#"import { schema, SyncResponse } from "spacetimedb/server"; + +const spacetimedb = schema({}); + +export default spacetimedb; + +export const get_echo = spacetimedb.http.handler( + req => + new SyncResponse(req.headers.get("x-echo") ?? "", { + headers: { "x-method": "GET" }, + }) +); + +export const post_echo = spacetimedb.http.handler( + req => + new SyncResponse(req.body, { + status: 201, + headers: { "x-method": "POST" }, + }) +); + +export const router = spacetimedb.http.router( + new spacetimedb.http.Router() + .get("/echo", get_echo) + .post("/echo", post_echo) +); +"#; + const NO_SUCH_ROUTE_BODY: &str = "Database has not registered a handler for this route"; fn extract_rust_code_blocks(doc_path: &Path) -> String { @@ -502,6 +530,57 @@ 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( + "http-routes-typescript", + "http-routes-typescript", + TS_HTTP_ROUTES_MODULE, + ) + .unwrap(); + + let base = format!("{}/v1/database/{identity}/route", test.server_url); + let client = reqwest::blocking::Client::new(); + + let resp = client + .get(format!("{base}/echo")) + .header("x-echo", "hello") + .send() + .expect("typescript get echo failed"); + let get_status = resp.status(); + let get_headers = resp.headers().clone(); + let get_body = resp.text().expect("typescript get echo body"); + assert!( + get_status.is_success(), + "typescript GET /echo failed: status={get_status} headers={get_headers:?} body={get_body:?}" + ); + assert_eq!( + get_headers.get("x-method").and_then(|value| value.to_str().ok()), + Some("GET") + ); + assert_eq!(get_body, "hello"); + + let payload = vec![0xFF, 0x00, 0xFE, 0x7F]; + let resp = client + .post(format!("{base}/echo")) + .body(payload.clone()) + .send() + .expect("typescript post echo failed"); + assert_eq!(resp.status().as_u16(), 201); + assert_eq!( + resp.headers().get("x-method").and_then(|value| value.to_str().ok()), + Some("POST") + ); + assert_eq!( + resp.bytes().expect("typescript post echo body").as_ref(), + payload.as_slice() + ); +} + /// Validates the Rust example from `docs/docs/00200-core-concepts/00200-functions/00600-HTTP-handlers.md`. #[test] fn http_handlers_tutorial_say_hello_route_works() { From acda488020532a2b9b2300ca52c19ae3df7ec56a Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Mon, 4 May 2026 12:46:42 -0400 Subject: [PATCH 2/4] fmt --- crates/core/src/host/v8/mod.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/core/src/host/v8/mod.rs b/crates/core/src/host/v8/mod.rs index 536e847a267..e955e8198dd 100644 --- a/crates/core/src/host/v8/mod.rs +++ b/crates/core/src/host/v8/mod.rs @@ -10,7 +10,9 @@ use self::syscall::{ call_describe_module, get_hooks, process_thrown_exception, resolve_sys_module, FnRet, HookFunctions, }; use super::module_common::{build_common_module_from_raw, run_describer, ModuleCommon}; -use super::module_host::{CallHttpHandlerParams, CallProcedureParams, CallReducerParams, ModuleInfo, ModuleWithInstance}; +use super::module_host::{ + CallHttpHandlerParams, CallProcedureParams, CallReducerParams, ModuleInfo, ModuleWithInstance, +}; use super::UpdateDatabaseResult; use crate::client::ClientActorId; use crate::config::V8HeapPolicyConfig; From 7974248b4cb173d6e7155c3c98d389deb83fd91b Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Mon, 4 May 2026 13:33:31 -0400 Subject: [PATCH 3/4] :facepalm: commit file I forgot about --- .../src/server/http_handlers.ts | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 crates/bindings-typescript/src/server/http_handlers.ts diff --git a/crates/bindings-typescript/src/server/http_handlers.ts b/crates/bindings-typescript/src/server/http_handlers.ts new file mode 100644 index 00000000000..c82cadd3b49 --- /dev/null +++ b/crates/bindings-typescript/src/server/http_handlers.ts @@ -0,0 +1,179 @@ +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 { + exportContext, + registerExport, + type ModuleExport, + type SchemaInner, +} from './schema'; + +export interface Request { + readonly method: string; + readonly url: string; + readonly headers: Headers; + readonly body: Uint8Array; +} + +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 HttpHandlers = HttpHandler[]; + +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 class Router { + [routesSymbol]: Route[] = []; + + 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; + } +} + +function registerHttpHandler( + 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); + }; + + return handlerExport; +} + +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, + }); + } + }, + }; +} + +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); + }, + }); +} + +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, + }); +} + +export function serializeHttpHandlerResponse( + response: SyncResponse +): [Uint8Array, Uint8Array] { + const responseWire: HttpResponse = { + code: response.status, + headers: serializeHeaders(response.headers), + version: { tag: 'Http11' }, + }; + + const responseBuf = new BinaryWriter(responseBaseSize); + HttpResponse.serialize(responseBuf, responseWire); + return [responseBuf.getBuffer(), response.bytes()]; +} From 0c6a9a432838525e9194ebb8e3e310abe5298ff4 Mon Sep 17 00:00:00 2001 From: Phoebe Goldman Date: Mon, 4 May 2026 15:39:13 -0400 Subject: [PATCH 4/4] pnpm run fmt --- .../src/server/http_handlers.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/bindings-typescript/src/server/http_handlers.ts b/crates/bindings-typescript/src/server/http_handlers.ts index c82cadd3b49..46a2d7051d0 100644 --- a/crates/bindings-typescript/src/server/http_handlers.ts +++ b/crates/bindings-typescript/src/server/http_handlers.ts @@ -1,8 +1,18 @@ -import { HttpRequest, HttpResponse, type MethodOrAny, type HttpMethod } from '../lib/autogen/types'; +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 { + Headers, + SyncResponse, + deserializeHeaders, + serializeHeaders, +} from './http_internal'; import { exportContext, registerExport, @@ -30,7 +40,10 @@ type Route = { export type HttpHandlers = HttpHandler[]; -const responseBaseSize = bsatnBaseSize({ types: [] }, HttpResponse.algebraicType); +const responseBaseSize = bsatnBaseSize( + { types: [] }, + HttpResponse.algebraicType +); const routesSymbol = Symbol('SpacetimeDB.http.routes'); const httpHandlerSymbol = Symbol('SpacetimeDB.http.handler');