toiljs 0.0.68 → 0.0.70

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/build/cli/.tsbuildinfo +1 -1
  3. package/build/client/.tsbuildinfo +1 -1
  4. package/build/client/rpc.js +10 -4
  5. package/build/client/stream/client.js +108 -5
  6. package/build/compiler/.tsbuildinfo +1 -1
  7. package/build/compiler/index.d.ts +2 -0
  8. package/build/compiler/index.js +282 -2
  9. package/build/compiler/toil-docs.generated.js +3 -2
  10. package/build/compiler/vite.js +8 -0
  11. package/build/devserver/.tsbuildinfo +1 -1
  12. package/build/devserver/daemon/host.d.ts +1 -7
  13. package/build/devserver/daemon/host.js +5 -59
  14. package/build/devserver/daemon/index.d.ts +1 -0
  15. package/build/devserver/daemon/index.js +17 -4
  16. package/build/devserver/db/database.js +1 -1
  17. package/build/devserver/db/routeKinds.d.ts +6 -0
  18. package/build/devserver/db/routeKinds.js +40 -0
  19. package/build/devserver/index.d.ts +0 -1
  20. package/build/devserver/index.js +0 -1
  21. package/build/devserver/runtime/module.d.ts +1 -0
  22. package/build/devserver/runtime/module.js +18 -2
  23. package/build/devserver/stream/index.js +4 -3
  24. package/build/devserver/wasm/surface.d.ts +2 -0
  25. package/build/devserver/wasm/surface.js +35 -4
  26. package/docs/derive.md +159 -0
  27. package/docs/index.md +1 -1
  28. package/docs/streams.md +49 -18
  29. package/examples/basic/server/services/Stats.ts +11 -3
  30. package/examples/basic/server/services/remotes.ts +8 -2
  31. package/package.json +3 -2
  32. package/server/runtime/exports/index.ts +8 -1
  33. package/server/runtime/index.ts +1 -0
  34. package/server/runtime/rpc/Rpc.ts +66 -0
  35. package/src/client/rpc.ts +21 -12
  36. package/src/client/stream/client.ts +138 -8
  37. package/src/compiler/index.ts +352 -2
  38. package/src/compiler/toil-docs.generated.ts +3 -2
  39. package/src/compiler/vite.ts +16 -0
  40. package/src/devserver/daemon/host.ts +10 -110
  41. package/src/devserver/daemon/index.ts +19 -6
  42. package/src/devserver/db/database.ts +1 -1
  43. package/src/devserver/db/routeKinds.ts +44 -0
  44. package/src/devserver/index.ts +0 -1
  45. package/src/devserver/runtime/host.ts +3 -7
  46. package/src/devserver/runtime/module.ts +30 -4
  47. package/src/devserver/stream/index.ts +8 -4
  48. package/src/devserver/wasm/surface.ts +33 -4
  49. package/test/daemon-build.test.ts +53 -0
  50. package/test/daemon-catalog.test.ts +78 -3
  51. package/test/daemon-emulation.test.ts +27 -29
  52. package/test/devserver-database.test.ts +93 -0
  53. package/test/fixtures/bignum-wire/spec.ts +3 -5
  54. package/test/fixtures/daemon-app.ts +25 -21
  55. package/test/fixtures/stream-typed.ts +41 -0
  56. package/test/rpc-dispatch.test.ts +132 -0
  57. package/test/rpc-kinds.test.ts +18 -0
  58. package/test/rpc.test.ts +20 -4
  59. package/test/stream-emulation.test.ts +39 -0
  60. package/build/devserver/mstore/store.d.ts +0 -18
  61. package/build/devserver/mstore/store.js +0 -82
  62. package/src/devserver/mstore/store.ts +0 -121
package/src/client/rpc.ts CHANGED
@@ -3,14 +3,12 @@
3
3
  * the toilscript server build into the project's `shared/server.ts`
4
4
  * (`declare global { const Server }`); this module is the runtime behind it.
5
5
  *
6
- * Two surfaces live under `Server`:
7
- * - `Server.REST.<controller>.<route>(args)` is a WORKING fetch client. The
8
- * generated `shared/server.ts` attaches it to `globalThis.__toilRest` when
9
- * imported; the proxy below surfaces it under `Server.REST`.
10
- * - `Server.<service>.<method>()` (RPC) is not wired yet, so any call throws.
11
- * The pipeline (tags -> generated types -> proxy) is in place; only the
12
- * network dispatch is a TODO. Build the server (`npm run build:server`) to
13
- * (re)generate the typed surface.
6
+ * Three surfaces live under `Server`, all attached by the generated `shared/server.ts` on import:
7
+ * - `Server.REST.<controller>.<route>(args)` - a fetch client (`globalThis.__toilRest`).
8
+ * - `Server.Stream.<class>.connect()` - the stream client (`globalThis.__toilStream`).
9
+ * - `Server.<service>.<method>(args)` and a free `Server.<remote>(args)` - the RPC client
10
+ * (`globalThis.__toilRpc`): each POSTs to `/__toil_rpc` with a method id and @data-encoded args.
11
+ * Build the server (`npm run build:server`) to (re)generate the typed surface + attach the clients.
14
12
  */
15
13
 
16
14
  /** A recursive proxy that throws on call, used when the REST client hasn't loaded. */
@@ -18,7 +16,7 @@ function restMissingStub(path: string): unknown {
18
16
  const call = (): never => {
19
17
  throw new Error(
20
18
  `toiljs REST: ${path}() is unavailable. The generated REST client has not loaded - ` +
21
- `import a type from your 'shared/server' (so the client attaches), or run 'npm run build:server'.`,
19
+ `run 'npm run build:server' to generate shared/server.ts; the client then attaches automatically.`,
22
20
  );
23
21
  };
24
22
  return new Proxy(call, {
@@ -37,7 +35,7 @@ function streamMissingStub(path: string): unknown {
37
35
  const call = (): never => {
38
36
  throw new Error(
39
37
  `toiljs Stream: ${path}() is unavailable. The generated stream client has not loaded - ` +
40
- `import a type from your 'shared/server' (so the client attaches), or run 'npm run build:server'.`,
38
+ `run 'npm run build:server' to generate shared/server.ts; the client then attaches automatically.`,
41
39
  );
42
40
  };
43
41
  return new Proxy(call, {
@@ -55,8 +53,8 @@ function streamMissingStub(path: string): unknown {
55
53
  function rpcStub(path: string): unknown {
56
54
  const call = (): never => {
57
55
  throw new Error(
58
- `toiljs RPC: ${path}() is not available yet. The client<->server transport ` +
59
- `is not wired; this is a generated stub. Remote calls will work once transport lands.`,
56
+ `toiljs RPC: ${path}() is unavailable. The generated RPC client has not loaded - ` +
57
+ `run 'npm run build:server' to generate shared/server.ts; the client then attaches automatically.`,
60
58
  );
61
59
  };
62
60
  return new Proxy(call, {
@@ -73,6 +71,12 @@ function rpcStub(path: string): unknown {
73
71
  const stream = (globalThis as { __toilStream?: unknown }).__toilStream;
74
72
  return stream !== undefined ? stream : streamMissingStub('Server.Stream');
75
73
  }
74
+ // `Server.<service>` / `Server.<remote>` surface the generated RPC client. Its top-level
75
+ // keys are the @service names (-> a `{ method }` object) and free @remote functions.
76
+ if (path === 'Server') {
77
+ const rpc = (globalThis as { __toilRpc?: Record<string, unknown> }).__toilRpc;
78
+ if (rpc !== undefined && prop in rpc) return rpc[prop];
79
+ }
76
80
  return rpcStub(`${path}.${prop}`);
77
81
  },
78
82
  apply() {
@@ -86,3 +90,8 @@ function rpcStub(path: string): unknown {
86
90
  * come from the generated `shared/server.ts`. toiljs assigns this to `globalThis`.
87
91
  */
88
92
  export const Server: unknown = rpcStub('Server');
93
+
94
+ // Back the generated `declare global { const Server }` (shared/server.ts) with a runtime value, so an
95
+ // app reaches `Server.REST` / `Server.Stream.<Name>` via the global without an import (the design note
96
+ // above). Idempotent - the proxy is a singleton; importing `toiljs/client` evaluates this once.
97
+ (globalThis as Record<string, unknown>).Server = Server;
@@ -1,9 +1,12 @@
1
1
  /**
2
2
  * Client runtime for the typed `@stream` channel (doc 08 section 8.2). `Server.Stream.<Class>.connect(path?)`
3
- * opens a browser `WebSocket` to the class's `route` (same origin) and returns a channel:
4
- * `onMessage` / `send` / `onClose` / `close`. RAW byte mode (the default `@message` bridge); the typed
5
- * `@data` codec (`messageMode = 'data'`) is a follow-up. The generated `shared/server.ts` (toilscript
6
- * hot pass) attaches `makeStreamClient(routes)` to `globalThis.__toilStream`, and `Server.Stream`
3
+ * opens a browser `WebTransport` session to the class's `route` on the production edge (an `https://`
4
+ * stream-tier origin), or a `WebSocket` against the dev server (a `ws(s)://` origin), and returns a channel:
5
+ * `onMessage` / `send` / `onClose` / `close`. A raw `@stream` channel sends `Uint8Array`; a typed
6
+ * `@stream({ message: T })` channel sends the `@data` class and encodes it on send (the per-class
7
+ * encoder the generated module passes). The inbound reply is always raw bytes. The generated
8
+ * `shared/server.ts` (toilscript hot pass) attaches `makeStreamClient(routes, undefined, encoders)` to
9
+ * `globalThis.__toilStream`, and `Server.Stream`
7
10
  * (`rpc.ts`) surfaces it - the same wiring the REST client uses via `globalThis.__toilRest`.
8
11
  */
9
12
 
@@ -42,10 +45,13 @@ function connectStream<TSend = Uint8Array>(
42
45
  let opened = false;
43
46
  let messageCb: ((data: Uint8Array) => void) | undefined;
44
47
  let closeCb: ((code: number) => void) | undefined;
48
+ const pending: Uint8Array[] = [];
45
49
 
46
50
  const channel: StreamChannel<TSend> = {
47
51
  onMessage: (cb): void => {
48
52
  messageCb = cb;
53
+ for (const m of pending) cb(m);
54
+ pending.length = 0;
49
55
  },
50
56
  onClose: (cb): void => {
51
57
  closeCb = cb;
@@ -54,7 +60,11 @@ function connectStream<TSend = Uint8Array>(
54
60
  if (ws.readyState !== WebSocket.OPEN) return;
55
61
  // A typed channel encodes the @data message; a raw channel sends the bytes as-is.
56
62
  const bytes = encode ? encode(data as never) : (data as unknown as Uint8Array);
57
- ws.send(bytes as BufferSource);
63
+ try {
64
+ ws.send(bytes as BufferSource);
65
+ } catch {
66
+ /* socket transitioned OPEN -> CLOSING between the readyState check and send */
67
+ }
58
68
  },
59
69
  close: (): void => {
60
70
  ws.close();
@@ -66,7 +76,12 @@ function connectStream<TSend = Uint8Array>(
66
76
  resolve(channel);
67
77
  });
68
78
  ws.addEventListener('message', (event: MessageEvent) => {
69
- if (event.data instanceof ArrayBuffer) messageCb?.(new Uint8Array(event.data));
79
+ if (!(event.data instanceof ArrayBuffer)) return;
80
+ const bytes = new Uint8Array(event.data);
81
+ // Buffer a reply that arrives before onMessage() registers (mirror the WebTransport path),
82
+ // so an eager server reply is not silently dropped.
83
+ if (messageCb) messageCb(bytes);
84
+ else pending.push(bytes);
70
85
  });
71
86
  ws.addEventListener('close', (event: CloseEvent) => {
72
87
  if (!opened) reject(new Error(`stream connect failed (closed ${String(event.code)})`));
@@ -78,12 +93,125 @@ function connectStream<TSend = Uint8Array>(
78
93
  });
79
94
  }
80
95
 
96
+ /** Open a browser `WebTransport` session to `url` (an `https://` stream-tier origin) and resolve a
97
+ * channel once the session is ready. The browser drives the QUIC handshake, H3 Extended-CONNECT, and
98
+ * the RFC 9297 Quarter-Stream-ID datagram framing, so `send`/`onMessage` deal in raw bytes (no manual
99
+ * prefix). Rejects if the session fails to open (wrong node / unreachable / cert); a server close AFTER
100
+ * open surfaces via `onClose(code)`. This is the PRODUCTION transport - the L2/L3 edge is
101
+ * WebTransport-only; the dev server uses the `connectStream` WebSocket above. */
102
+ function connectStreamWT<TSend = Uint8Array>(
103
+ url: string,
104
+ encode?: (msg: never) => Uint8Array,
105
+ ): Promise<StreamChannel<TSend>> {
106
+ return new Promise((resolve, reject) => {
107
+ if (!('WebTransport' in globalThis)) {
108
+ reject(new Error('WebTransport is not available in this browser'));
109
+ return;
110
+ }
111
+ let transport: WebTransport;
112
+ try {
113
+ transport = new WebTransport(url);
114
+ } catch (e) {
115
+ reject(e instanceof Error ? e : new Error(String(e)));
116
+ return;
117
+ }
118
+ let messageCb: ((data: Uint8Array) => void) | undefined;
119
+ let closeCb: ((code: number) => void) | undefined;
120
+ let writer: WritableStreamDefaultWriter<Uint8Array> | undefined;
121
+ let opened = false;
122
+ const pending: Uint8Array[] = [];
123
+
124
+ const channel: StreamChannel<TSend> = {
125
+ onMessage: (cb): void => {
126
+ messageCb = cb;
127
+ for (const m of pending) cb(m);
128
+ pending.length = 0;
129
+ },
130
+ onClose: (cb): void => {
131
+ closeCb = cb;
132
+ },
133
+ send: (data): void => {
134
+ if (!writer) return;
135
+ const bytes = encode ? encode(data as never) : (data as unknown as Uint8Array);
136
+ // Fire-and-forget; a write that rejects (transport closing / backpressure) is surfaced
137
+ // via onClose, so swallow it here to avoid an unhandled promise rejection.
138
+ void writer.write(bytes).catch(() => {});
139
+ },
140
+ close: (): void => {
141
+ try {
142
+ transport.close();
143
+ } catch {
144
+ /* already closing */
145
+ }
146
+ },
147
+ };
148
+
149
+ // A close AFTER open (guest reject, idle sweep, server shutdown) reports through onClose; a
150
+ // failure BEFORE open rejects the connect() promise via the `ready` catch below.
151
+ transport.closed
152
+ .then((info) => {
153
+ if (opened) closeCb?.(info?.closeCode ?? 0);
154
+ })
155
+ .catch(() => {
156
+ if (opened) closeCb?.(1);
157
+ });
158
+
159
+ transport.ready
160
+ .then(() => {
161
+ opened = true;
162
+ writer = transport.datagrams.writable.getWriter();
163
+ const reader = transport.datagrams.readable.getReader();
164
+ void (async (): Promise<void> => {
165
+ try {
166
+ for (;;) {
167
+ const { value, done } = await reader.read();
168
+ if (done) break;
169
+ const bytes =
170
+ value instanceof Uint8Array
171
+ ? value
172
+ : new Uint8Array(value as ArrayBufferLike);
173
+ if (messageCb) messageCb(bytes);
174
+ else pending.push(bytes);
175
+ }
176
+ } catch {
177
+ /* read loop ended on session close */
178
+ }
179
+ })();
180
+ resolve(channel);
181
+ })
182
+ .catch((e) => reject(e instanceof Error ? e : new Error(String(e))));
183
+ });
184
+ }
185
+
186
+ /** Pick the transport by origin scheme: `https://` -> WebTransport (the production L2/L3 edge),
187
+ * `ws(s)://` -> WebSocket (the dev server). The generated `shared/server.ts` sets the scheme per build
188
+ * (edge: `https://wt.<tenant>`; dev: same-origin `wss://`). */
189
+ function openChannel<TSend = Uint8Array>(
190
+ url: string,
191
+ encode?: (msg: never) => Uint8Array,
192
+ ): Promise<StreamChannel<TSend>> {
193
+ return url.startsWith('https://')
194
+ ? connectStreamWT<TSend>(url, encode)
195
+ : connectStream<TSend>(url, encode);
196
+ }
197
+
81
198
  /** The same-origin WebSocket base (`ws://` / `wss://` per the page protocol). */
82
199
  function defaultOrigin(): string {
83
200
  const loc = globalThis.location;
84
201
  return `${loc.protocol === 'https:' ? 'wss:' : 'ws:'}//${loc.host}`;
85
202
  }
86
203
 
204
+ /** Resolve the stream-tier origin when the generated client passes none. A deploy/runtime override -
205
+ * `globalThis.__TOIL_STREAM_ORIGIN__` (e.g. `"https://wt.dacely.com"`, the production L2/L3 edge) -
206
+ * wins; otherwise fall back to the same-origin WebSocket base (the dev server). The edge override is
207
+ * `https://` so `openChannel` selects WebTransport; the dev fallback is `ws(s)://` so it selects
208
+ * WebSocket. This is how a deployed app points at its `wt.<tenant>` tier without the build knowing it. */
209
+ function resolveStreamOrigin(): string {
210
+ const override = (globalThis as { __TOIL_STREAM_ORIGIN__?: unknown }).__TOIL_STREAM_ORIGIN__;
211
+ if (typeof override === 'string' && override.length > 0) return override;
212
+ return defaultOrigin();
213
+ }
214
+
87
215
  /**
88
216
  * Build the `Server.Stream` client from the generated route map (`{ ClassName: route }`). `origin`
89
217
  * defaults to the page origin. `encoders` carries one `@data` encoder per typed `@stream({ message: T })`
@@ -95,12 +223,14 @@ export function makeStreamClient(
95
223
  origin?: string,
96
224
  encoders?: Record<string, (msg: never) => Uint8Array>,
97
225
  ): StreamClient {
98
- const base = origin ?? defaultOrigin();
99
226
  const client: StreamClient = {};
100
227
  for (const [name, route] of Object.entries(routes)) {
101
228
  const encode = encoders?.[name];
102
229
  client[name] = {
103
- connect: (path = ''): Promise<StreamChannel> => connectStream(`${base}${route}${path}`, encode),
230
+ // Resolve the origin LAZILY, per connect() - so a deploy/app that sets
231
+ // `__TOIL_STREAM_ORIGIN__` after this module loads is still honoured.
232
+ connect: (path = ''): Promise<StreamChannel> =>
233
+ openChannel(`${origin ?? resolveStreamOrigin()}${route}${path}`, encode),
104
234
  };
105
235
  }
106
236
  return client;