Skip to content

Commit 910ab30

Browse files
committed
[TS] Http procedure API
1 parent cccddf3 commit 910ab30

File tree

21 files changed

+538
-65
lines changed

21 files changed

+538
-65
lines changed

crates/bindings-typescript/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,10 @@
158158
"dependencies": {
159159
"base64-js": "^1.5.1",
160160
"fast-text-encoding": "^1.0.0",
161-
"prettier": "^3.3.3"
161+
"headers-polyfill": "^4.0.3",
162+
"prettier": "^3.3.3",
163+
"statuses": "^2.0.2",
164+
"url-polyfill": "^1.1.14"
162165
},
163166
"peerDependencies": {
164167
"react": "^18.0.0 || ^19.0.0-0 || ^19.0.0",
@@ -177,6 +180,7 @@
177180
"@size-limit/file": "^11.2.0",
178181
"@types/fast-text-encoding": "^1.0.3",
179182
"@types/react": "^19.1.13",
183+
"@types/statuses": "^2.0.6",
180184
"@typescript-eslint/eslint-plugin": "^8.18.2",
181185
"@typescript-eslint/parser": "^8.18.2",
182186
"@vitest/coverage-v8": "^3.2.4",
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export { default as HttpHeaderPair } from './autogen/http_header_pair_type';
2+
export { default as HttpHeaders } from './autogen/http_headers_type';
3+
export { default as HttpMethod } from './autogen/http_method_type';
4+
export { default as HttpRequest } from './autogen/http_request_type';
5+
export { default as HttpResponse } from './autogen/http_response_type';
6+
export { default as HttpVersion } from './autogen/http_version_type';

crates/bindings-typescript/src/lib/procedures.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { AlgebraicType, ProductType } from '../lib/algebraic_type';
22
import type { ConnectionId } from '../lib/connection_id';
33
import type { Identity } from '../lib/identity';
44
import type { Timestamp } from '../lib/timestamp';
5+
import type { HttpClient } from '../server/http_internal';
56
import type { ParamsObj } from './reducers';
67
import { MODULE_DEF, type UntypedSchemaDef } from './schema';
78
import type { Infer, InferTypeOfRow, TypeBuilder } from './type_builders';
@@ -19,6 +20,7 @@ export interface ProcedureCtx<S extends UntypedSchemaDef> {
1920
readonly identity: Identity;
2021
readonly timestamp: Timestamp;
2122
readonly connectionId: ConnectionId | null;
23+
readonly http: HttpClient;
2224
}
2325

2426
export function procedure<

crates/bindings-typescript/src/server/errors.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
export class SpacetimeHostError extends Error {
99
public readonly code: number;
1010
public readonly message: string;
11-
constructor(code: number) {
11+
constructor(code: number, message?: string) {
1212
super();
1313
const proto = Object.getPrototypeOf(this);
1414
let cls;
@@ -24,7 +24,7 @@ export class SpacetimeHostError extends Error {
2424
}
2525
Object.setPrototypeOf(this, cls.prototype);
2626
this.code = cls.CODE;
27-
this.message = cls.MESSAGE;
27+
this.message = message ?? cls.MESSAGE;
2828
}
2929
get name(): string {
3030
return errnoToClass.get(this.code)?.name ?? 'SpacetimeHostError';
@@ -151,6 +151,8 @@ const errorData = {
151151
20,
152152
'ABI call can only be made while within a read-only transaction',
153153
],
154+
155+
HttpError: [21, 'The HTTP request failed'],
154156
} as const;
155157

156158
function mapEntries<const T extends Record<string, any>, U>(
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { Headers, SyncResponse } from './http_internal';
2+
export type { BodyInit, HeadersInit, ResponseInit } from './http_internal';
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import { Headers, headersToList } from 'headers-polyfill';
2+
import status from 'statuses';
3+
import BinaryReader from '../lib/binary_reader';
4+
import BinaryWriter from '../lib/binary_writer';
5+
import {
6+
HttpHeaders,
7+
HttpMethod,
8+
HttpRequest,
9+
HttpResponse,
10+
} from '../lib/http_types';
11+
import type { TimeDuration } from '../lib/time_duration';
12+
import { bsatnBaseSize } from '../lib/util';
13+
import type { Infer } from '../sdk';
14+
import { sys } from './runtime';
15+
16+
export { Headers };
17+
18+
const { freeze } = Object;
19+
20+
export type BodyInit = ArrayBuffer | ArrayBufferView | string;
21+
export type HeadersInit = [string, string][] | Record<string, string> | Headers;
22+
export interface ResponseInit {
23+
headers?: HeadersInit;
24+
status?: number;
25+
statusText?: string;
26+
}
27+
28+
const textEncoder = new TextEncoder();
29+
const textDecoder = new TextDecoder('utf-8' /* { fatal: true } */);
30+
31+
const makeResponse = Symbol('makeResponse');
32+
33+
// based on deno's type of the same name
34+
interface InnerResponse {
35+
type: 'basic' | 'cors' | 'default' | 'error' | 'opaque' | 'opaqueredirect';
36+
url: string | null;
37+
status: number;
38+
statusText: string;
39+
headers: Headers;
40+
aborted: boolean;
41+
}
42+
43+
export class SyncResponse {
44+
#body: string | ArrayBuffer | null;
45+
#inner: InnerResponse;
46+
47+
constructor(body?: BodyInit | null, init?: ResponseInit) {
48+
if (body == null) {
49+
this.#body = null;
50+
} else if (typeof body === 'string') {
51+
this.#body = body;
52+
} else {
53+
// this call is fine, the typings are just weird
54+
this.#body = new Uint8Array<ArrayBuffer>(body as any).buffer;
55+
}
56+
57+
// there's a type mismatch - headers-polyfill's typing doesn't expect its
58+
// own `Headers` type, even though the actual code handles it correctly.
59+
this.#inner = {
60+
headers: new Headers(init?.headers as any),
61+
status: init?.status ?? 200,
62+
statusText: init?.statusText ?? '',
63+
type: 'default',
64+
url: null,
65+
aborted: false,
66+
};
67+
}
68+
69+
static [makeResponse](body: BodyInit | null, inner: InnerResponse) {
70+
const me = new SyncResponse(body);
71+
me.#inner = inner;
72+
return me;
73+
}
74+
75+
get headers(): Headers {
76+
return this.#inner.headers;
77+
}
78+
get status(): number {
79+
return this.#inner.status;
80+
}
81+
get statusText() {
82+
return this.#inner.statusText;
83+
}
84+
get ok(): boolean {
85+
return 200 <= this.#inner.status && this.#inner.status <= 299;
86+
}
87+
get url(): string {
88+
return this.#inner.url ?? '';
89+
}
90+
get type() {
91+
return this.#inner.type;
92+
}
93+
94+
arrayBuffer(): ArrayBuffer {
95+
return this.bytes().buffer;
96+
}
97+
98+
bytes(): Uint8Array<ArrayBuffer> {
99+
if (this.#body == null) {
100+
return new Uint8Array();
101+
} else if (typeof this.#body === 'string') {
102+
return textEncoder.encode(this.#body);
103+
} else {
104+
return new Uint8Array(this.#body);
105+
}
106+
}
107+
108+
json(): any {
109+
return JSON.parse(this.text());
110+
}
111+
112+
text(): string {
113+
if (this.#body == null) {
114+
return '';
115+
} else if (typeof this.#body === 'string') {
116+
return this.#body;
117+
} else {
118+
return textDecoder.decode(this.#body);
119+
}
120+
}
121+
}
122+
123+
export interface RequestOptions {
124+
/** A BodyInit object or null to set request's body. */
125+
body?: BodyInit | null;
126+
/** A Headers object, an object literal, or an array of two-item arrays to set request's headers. */
127+
headers?: HeadersInit;
128+
/** A string to set request's method. */
129+
method?: string;
130+
/** A duration, after which the request will timeout */
131+
timeout?: TimeDuration;
132+
// /** 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. */
133+
// redirect?: RequestRedirect;
134+
}
135+
136+
export interface HttpClient {
137+
fetch(url: URL | string, init?: RequestOptions): SyncResponse;
138+
}
139+
140+
const requestBaseSize = bsatnBaseSize({ types: [] }, HttpRequest.algebraicType);
141+
142+
const methods = new Map<string, Infer<typeof HttpMethod>>([
143+
['GET', { tag: 'Get' }],
144+
['HEAD', { tag: 'Head' }],
145+
['POST', { tag: 'Post' }],
146+
['PUT', { tag: 'Put' }],
147+
['DELETE', { tag: 'Delete' }],
148+
['CONNECT', { tag: 'Connect' }],
149+
['OPTIONS', { tag: 'Options' }],
150+
['TRACE', { tag: 'Trace' }],
151+
['PATCH', { tag: 'Patch' }],
152+
]);
153+
154+
function fetch(url: URL | string, init: RequestOptions = {}) {
155+
const method = methods.get(init.method?.toUpperCase() ?? 'GET') ?? {
156+
tag: 'Extension',
157+
value: init.method!,
158+
};
159+
const headers: Infer<typeof HttpHeaders> = {
160+
// anys because the typings are wonky - see comment in SyncResponse.constructor
161+
entries: headersToList(new Headers(init.headers as any) as any)
162+
.flatMap(([k, v]) => (Array.isArray(v) ? v.map(v => [k, v]) : [[k, v]]))
163+
.map(([name, value]) => ({ name, value: textEncoder.encode(value) })),
164+
};
165+
const uri = '' + url;
166+
const request: Infer<typeof HttpRequest> = freeze({
167+
method,
168+
headers,
169+
timeout: init.timeout,
170+
uri,
171+
version: { tag: 'Http11' } as const,
172+
});
173+
const requestBuf = new BinaryWriter(requestBaseSize);
174+
HttpRequest.serialize(requestBuf, request);
175+
const body =
176+
init.body == null
177+
? new Uint8Array()
178+
: typeof init.body === 'string'
179+
? init.body
180+
: new Uint8Array<ArrayBuffer>(init.body as any);
181+
const [responseBuf, responseBody] = sys.procedure_http_request(
182+
requestBuf.getBuffer(),
183+
body
184+
);
185+
const response = HttpResponse.deserialize(new BinaryReader(responseBuf));
186+
return SyncResponse[makeResponse](responseBody, {
187+
type: 'basic',
188+
url: uri,
189+
status: response.code,
190+
statusText: status(response.code),
191+
headers: new Headers(),
192+
aborted: false,
193+
});
194+
}
195+
196+
freeze(fetch);
197+
198+
export const httpClient: HttpClient = freeze({ fetch });
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
import 'fast-text-encoding';
2+
import 'url-polyfill';

crates/bindings-typescript/src/server/procedures.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Identity } from '../lib/identity';
66
import { PROCEDURES, type ProcedureCtx } from '../lib/procedures';
77
import { MODULE_DEF, type UntypedSchemaDef } from '../lib/schema';
88
import type { Timestamp } from '../lib/timestamp';
9+
import { httpClient } from './http_internal';
910
import { sys } from './runtime';
1011

1112
const { freeze } = Object;
@@ -28,6 +29,7 @@ export function callProcedure(
2829
sender,
2930
timestamp,
3031
connectionId,
32+
http: httpClient,
3133
get identity() {
3234
return new Identity(sys.identity().__identity__);
3335
},

crates/bindings-typescript/src/server/runtime.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -623,7 +623,12 @@ function wrapSyscall<F extends (...args: any[]) => any>(
623623
hasOwn(e, '__code_error__') &&
624624
typeof e.__code_error__ == 'number'
625625
) {
626-
throw new SpacetimeHostError(e.__code_error__);
626+
const message =
627+
hasOwn(e, '__error_message__') &&
628+
typeof e.__error_message__ === 'string'
629+
? e.__error_message__
630+
: undefined;
631+
throw new SpacetimeHostError(e.__code_error__, message);
627632
}
628633
throw e;
629634
}

crates/bindings-typescript/src/server/sys.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,4 +88,9 @@ declare module 'spacetime:sys@1.2' {
8888
};
8989

9090
export function register_hooks(hooks: ModuleHooks);
91+
92+
export function procedure_http_request(
93+
request: Uint8Array,
94+
body: Uint8Array | string
95+
): [Uint8Array, Uint8Array];
9196
}

0 commit comments

Comments
 (0)