Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions crates/bindings-typescript/src/lib/autogen/types.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 14 additions & 0 deletions crates/bindings-typescript/src/lib/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,8 @@ export class ModuleContext {
typespace: { types: [] },
tables: [],
reducers: [],
httpHandlers: [],
httpRoutes: [],
types: [],
rowLevelSecurity: [],
schedules: [],
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion crates/bindings-typescript/src/sdk/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]';
}
Expand Down
192 changes: 192 additions & 0 deletions crates/bindings-typescript/src/server/http_handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
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<HttpMethodName, MethodOrAny> = {
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()];
}
18 changes: 11 additions & 7 deletions crates/bindings-typescript/src/server/http_internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions crates/bindings-typescript/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
16 changes: 16 additions & 0 deletions crates/bindings-typescript/src/server/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
9 changes: 9 additions & 0 deletions crates/bindings-typescript/src/server/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -48,6 +53,7 @@ export class SchemaInner<
schemaType: S;
existingFunctions = new Set<string>();
reducers: Reducers = [];
httpHandlers: HttpHandlers = [];
procedures: Procedures = [];
views: Views = [];
anonViews: AnonViews = [];
Expand All @@ -57,6 +63,7 @@ export class SchemaInner<
*/
functionExports: Map<
| ReducerExport<UntypedSchemaDef, any>
| HttpHandlerExport
| ProcedureExport<UntypedSchemaDef, any, any>,
string
> = new Map();
Expand Down Expand Up @@ -131,10 +138,12 @@ type PendingSchedule = UntypedTableSchema['schedule'] & { tableName: string };
// be the type of the user table.
export class Schema<S extends UntypedSchemaDef> implements ModuleDefaultExport {
#ctx: SchemaInner<S>;
readonly http;

constructor(ctx: SchemaInner<S>) {
// TODO: TableSchema and TableDef should really be unified
this.#ctx = ctx;
this.http = makeHttpNamespace(this.#ctx);
}

[moduleHooks](exports: object) {
Expand Down
Loading
Loading