diff --git a/crates/bindings-typescript/package.json b/crates/bindings-typescript/package.json index a65f6719cee..b4cf5db2ff5 100644 --- a/crates/bindings-typescript/package.json +++ b/crates/bindings-typescript/package.json @@ -158,7 +158,10 @@ "dependencies": { "base64-js": "^1.5.1", "fast-text-encoding": "^1.0.0", - "prettier": "^3.3.3" + "headers-polyfill": "^4.0.3", + "prettier": "^3.3.3", + "statuses": "^2.0.2", + "url-polyfill": "^1.1.14" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0", @@ -177,6 +180,7 @@ "@size-limit/file": "^11.2.0", "@types/fast-text-encoding": "^1.0.3", "@types/react": "^19.1.13", + "@types/statuses": "^2.0.6", "@typescript-eslint/eslint-plugin": "^8.18.2", "@typescript-eslint/parser": "^8.18.2", "@vitest/coverage-v8": "^3.2.4", diff --git a/crates/bindings-typescript/src/lib/autogen/http_header_pair_type.ts b/crates/bindings-typescript/src/lib/autogen/http_header_pair_type.ts new file mode 100644 index 00000000000..4e07a0285e6 --- /dev/null +++ b/crates/bindings-typescript/src/lib/autogen/http_header_pair_type.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from '../../lib/type_builders'; + +export default __t.object('HttpHeaderPair', { + name: __t.string(), + value: __t.byteArray(), +}); diff --git a/crates/bindings-typescript/src/lib/autogen/http_headers_type.ts b/crates/bindings-typescript/src/lib/autogen/http_headers_type.ts new file mode 100644 index 00000000000..3883db13f52 --- /dev/null +++ b/crates/bindings-typescript/src/lib/autogen/http_headers_type.ts @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from '../../lib/type_builders'; +import HttpHeaderPair from './http_header_pair_type'; + +export default __t.object('HttpHeaders', { + get entries() { + return __t.array(HttpHeaderPair); + }, +}); diff --git a/crates/bindings-typescript/src/lib/autogen/http_method_type.ts b/crates/bindings-typescript/src/lib/autogen/http_method_type.ts new file mode 100644 index 00000000000..4815c102859 --- /dev/null +++ b/crates/bindings-typescript/src/lib/autogen/http_method_type.ts @@ -0,0 +1,27 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from '../../lib/type_builders'; + +// The tagged union or sum type for the algebraic type `HttpMethod`. +const HttpMethod = __t.enum('HttpMethod', { + Get: __t.unit(), + Head: __t.unit(), + Post: __t.unit(), + Put: __t.unit(), + Delete: __t.unit(), + Connect: __t.unit(), + Options: __t.unit(), + Trace: __t.unit(), + Patch: __t.unit(), + Extension: __t.string(), +}); + +export default HttpMethod; diff --git a/crates/bindings-typescript/src/lib/autogen/http_request_type.ts b/crates/bindings-typescript/src/lib/autogen/http_request_type.ts new file mode 100644 index 00000000000..20264ab680e --- /dev/null +++ b/crates/bindings-typescript/src/lib/autogen/http_request_type.ts @@ -0,0 +1,28 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from '../../lib/type_builders'; +import HttpMethod from './http_method_type'; +import HttpHeaders from './http_headers_type'; +import HttpVersion from './http_version_type'; + +export default __t.object('HttpRequest', { + get method() { + return HttpMethod; + }, + get headers() { + return HttpHeaders; + }, + timeout: __t.option(__t.timeDuration()), + uri: __t.string(), + get version() { + return HttpVersion; + }, +}); diff --git a/crates/bindings-typescript/src/lib/autogen/http_response_type.ts b/crates/bindings-typescript/src/lib/autogen/http_response_type.ts new file mode 100644 index 00000000000..56ae869056d --- /dev/null +++ b/crates/bindings-typescript/src/lib/autogen/http_response_type.ts @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from '../../lib/type_builders'; +import HttpHeaders from './http_headers_type'; +import HttpVersion from './http_version_type'; + +export default __t.object('HttpResponse', { + get headers() { + return HttpHeaders; + }, + get version() { + return HttpVersion; + }, + code: __t.u16(), +}); diff --git a/crates/bindings-typescript/src/lib/autogen/http_version_type.ts b/crates/bindings-typescript/src/lib/autogen/http_version_type.ts new file mode 100644 index 00000000000..8f022126f09 --- /dev/null +++ b/crates/bindings-typescript/src/lib/autogen/http_version_type.ts @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from '../../lib/type_builders'; + +// The tagged union or sum type for the algebraic type `HttpVersion`. +const HttpVersion = __t.enum('HttpVersion', { + Http09: __t.unit(), + Http10: __t.unit(), + Http11: __t.unit(), + Http2: __t.unit(), + Http3: __t.unit(), +}); + +export default HttpVersion; diff --git a/crates/bindings-typescript/src/lib/http_types.ts b/crates/bindings-typescript/src/lib/http_types.ts new file mode 100644 index 00000000000..3564330e01d --- /dev/null +++ b/crates/bindings-typescript/src/lib/http_types.ts @@ -0,0 +1,6 @@ +export { default as HttpHeaderPair } from './autogen/http_header_pair_type'; +export { default as HttpHeaders } from './autogen/http_headers_type'; +export { default as HttpMethod } from './autogen/http_method_type'; +export { default as HttpRequest } from './autogen/http_request_type'; +export { default as HttpResponse } from './autogen/http_response_type'; +export { default as HttpVersion } from './autogen/http_version_type'; diff --git a/crates/bindings-typescript/src/lib/procedures.ts b/crates/bindings-typescript/src/lib/procedures.ts index 259cfb56576..7e368702a81 100644 --- a/crates/bindings-typescript/src/lib/procedures.ts +++ b/crates/bindings-typescript/src/lib/procedures.ts @@ -2,6 +2,7 @@ import { AlgebraicType, ProductType } from '../lib/algebraic_type'; import type { ConnectionId } from '../lib/connection_id'; import type { Identity } from '../lib/identity'; import type { Timestamp } from '../lib/timestamp'; +import type { HttpClient } from '../server/http_internal'; import type { ParamsObj } from './reducers'; import { MODULE_DEF, @@ -23,6 +24,7 @@ export interface ProcedureCtx { readonly identity: Identity; readonly timestamp: Timestamp; readonly connectionId: ConnectionId | null; + readonly http: HttpClient; } export function procedure< diff --git a/crates/bindings-typescript/src/server/errors.ts b/crates/bindings-typescript/src/server/errors.ts index ebf6e86ecfa..c3edb00be87 100644 --- a/crates/bindings-typescript/src/server/errors.ts +++ b/crates/bindings-typescript/src/server/errors.ts @@ -8,7 +8,7 @@ export class SpacetimeHostError extends Error { public readonly code: number; public readonly message: string; - constructor(code: number) { + constructor(code: number, message?: string) { super(); const proto = Object.getPrototypeOf(this); let cls; @@ -24,7 +24,7 @@ export class SpacetimeHostError extends Error { } Object.setPrototypeOf(this, cls.prototype); this.code = cls.CODE; - this.message = cls.MESSAGE; + this.message = message ?? cls.MESSAGE; } get name(): string { return errnoToClass.get(this.code)?.name ?? 'SpacetimeHostError'; @@ -151,6 +151,8 @@ const errorData = { 20, 'ABI call can only be made while within a read-only transaction', ], + + HttpError: [21, 'The HTTP request failed'], } as const; function mapEntries, U>( diff --git a/crates/bindings-typescript/src/server/http.ts b/crates/bindings-typescript/src/server/http.ts new file mode 100644 index 00000000000..1a2595ed4f1 --- /dev/null +++ b/crates/bindings-typescript/src/server/http.ts @@ -0,0 +1,2 @@ +export { Headers, SyncResponse } from './http_internal'; +export type { BodyInit, HeadersInit, ResponseInit } from './http_internal'; diff --git a/crates/bindings-typescript/src/server/http_internal.ts b/crates/bindings-typescript/src/server/http_internal.ts new file mode 100644 index 00000000000..1bb171a3781 --- /dev/null +++ b/crates/bindings-typescript/src/server/http_internal.ts @@ -0,0 +1,198 @@ +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 type { TimeDuration } from '../lib/time_duration'; +import { bsatnBaseSize } from '../lib/util'; +import type { Infer } from '../sdk'; +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 } */); + +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 { + // 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; + /** A Headers object, an object literal, or an array of two-item arrays to set request's headers. */ + headers?: HeadersInit; + /** A string to set request's method. */ + method?: string; + /** A duration, after which the request will timeout */ + timeout?: TimeDuration; + // /** A string indicating whether request follows redirects, results in an error upon encountering a redirect, or returns the redirect (in an opaque fashion). Sets request's redirect. */ + // redirect?: RequestRedirect; +} + +export interface HttpClient { + fetch(url: URL | string, init?: RequestOptions): SyncResponse; +} + +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: Infer = { + // 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: Infer = freeze({ + method, + headers, + timeout: init.timeout, + uri, + version: { tag: 'Http11' } as const, + }); + const requestBuf = new BinaryWriter(requestBaseSize); + HttpRequest.serialize(requestBuf, request); + const body = + init.body == null + ? new Uint8Array() + : typeof init.body === 'string' + ? init.body + : new Uint8Array(init.body as any); + const [responseBuf, responseBody] = sys.procedure_http_request( + requestBuf.getBuffer(), + body + ); + const response = HttpResponse.deserialize(new BinaryReader(responseBuf)); + return SyncResponse[makeResponse](responseBody, { + type: 'basic', + url: uri, + status: response.code, + statusText: status(response.code), + headers: new Headers(), + aborted: false, + }); +} + +freeze(fetch); + +export const httpClient: HttpClient = freeze({ fetch }); diff --git a/crates/bindings-typescript/src/server/polyfills.ts b/crates/bindings-typescript/src/server/polyfills.ts index c6ba5bae56e..674395c850f 100644 --- a/crates/bindings-typescript/src/server/polyfills.ts +++ b/crates/bindings-typescript/src/server/polyfills.ts @@ -1 +1,2 @@ import 'fast-text-encoding'; +import 'url-polyfill'; diff --git a/crates/bindings-typescript/src/server/procedures.ts b/crates/bindings-typescript/src/server/procedures.ts index 4cca8e3f0e0..5194d8f3af2 100644 --- a/crates/bindings-typescript/src/server/procedures.ts +++ b/crates/bindings-typescript/src/server/procedures.ts @@ -6,6 +6,7 @@ import { Identity } from '../lib/identity'; import { PROCEDURES, type ProcedureCtx } from '../lib/procedures'; import { MODULE_DEF, type UntypedSchemaDef } from '../lib/schema'; import type { Timestamp } from '../lib/timestamp'; +import { httpClient } from './http_internal'; import { sys } from './runtime'; const { freeze } = Object; @@ -28,6 +29,7 @@ export function callProcedure( sender, timestamp, connectionId, + http: httpClient, get identity() { return new Identity(sys.identity().__identity__); }, diff --git a/crates/bindings-typescript/src/server/runtime.ts b/crates/bindings-typescript/src/server/runtime.ts index 55260520a78..457e45fb5e3 100644 --- a/crates/bindings-typescript/src/server/runtime.ts +++ b/crates/bindings-typescript/src/server/runtime.ts @@ -623,7 +623,12 @@ function wrapSyscall any>( hasOwn(e, '__code_error__') && typeof e.__code_error__ == 'number' ) { - throw new SpacetimeHostError(e.__code_error__); + const message = + hasOwn(e, '__error_message__') && + typeof e.__error_message__ === 'string' + ? e.__error_message__ + : undefined; + throw new SpacetimeHostError(e.__code_error__, message); } throw e; } diff --git a/crates/bindings-typescript/src/server/sys.d.ts b/crates/bindings-typescript/src/server/sys.d.ts index c482eb6ff9d..d148f4b8c43 100644 --- a/crates/bindings-typescript/src/server/sys.d.ts +++ b/crates/bindings-typescript/src/server/sys.d.ts @@ -88,4 +88,9 @@ declare module 'spacetime:sys@1.2' { }; export function register_hooks(hooks: ModuleHooks); + + export function procedure_http_request( + request: Uint8Array, + body: Uint8Array | string + ): [response: Uint8Array, body: Uint8Array]; } diff --git a/crates/bindings-typescript/tsconfig.json b/crates/bindings-typescript/tsconfig.json index df35b58a4e4..5081880612d 100644 --- a/crates/bindings-typescript/tsconfig.json +++ b/crates/bindings-typescript/tsconfig.json @@ -16,7 +16,6 @@ // This library is ESM-only, do not import commonjs modules "esModuleInterop": false, - "allowSyntheticDefaultImports": false, "useDefineForClassFields": true, // Crucial when using esbuild/swc/babel instead of tsc emit: diff --git a/crates/bindings-typescript/tsup.config.ts b/crates/bindings-typescript/tsup.config.ts index d9bee996e52..f88f703b9ec 100644 --- a/crates/bindings-typescript/tsup.config.ts +++ b/crates/bindings-typescript/tsup.config.ts @@ -131,7 +131,7 @@ export default defineConfig([ ], }, external: ['undici', /^spacetime:sys.*$/], - noExternal: ['base64-js', 'fast-text-encoding'], + noExternal: ['base64-js', 'fast-text-encoding', 'statuses'], outExtension, esbuildOptions: commonEsbuildTweaks(), }, diff --git a/crates/codegen/examples/regen-typescript-moduledef.rs b/crates/codegen/examples/regen-typescript-moduledef.rs index a5f76e64be4..4e01b963bef 100644 --- a/crates/codegen/examples/regen-typescript-moduledef.rs +++ b/crates/codegen/examples/regen-typescript-moduledef.rs @@ -1,5 +1,7 @@ -//! This script is used to generate the C# bindings for the `RawModuleDef` type. -//! Run `cargo run --example regen-csharp-moduledef` to update C# bindings whenever the module definition changes. +//! This script is used to generate the Typescript bindings for the `RawModuleDef` type. +//! Run `cargo run --example regen-typescript-moduledef` to update TS bindings whenever the module definition changes. + +// TODO: consider renaming this file, since it doesn't just generate `RawModuleDef` anymore. use fs_err as fs; use regex::Regex; @@ -20,6 +22,8 @@ macro_rules! regex_replace { fn main() -> anyhow::Result<()> { let module = RawModuleDefV8::with_builder(|module| { module.add_type::(); + module.add_type::(); + module.add_type::(); }); let dir = &Path::new(concat!( diff --git a/crates/core/src/host/instance_env.rs b/crates/core/src/host/instance_env.rs index b8fef43dbd3..f39129db0c5 100644 --- a/crates/core/src/host/instance_env.rs +++ b/crates/core/src/host/instance_env.rs @@ -27,6 +27,7 @@ use spacetimedb_sats::{ use spacetimedb_table::indexes::RowPointer; use spacetimedb_table::table::RowRef; use std::fmt::Display; +use std::future::Future; use std::ops::DerefMut; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -632,6 +633,10 @@ impl InstanceEnv { } } + // Async procedure syscalls return a `Result`, so that we can check `get_tx()` + // *before* requiring an async runtime. Otherwise, the v8 module host would have to call + // on `tokio::runtime::Handle::try_current()` before being able to run the `get_tx()` check. + pub async fn commit_mutable_tx(&mut self) -> Result<(), NodesError> { self.finish_anon_tx()?; @@ -699,11 +704,11 @@ impl InstanceEnv { self.procedure_last_tx_offset.take() } - pub async fn http_request( + pub fn http_request( &mut self, request: st_http::Request, body: bytes::Bytes, - ) -> Result<(st_http::Response, bytes::Bytes), NodesError> { + ) -> Result>, NodesError> { if self.in_tx() { // If we're holding a transaction open, refuse to perform this blocking operation. return Err(NodesError::WouldBlockTransaction(super::AbiCall::ProcedureHttpRequest)); @@ -737,21 +742,25 @@ impl InstanceEnv { // Actually execute the HTTP request! // TODO(perf): Stash a long-lived `Client` in the env somewhere, rather than building a new one for each call. - let response = reqwest::Client::new().execute(reqwest).await.map_err(http_error)?; + let execute_fut = reqwest::Client::new().execute(reqwest); + + Ok(async move { + let response = execute_fut.await.map_err(http_error)?; - // Download the response body, which in all likelihood will be a stream, - // as reqwest seems to prefer that. - let (response, body) = http::Response::from(response).into_parts(); - let body = http_body_util::BodyExt::collect(body) - .await - .map_err(http_error)? - .to_bytes(); + // Download the response body, which in all likelihood will be a stream, + // as reqwest seems to prefer that. + let (response, body) = http::Response::from(response).into_parts(); + let body = http_body_util::BodyExt::collect(body) + .await + .map_err(http_error)? + .to_bytes(); - // Transform the `http::Response` into our `spacetimedb_lib::http::Response` type, - // which has a stable BSATN encoding to pass across the WASM boundary. - let response = convert_http_response(response); + // Transform the `http::Response` into our `spacetimedb_lib::http::Response` type, + // which has a stable BSATN encoding to pass across the WASM boundary. + let response = convert_http_response(response); - Ok((response, body)) + Ok((response, body)) + }) } } diff --git a/crates/core/src/host/v8/error.rs b/crates/core/src/host/v8/error.rs index 7156c29aa31..6ad57a2074b 100644 --- a/crates/core/src/host/v8/error.rs +++ b/crates/core/src/host/v8/error.rs @@ -138,6 +138,29 @@ impl CodeError { } } +/// A catchable error code thrown in callbacks +/// to indicate bad arguments to a syscall. +#[derive(Serialize)] +pub(super) struct CodeMessageError { + __code_error__: u16, + __error_message__: String, +} + +impl CodeMessageError { + /// Create a code error from a code. + pub(super) fn from_code<'scope>( + scope: &PinScope<'scope, '_>, + __code_error__: u16, + __error_message__: String, + ) -> ExcResult> { + let error = Self { + __code_error__, + __error_message__, + }; + serialize_to_js(scope, &error).map(ExceptionValue) + } +} + /// A catchable error code thrown in callbacks /// to indicate that a buffer was too small and the minimum size required. #[derive(Serialize)] diff --git a/crates/core/src/host/v8/mod.rs b/crates/core/src/host/v8/mod.rs index c47549c5f77..a3949a207e1 100644 --- a/crates/core/src/host/v8/mod.rs +++ b/crates/core/src/host/v8/mod.rs @@ -1,7 +1,7 @@ use self::budget::energy_from_elapsed; use self::error::{ - catch_exception, exception_already_thrown, log_traceback, BufferTooSmall, CanContinue, CodeError, ErrorOrException, - ExcResult, ExceptionThrown, JsStackTrace, TerminationError, Throwable, + catch_exception, exception_already_thrown, log_traceback, BufferTooSmall, CanContinue, ErrorOrException, ExcResult, + ExceptionThrown, JsStackTrace, TerminationError, Throwable, }; use self::ser::serialize_to_js; use self::string::{str_from_ident, IntoJsString}; @@ -61,6 +61,7 @@ mod ser; mod string; mod syscall; mod to_value; +mod util; /// The V8 runtime, for modules written in e.g., JS or TypeScript. #[derive(Default)] @@ -384,10 +385,13 @@ impl JsInstance { } pub async fn call_procedure(self: Box, params: CallProcedureParams) -> (CallProcedureReturn, Box) { + // Get a handle to the current tokio runtime, and pass it to the worker + // so that it can execute futures. + let rt = tokio::runtime::Handle::current(); let (r, s) = self .send_recv( JsWorkerReply::into_call_procedure, - JsWorkerRequest::CallProcedure { params }, + JsWorkerRequest::CallProcedure { params, rt }, ) .await; (*r, s) @@ -434,7 +438,10 @@ enum JsWorkerRequest { /// See [`JsInstance::call_view`]. CallView { tx: MutTxId, params: CallViewParams }, /// See [`JsInstance::call_procedure`]. - CallProcedure { params: CallProcedureParams }, + CallProcedure { + params: CallProcedureParams, + rt: tokio::runtime::Handle, + }, /// See [`JsInstance::clear_all_clients`]. ClearAllClients, /// See [`JsInstance::call_identity_connected`]. @@ -588,7 +595,11 @@ fn spawn_instance_worker( let (res, trapped) = instance_common.call_view_with_tx(tx, params, &mut inst); reply("call_view", JsWorkerReply::CallView(res.into()), trapped); } - JsWorkerRequest::CallProcedure { params } => { + JsWorkerRequest::CallProcedure { params, rt } => { + // The callee passed us a handle to their tokio runtime - enter its + // context so that we can execute futures. + let _guard = rt.enter(); + let (res, trapped) = instance_common .call_procedure(params, &mut inst) .now_or_never() diff --git a/crates/core/src/host/v8/ser.rs b/crates/core/src/host/v8/ser.rs index 09dc1f28bfd..01689745775 100644 --- a/crates/core/src/host/v8/ser.rs +++ b/crates/core/src/host/v8/ser.rs @@ -11,7 +11,7 @@ use spacetimedb_sats::{ ser::{self, Serialize}, u256, }; -use v8::{Array, ArrayBuffer, IntegrityLevel, Local, Object, PinScope, Uint8Array, Value}; +use v8::{Array, IntegrityLevel, Local, Object, PinScope, Value}; /// Serializes `value` into a V8 into `scope`. pub(super) fn serialize_to_js<'scope>(scope: &PinScope<'scope, '_>, value: &impl Serialize) -> FnRet<'scope> { @@ -112,9 +112,7 @@ impl<'this, 'scope, 'isolate> ser::Serializer for Serializer<'this, 'scope, 'iso } fn serialize_bytes(self, bytes: &[u8]) -> Result { - let store = ArrayBuffer::new_backing_store_from_boxed_slice(bytes.into()).make_shared(); - let buf = ArrayBuffer::with_backing_store(self.scope, &store); - Ok(Uint8Array::new(self.scope, buf, 0, bytes.len()).unwrap().into()) + Ok(super::util::make_uint8array(self.scope, bytes.to_vec()).into()) } fn serialize_array(self, len: usize) -> Result { diff --git a/crates/core/src/host/v8/syscall/v1.rs b/crates/core/src/host/v8/syscall/v1.rs index 74b5e7822c3..8b6edac2a05 100644 --- a/crates/core/src/host/v8/syscall/v1.rs +++ b/crates/core/src/host/v8/syscall/v1.rs @@ -4,13 +4,14 @@ use crate::database_logger::{LogLevel, Record}; use crate::error::NodesError; use crate::host::instance_env::InstanceEnv; use crate::host::v8::de::{deserialize_js, scratch_buf}; -use crate::host::v8::error::{ErrorOrException, ExcResult, ExceptionThrown}; +use crate::host::v8::error::{CodeError, CodeMessageError, ErrorOrException, ExcResult, ExceptionThrown, TypeError}; use crate::host::v8::from_value::cast; use crate::host::v8::ser::serialize_to_js; use crate::host::v8::string::{str_from_ident, StringConst}; use crate::host::v8::syscall::hooks::HookFunctions; +use crate::host::v8::util::make_uint8array; use crate::host::v8::{ - call_free_fun, env_on_isolate, exception_already_thrown, BufferTooSmall, CodeError, JsInstanceEnv, JsStackTrace, + call_free_fun, env_on_isolate, exception_already_thrown, BufferTooSmall, JsInstanceEnv, JsStackTrace, TerminationError, Throwable, }; use crate::host::wasm_common::instrumentation::span; @@ -120,7 +121,16 @@ pub(super) fn sys_v1_1<'scope>(scope: &mut PinScope<'scope, '_>) -> Local<'scope pub(super) fn sys_v1_2<'scope>(scope: &mut PinScope<'scope, '_>) -> Local<'scope, Module> { use register_hooks_v1_2 as register_hooks; - create_synthetic_module!(scope, "spacetime:sys@1.2", (with_nothing, (), register_hooks)) + create_synthetic_module!( + scope, + "spacetime:sys@1.2", + (with_nothing, (), register_hooks), + ( + with_sys_result_value, + AbiCall::ProcedureHttpRequest, + procedure_http_request + ) + ) } /// Registers a function in `module` @@ -207,6 +217,23 @@ fn with_sys_result_noret<'scope>( } } +/// Wraps `run` in [`with_span`] and returns undefined to JS. +/// Handles [`SysCallError`] if it occurs by throwing exceptions into JS. +fn with_sys_result_value<'scope, O>( + abi_call: AbiCall, + scope: &mut PinScope<'scope, '_>, + args: FunctionCallbackArguments<'scope>, + run: impl FnOnce(&mut PinScope<'scope, '_>, FunctionCallbackArguments<'scope>) -> SysCallResult>, +) -> FnRet<'scope> +where + Local<'scope, O>: Into>, +{ + match with_span(abi_call, scope, args, run) { + Ok(v) => Ok(v.into()), + Err(err) => handle_sys_call_error(abi_call, scope, err), + } +} + /// A higher order function conforming to the interface of [`with_sys_result`] and [`with_span`]. fn with_nothing<'scope>( (): (), @@ -240,7 +267,8 @@ fn code_error(scope: &PinScope<'_, '_>, code: u16) -> ExceptionThrown { /// Turns a [`NodesError`] into a thrown exception. fn throw_nodes_error(abi_call: AbiCall, scope: &mut PinScope<'_, '_>, error: NodesError) -> ExceptionThrown { let res = match err_to_errno_and_log::(abi_call, error) { - Ok(code) => CodeError::from_code(scope, code), + Ok((code, None)) => CodeError::from_code(scope, code), + Ok((code, Some(message))) => CodeMessageError::from_code(scope, code, message), Err(err) => { // Terminate execution ASAP and throw a catchable exception (`TerminationError`). // Unfortunately, JS execution won't be terminated once the callback returns, @@ -1497,3 +1525,63 @@ fn get_jwt_payload(scope: &mut PinScope<'_, '_>, args: FunctionCallbackArguments fn identity<'scope>(scope: &mut PinScope<'scope, '_>, _: FunctionCallbackArguments<'scope>) -> SysCallResult { Ok(*get_env(scope)?.instance_env.database_identity()) } + +/// Execute an HTTP request in the context of a procedure. +/// +/// # Signature +/// +/// ```ignore +/// function procedure_http_request( +/// request: Uint8Array, +/// body: Uint8Array | string +/// ): [response: Uint8Array, body: Uint8Array]; +/// ``` +/// +/// Accepts a BSATN-encoded [`spacetimedb_lib::http::Request`] and a request body, and +/// returns a BSATN-encoded [`spacetimedb_lib::http::Response`] and the response body. +fn procedure_http_request<'scope>( + scope: &mut PinScope<'scope, '_>, + args: FunctionCallbackArguments<'scope>, +) -> SysCallResult> { + use spacetimedb_lib::http as st_http; + + let request = + cast!(scope, args.get(0), v8::Uint8Array, "Uint8Array for procedure request").map_err(|e| e.throw(scope))?; + + let request = bsatn::from_slice::(request.get_contents(&mut [])) + .map_err(|e| TypeError(format!("failed to decode http request: {e}")).throw(scope))?; + + let request_body = args.get(1); + let request_body = if let Ok(s) = request_body.try_cast::() { + Bytes::from(s.to_rust_string_lossy(scope)) + } else { + let bytes = cast!( + scope, + request_body, + v8::Uint8Array, + "Uint8Array or string for request body" + ) + .map_err(|e| e.throw(scope))?; + Bytes::copy_from_slice(bytes.get_contents(&mut [])) + }; + + let env = get_env(scope)?; + + let fut = env.instance_env.http_request(request, request_body)?; + + let rt = tokio::runtime::Handle::current(); + let (response, response_body) = rt.block_on(fut)?; + + let response = bsatn::to_vec(&response).expect("failed to serialize `HttpResponse`"); + let response = make_uint8array(scope, response); + + let response_body = match response_body.try_into_mut() { + Ok(bytes_mut) => make_uint8array(scope, Box::new(bytes_mut)), + Err(bytes) => make_uint8array(scope, Vec::from(bytes)), + }; + + Ok(v8::Array::new_with_elements( + scope, + &[response.into(), response_body.into()], + )) +} diff --git a/crates/core/src/host/v8/util.rs b/crates/core/src/host/v8/util.rs new file mode 100644 index 00000000000..4fb418189f5 --- /dev/null +++ b/crates/core/src/host/v8/util.rs @@ -0,0 +1,32 @@ +/// The trait used as bound on `v8::ArrayBuffer::new_backing_store_from_bytes` +/// isn't public, so we need to emulate it. +pub(super) trait IntoArrayBufferBackingStore { + fn into_backing_store(self) -> v8::UniqueRef; +} +macro_rules! impl_into_backing_store { + ([$($bounds:tt)*] $t:ty) => { + impl<$($bounds)*> IntoArrayBufferBackingStore for $t { + fn into_backing_store(self) -> v8::UniqueRef { + v8::ArrayBuffer::new_backing_store_from_bytes(self) + } + } + }; + ($($primitive:ty),*) => {$( + impl_into_backing_store!([] Box<[$primitive]>); + impl_into_backing_store!([] Vec<$primitive>); + )*}; +} + +impl_into_backing_store!([T: AsMut<[u8]>] Box); +impl_into_backing_store!(u8, u16, u32, u64, i8, i16, i32, i64); + +/// Taking a scope and a buffer, return a `v8::Local<'scope, v8::Uint8Array>`. +pub(super) fn make_uint8array<'scope>( + scope: &v8::PinScope<'scope, '_>, + buf: impl IntoArrayBufferBackingStore, +) -> v8::Local<'scope, v8::Uint8Array> { + let store = buf.into_backing_store(); + let len = store.byte_length(); + let buf = v8::ArrayBuffer::with_backing_store(scope, &store.make_shared()); + v8::Uint8Array::new(scope, buf, 0, len).expect("len > 8 pebibytes") +} diff --git a/crates/core/src/host/wasm_common.rs b/crates/core/src/host/wasm_common.rs index 520f4faf9e6..408f6b84e61 100644 --- a/crates/core/src/host/wasm_common.rs +++ b/crates/core/src/host/wasm_common.rs @@ -344,21 +344,21 @@ decl_index!(TimingSpanIdx => TimingSpan); pub(super) type TimingSpanSet = ResourceSlab; /// Converts a [`NodesError`] to an error code, if possible. -pub fn err_to_errno(err: &NodesError) -> Option { - match err { - NodesError::NotInTransaction => Some(errno::NOT_IN_TRANSACTION), - NodesError::NotInAnonTransaction => Some(errno::TRANSACTION_NOT_ANONYMOUS), - NodesError::WouldBlockTransaction(_) => Some(errno::WOULD_BLOCK_TRANSACTION), - NodesError::DecodeRow(_) => Some(errno::BSATN_DECODE_ERROR), - NodesError::DecodeValue(_) => Some(errno::BSATN_DECODE_ERROR), - NodesError::TableNotFound => Some(errno::NO_SUCH_TABLE), - NodesError::IndexNotFound => Some(errno::NO_SUCH_INDEX), - NodesError::IndexNotUnique => Some(errno::INDEX_NOT_UNIQUE), - NodesError::IndexRowNotFound => Some(errno::NO_SUCH_ROW), - NodesError::ScheduleError(ScheduleError::DelayTooLong(_)) => Some(errno::SCHEDULE_AT_DELAY_TOO_LONG), - NodesError::AlreadyExists(_) => Some(errno::UNIQUE_ALREADY_EXISTS), - NodesError::HttpError(_) => Some(errno::HTTP_ERROR), - NodesError::Internal(internal) => match **internal { +pub fn err_to_errno(err: NodesError) -> Result<(NonZeroU16, Option), NodesError> { + let errno = match err { + NodesError::NotInTransaction => errno::NOT_IN_TRANSACTION, + NodesError::NotInAnonTransaction => errno::TRANSACTION_NOT_ANONYMOUS, + NodesError::WouldBlockTransaction(_) => errno::WOULD_BLOCK_TRANSACTION, + NodesError::DecodeRow(_) => errno::BSATN_DECODE_ERROR, + NodesError::DecodeValue(_) => errno::BSATN_DECODE_ERROR, + NodesError::TableNotFound => errno::NO_SUCH_TABLE, + NodesError::IndexNotFound => errno::NO_SUCH_INDEX, + NodesError::IndexNotUnique => errno::INDEX_NOT_UNIQUE, + NodesError::IndexRowNotFound => errno::NO_SUCH_ROW, + NodesError::ScheduleError(ScheduleError::DelayTooLong(_)) => errno::SCHEDULE_AT_DELAY_TOO_LONG, + NodesError::AlreadyExists(_) => errno::UNIQUE_ALREADY_EXISTS, + NodesError::HttpError(message) => return Ok((errno::HTTP_ERROR, Some(message))), + NodesError::Internal(ref internal) => match **internal { DBError::Datastore(DatastoreError::Index(IndexError::UniqueConstraintViolation( UniqueConstraintViolation { constraint_name: _, @@ -366,23 +366,22 @@ pub fn err_to_errno(err: &NodesError) -> Option { cols: _, value: _, }, - ))) => Some(errno::UNIQUE_ALREADY_EXISTS), - _ => None, + ))) => errno::UNIQUE_ALREADY_EXISTS, + _ => return Err(err), }, - _ => None, - } + _ => return Err(err), + }; + Ok((errno, None)) } /// Converts a [`NodesError`] to an error code and logs, if possible. -pub fn err_to_errno_and_log>(func: AbiCall, err: NodesError) -> anyhow::Result { - let Some(errno) = err_to_errno(&err) else { - return Err(AbiRuntimeError { func, err }.into()); - }; +pub fn err_to_errno_and_log>(func: AbiCall, err: NodesError) -> anyhow::Result<(C, Option)> { + let (errno, message) = err_to_errno(err).map_err(|err| AbiRuntimeError { func, err })?; log::debug!( "abi call to {func} returned an errno: {errno} ({})", errno::strerror(errno).unwrap_or("") ); - Ok(errno.get().into()) + Ok((errno.get().into(), message)) } #[derive(Debug, thiserror::Error)] diff --git a/crates/core/src/host/wasmtime/wasm_instance_env.rs b/crates/core/src/host/wasmtime/wasm_instance_env.rs index 1188bc12783..882c85eb5df 100644 --- a/crates/core/src/host/wasmtime/wasm_instance_env.rs +++ b/crates/core/src/host/wasmtime/wasm_instance_env.rs @@ -323,7 +323,7 @@ impl WasmInstanceEnv { fn convert_wasm_result>(func: AbiCall, err: WasmError) -> RtResult { match err { - WasmError::Db(err) => err_to_errno_and_log(func, err), + WasmError::Db(err) => err_to_errno_and_log(func, err).map(|(code, _)| code), WasmError::BufferTooSmall => Ok(errno::BUFFER_TOO_SMALL.get().into()), WasmError::Wasm(err) => Err(err), } @@ -1629,9 +1629,7 @@ impl WasmInstanceEnv { let body_buf = mem.deref_slice(body_ptr, body_len)?; let body = bytes::Bytes::copy_from_slice(body_buf); - let result = env - .instance_env - .http_request(request, body) + let result = async { env.instance_env.http_request(request, body)?.await } // TODO(perf): Evaluate whether it's better to run this future on the "global" I/O Tokio executor, // rather than the thread-local database executors. .await; diff --git a/modules/module-test-ts/src/index.ts b/modules/module-test-ts/src/index.ts index 659bde643eb..a16eb528339 100644 --- a/modules/module-test-ts/src/index.ts +++ b/modules/module-test-ts/src/index.ts @@ -8,6 +8,7 @@ import { t, type Infer, type InferTypeOfRow, + errors, } from 'spacetimedb/server'; // ───────────────────────────────────────────────────────────────────────────── @@ -439,3 +440,21 @@ spacetimedb.reducer('assert_caller_identity_is_module_identity', {}, ctx => { console.info(`Called by the owner ${owner}`); } }); + +// Hit SpacetimeDB's schema HTTP route and return its result as a string. +// +// This is a silly thing to do, but an effective test of the procedure HTTP API. +spacetimedb.procedure('get_my_schema_via_http', t.string(), ctx => { + const module_identity = ctx.identity; + try { + const response = ctx.http.fetch( + `http://localhost:3000/v1/database/${module_identity}/schema?version=9` + ); + return response.text(); + } catch (e) { + if (e instanceof errors.HttpError) { + return e.message; + } + throw e; + } +}); diff --git a/modules/sdk-test-procedure-ts/package-lock.json b/modules/sdk-test-procedure-ts/package-lock.json new file mode 100644 index 00000000000..69a1f0ac828 --- /dev/null +++ b/modules/sdk-test-procedure-ts/package-lock.json @@ -0,0 +1,113 @@ +{ + "name": "sdk-test-module", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sdk-test-module", + "license": "ISC", + "dependencies": { + "fast-text-encoding": "^1.0.0" + }, + "devDependencies": { + "@types/fast-text-encoding": "^1.0.3", + "tsup": "^8.1.0" + } + }, + "../../node_modules/.pnpm/fast-text-encoding@1.0.6/node_modules/fast-text-encoding": { + "version": "1.0.6", + "license": "Apache-2.0" + }, + "../../node_modules/.pnpm/tsup@8.5.0_jiti@2.5.1_postcss@8.5.6_tsx@4.20.4_typescript@5.9.2/node_modules/tsup": { + "version": "8.5.0", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-require": "^5.1.0", + "cac": "^6.7.14", + "chokidar": "^4.0.3", + "consola": "^3.4.0", + "debug": "^4.4.0", + "esbuild": "^0.25.0", + "fix-dts-default-cjs-exports": "^1.0.0", + "joycon": "^3.1.1", + "picocolors": "^1.1.1", + "postcss-load-config": "^6.0.1", + "resolve-from": "^5.0.0", + "rollup": "^4.34.8", + "source-map": "0.8.0-beta.0", + "sucrase": "^3.35.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.11", + "tree-kill": "^1.2.2" + }, + "bin": { + "tsup": "dist/cli-default.js", + "tsup-node": "dist/cli-node.js" + }, + "devDependencies": { + "@microsoft/api-extractor": "^7.50.0", + "@rollup/plugin-json": "6.1.0", + "@swc/core": "1.10.18", + "@types/debug": "4.1.12", + "@types/node": "22.13.4", + "@types/resolve": "1.20.6", + "bumpp": "^10.0.3", + "flat": "6.0.1", + "postcss": "8.5.2", + "postcss-simple-vars": "7.0.1", + "prettier": "3.5.1", + "resolve": "1.22.10", + "rollup-plugin-dts": "6.1.1", + "sass": "1.85.0", + "strip-json-comments": "5.0.1", + "svelte": "5.19.9", + "svelte-preprocess": "6.0.3", + "terser": "^5.39.0", + "ts-essentials": "10.0.4", + "tsup": "8.3.6", + "typescript": "5.7.3", + "vitest": "3.0.6", + "wait-for-expect": "3.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@microsoft/api-extractor": "^7.36.0", + "@swc/core": "^1", + "postcss": "^8.4.12", + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "postcss": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@types/fast-text-encoding": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/fast-text-encoding/-/fast-text-encoding-1.0.3.tgz", + "integrity": "sha512-bbGJt6IyiuyAhPOX7htQDDzv2bDGJdWyd6X+e1BcdPzU3e5jyjOdB86LoTSoE80faY9v8Wt7/ix3Sp+coRJ03Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-text-encoding": { + "resolved": "../../node_modules/.pnpm/fast-text-encoding@1.0.6/node_modules/fast-text-encoding", + "link": true + }, + "node_modules/tsup": { + "resolved": "../../node_modules/.pnpm/tsup@8.5.0_jiti@2.5.1_postcss@8.5.6_tsx@4.20.4_typescript@5.9.2/node_modules/tsup", + "link": true + } + } +} diff --git a/modules/sdk-test-procedure-ts/package.json b/modules/sdk-test-procedure-ts/package.json new file mode 100644 index 00000000000..c8b4bc4ba89 --- /dev/null +++ b/modules/sdk-test-procedure-ts/package.json @@ -0,0 +1,13 @@ +{ + "name": "sdk-test-procedure-module", + "license": "ISC", + "type": "module", + "scripts": { + "build": "cargo build -p spacetimedb-standalone && cargo run -p spacetimedb-cli -- build", + "generate-ts": "cargo build -p spacetimedb-standalone && cargo run -p spacetimedb-cli -- generate --lang typescript --out-dir ts-codegen", + "publish": "cargo run -p spacetimedb-cli -- publish" + }, + "dependencies": { + "spacetimedb": "workspace:^" + } +} diff --git a/modules/sdk-test-procedure-ts/src/index.ts b/modules/sdk-test-procedure-ts/src/index.ts new file mode 100644 index 00000000000..07c4f6346fc --- /dev/null +++ b/modules/sdk-test-procedure-ts/src/index.ts @@ -0,0 +1,70 @@ +// ───────────────────────────────────────────────────────────────────────────── +// IMPORTS +// ───────────────────────────────────────────────────────────────────────────── +import { errors, schema, t, table } from 'spacetimedb/server'; + +const ReturnStruct = t.object('ReturnStruct', { + a: t.u32(), + b: t.string(), +}); + +const ReturnEnum = t.enum('ReturnEnum', { + A: t.u32(), + B: t.string(), +}); + +const spacetimedb = schema(); + +spacetimedb.procedure( + 'return_primitive', + { lhs: t.u32(), rhs: t.u32() }, + t.u32(), + (_ctx, { lhs, rhs }) => lhs + rhs +); + +spacetimedb.procedure( + 'return_struct', + { a: t.u32(), b: t.string() }, + ReturnStruct, + (_ctx, { a, b }) => ({ a, b }) +); + +spacetimedb.procedure( + 'return_enum_a', + { a: t.u32() }, + ReturnEnum, + (_ctx, { a }) => ReturnEnum.A(a) +); + +spacetimedb.procedure( + 'return_enum_b', + { b: t.string() }, + ReturnEnum, + (_ctx, { b }) => ReturnEnum.B(b) +); + +spacetimedb.procedure('will_panic', t.unit(), _ctx => { + throw new Error('This procedure is expected to panic'); +}); + +spacetimedb.procedure('read_my_schema', t.string(), ctx => { + const module_identity = ctx.identity; + const response = ctx.http.fetch( + `http://localhost:3000/v1/database/${module_identity}/schema?version=9` + ); + return response.text(); +}); + +spacetimedb.procedure('invalid_request', t.string(), ctx => { + try { + const response = ctx.http.fetch('http://foo.invalid/'); + throw new Error( + `Got result from requesting \`http://foo.invalid\`... huh?\n${response.text()}` + ); + } catch (e) { + if (e instanceof errors.HttpError) { + return e.message; + } + throw e; + } +}); diff --git a/modules/sdk-test-procedure-ts/tsconfig.json b/modules/sdk-test-procedure-ts/tsconfig.json new file mode 100644 index 00000000000..9ee145701c7 --- /dev/null +++ b/modules/sdk-test-procedure-ts/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + + "strict": true, + "declaration": true, + "emitDeclarationOnly": false, + "noEmit": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "allowImportingTsExtensions": true, + "noImplicitAny": true, + "moduleResolution": "Bundler", + "isolatedDeclarations": false, + + // This library is ESM-only, do not import commonjs modules + "esModuleInterop": false, + "allowSyntheticDefaultImports": false, + "useDefineForClassFields": true, + + // Crucial when using esbuild/swc/babel instead of tsc emit: + "verbatimModuleSyntax": true, + "isolatedModules": true + }, + "include": ["src/index.ts", "tests/**/*"], + "exclude": ["node_modules", "**/__tests__/*", "dist/**/*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f153d17db74..ffc766e8fbe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,15 +53,24 @@ importers: fast-text-encoding: specifier: ^1.0.0 version: 1.0.6 + headers-polyfill: + specifier: ^4.0.3 + version: 4.0.3 prettier: specifier: ^3.3.3 version: 3.6.2 react: specifier: ^18.0.0 || ^19.0.0-0 || ^19.0.0 version: 19.2.0 + statuses: + specifier: ^2.0.2 + version: 2.0.2 undici: specifier: ^6.19.2 version: 6.21.3 + url-polyfill: + specifier: ^1.1.14 + version: 1.1.14 devDependencies: '@eslint/js': specifier: ^9.17.0 @@ -75,6 +84,9 @@ importers: '@types/react': specifier: ^19.1.13 version: 19.1.13 + '@types/statuses': + specifier: ^2.0.6 + version: 2.0.6 '@typescript-eslint/eslint-plugin': specifier: ^8.18.2 version: 8.40.0(@typescript-eslint/parser@8.40.0(eslint@9.33.0(jiti@2.5.1))(typescript@5.9.3))(eslint@9.33.0(jiti@2.5.1))(typescript@5.9.3) @@ -252,7 +264,7 @@ importers: version: 3.9.1(@mdx-js/react@3.1.1(@types/react@19.2.0)(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.6.3) '@docusaurus/plugin-content-docs': specifier: ^3.9.2 - version: 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.0)(react@19.2.0))(debug@4.4.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.6.3) + version: 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.0)(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.6.3) '@docusaurus/preset-classic': specifier: 3.9.1 version: 3.9.1(@algolia/client-search@5.39.0)(@mdx-js/react@3.1.1(@types/react@19.2.0)(react@19.2.0))(@types/react@19.2.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(search-insights@2.17.3)(typescript@5.6.3) @@ -336,6 +348,12 @@ importers: specifier: workspace:^ version: link:../../crates/bindings-typescript + modules/sdk-test-procedure-ts: + dependencies: + spacetimedb: + specifier: workspace:^ + version: link:../../crates/bindings-typescript + modules/sdk-test-ts: dependencies: spacetimedb: @@ -3422,6 +3440,9 @@ packages: '@types/sockjs@0.3.36': resolution: {integrity: sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q==} + '@types/statuses@2.0.6': + resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -5534,6 +5555,9 @@ packages: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true + headers-polyfill@4.0.3: + resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} + history@4.10.1: resolution: {integrity: sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==} @@ -8024,6 +8048,10 @@ packages: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@3.9.0: resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} @@ -8515,6 +8543,9 @@ packages: file-loader: optional: true + url-polyfill@1.1.14: + resolution: {integrity: sha512-p4f3TTAG6ADVF3mwbXw7hGw+QJyw5CnNGvYh5fCuQQZIiuKUswqcznyV3pGDP9j0TSmC4UvRKm8kl1QsX1diiQ==} + use-callback-ref@1.3.3: resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} engines: {node: '>=10'} @@ -10763,6 +10794,46 @@ snapshots: - webpack-cli '@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.0)(react@19.2.0))(debug@4.4.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.6.3)': + dependencies: + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.0)(react@19.2.0))(debug@4.4.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.6.3) + '@docusaurus/logger': 3.9.2 + '@docusaurus/mdx-loader': 3.9.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/module-type-aliases': 3.9.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.0)(react@19.2.0))(debug@4.4.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.6.3))(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/types': 3.9.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/utils': 3.9.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/utils-common': 3.9.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/utils-validation': 3.9.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@types/react-router-config': 5.0.11 + combine-promises: 1.2.0 + fs-extra: 11.3.2 + js-yaml: 4.1.0 + lodash: 4.17.21 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + schema-dts: 1.1.5 + tslib: 2.8.1 + utility-types: 3.11.0 + webpack: 5.102.0 + transitivePeerDependencies: + - '@docusaurus/faster' + - '@mdx-js/react' + - '@parcel/css' + - '@rspack/core' + - '@swc/core' + - '@swc/css' + - bufferutil + - csso + - debug + - esbuild + - lightningcss + - supports-color + - typescript + - uglify-js + - utf-8-validate + - webpack-cli + + '@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.0)(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.6.3)': dependencies: '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.0)(react@19.2.0))(debug@4.4.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.6.3) '@docusaurus/logger': 3.9.2 @@ -11147,7 +11218,7 @@ snapshots: dependencies: '@docusaurus/mdx-loader': 3.9.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@docusaurus/module-type-aliases': 3.9.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.0)(react@19.2.0))(debug@4.4.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.6.3) + '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.0)(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.6.3) '@docusaurus/utils': 3.9.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@docusaurus/utils-common': 3.9.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@types/history': 4.7.11 @@ -11167,7 +11238,7 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/theme-common@3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.0)(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.6.3))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + '@docusaurus/theme-common@3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.0)(react@19.2.0))(debug@4.4.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.6.3))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@docusaurus/mdx-loader': 3.9.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@docusaurus/module-type-aliases': 3.9.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -11191,6 +11262,30 @@ snapshots: - uglify-js - webpack-cli + '@docusaurus/theme-common@3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.0)(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.6.3))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@docusaurus/mdx-loader': 3.9.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/module-type-aliases': 3.9.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.0)(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.6.3) + '@docusaurus/utils': 3.9.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@docusaurus/utils-common': 3.9.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@types/history': 4.7.11 + '@types/react': 18.3.23 + '@types/react-router-config': 5.0.11 + clsx: 2.1.1 + parse-numeric-range: 1.3.0 + prism-react-renderer: 2.4.1(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + tslib: 2.8.1 + utility-types: 3.11.0 + transitivePeerDependencies: + - '@swc/core' + - esbuild + - supports-color + - uglify-js + - webpack-cli + '@docusaurus/theme-search-algolia@3.9.1(@algolia/client-search@5.39.0)(@mdx-js/react@3.1.1(@types/react@19.2.0)(react@19.2.0))(@types/react@19.2.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(search-insights@2.17.3)(typescript@5.6.3)': dependencies: '@docsearch/react': 4.2.0(@algolia/client-search@5.39.0)(@types/react@19.2.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(search-insights@2.17.3) @@ -13464,6 +13559,8 @@ snapshots: dependencies: '@types/node': 22.18.0 + '@types/statuses@2.0.6': {} + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} @@ -16488,6 +16585,8 @@ snapshots: he@1.2.0: {} + headers-polyfill@4.0.3: {} + history@4.10.1: dependencies: '@babel/runtime': 7.28.4 @@ -19489,6 +19588,8 @@ snapshots: statuses@2.0.1: {} + statuses@2.0.2: {} + std-env@3.9.0: {} string-width@4.2.3: @@ -19995,6 +20096,8 @@ snapshots: optionalDependencies: file-loader: 6.2.0(webpack@5.102.0) + url-polyfill@1.1.14: {} + use-callback-ref@1.3.3(@types/react@19.2.0)(react@19.2.0): dependencies: react: 19.2.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 44dd8100610..d8d54dce23a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -8,5 +8,6 @@ packages: - 'modules/module-test-ts' - 'modules/quickstart-chat-ts' - 'modules/sdk-test-connect-disconnect-ts' + - 'modules/sdk-test-procedure-ts' - 'modules/sdk-test-ts' - 'docs'