toiljs 0.0.66 → 0.0.68

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 (111) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +63 -61
  3. package/build/backend/.tsbuildinfo +1 -1
  4. package/build/cli/.tsbuildinfo +1 -1
  5. package/build/cli/index.js +13 -1
  6. package/build/client/.tsbuildinfo +1 -1
  7. package/build/client/index.d.ts +2 -0
  8. package/build/client/index.js +1 -0
  9. package/build/client/rpc.js +21 -1
  10. package/build/client/stream/client.d.ts +11 -0
  11. package/build/client/stream/client.js +59 -0
  12. package/build/compiler/.tsbuildinfo +1 -1
  13. package/build/compiler/config.d.ts +2 -0
  14. package/build/compiler/config.js +9 -7
  15. package/build/compiler/index.d.ts +1 -0
  16. package/build/compiler/index.js +22 -6
  17. package/build/compiler/toil-docs.generated.js +3 -3
  18. package/build/devserver/.tsbuildinfo +1 -1
  19. package/build/devserver/daemon/index.js +4 -3
  20. package/build/devserver/daemon/runtime.d.ts +13 -0
  21. package/build/devserver/daemon/runtime.js +29 -0
  22. package/build/devserver/db/catalog.js +8 -12
  23. package/build/devserver/db/database.d.ts +1 -0
  24. package/build/devserver/db/database.js +10 -0
  25. package/build/devserver/db/derives.d.ts +7 -0
  26. package/build/devserver/db/derives.js +94 -0
  27. package/build/devserver/db/index.d.ts +1 -0
  28. package/build/devserver/db/index.js +1 -0
  29. package/build/devserver/db/types.d.ts +1 -0
  30. package/build/devserver/db/types.js +1 -0
  31. package/build/devserver/http/proxy.d.ts +5 -1
  32. package/build/devserver/http/proxy.js +39 -36
  33. package/build/devserver/http/runtime.d.ts +62 -0
  34. package/build/devserver/http/runtime.js +194 -0
  35. package/build/devserver/index.d.ts +2 -0
  36. package/build/devserver/index.js +1 -0
  37. package/build/devserver/production-ipc.d.ts +50 -0
  38. package/build/devserver/production-ipc.js +21 -0
  39. package/build/devserver/production-worker.d.ts +1 -0
  40. package/build/devserver/production-worker.js +73 -0
  41. package/build/devserver/production.d.ts +35 -0
  42. package/build/devserver/production.js +502 -0
  43. package/build/devserver/runtime/module.d.ts +5 -0
  44. package/build/devserver/runtime/module.js +47 -1
  45. package/build/devserver/server.d.ts +1 -0
  46. package/build/devserver/server.js +32 -145
  47. package/build/devserver/ssr.d.ts +2 -0
  48. package/build/devserver/ssr.js +19 -2
  49. package/build/devserver/stream/catalog.d.ts +20 -0
  50. package/build/devserver/stream/catalog.js +54 -0
  51. package/build/devserver/stream/host.d.ts +9 -0
  52. package/build/devserver/stream/host.js +15 -0
  53. package/build/devserver/stream/index.d.ts +37 -0
  54. package/build/devserver/stream/index.js +220 -0
  55. package/build/devserver/stream/manager.d.ts +34 -0
  56. package/build/devserver/stream/manager.js +103 -0
  57. package/build/devserver/stream/router.d.ts +25 -0
  58. package/build/devserver/stream/router.js +64 -0
  59. package/build/devserver/stream/wire.d.ts +5 -0
  60. package/build/devserver/stream/wire.js +33 -0
  61. package/build/devserver/stream/ws.d.ts +18 -0
  62. package/build/devserver/stream/ws.js +46 -0
  63. package/build/devserver/wasm/surface.d.ts +1 -1
  64. package/build/devserver/wasm/surface.js +1 -1
  65. package/docs/cli.md +3 -1
  66. package/docs/getting-started.md +7 -7
  67. package/docs/tiers.md +15 -9
  68. package/examples/basic/server/routes/Guestbook.ts +38 -13
  69. package/package.json +2 -2
  70. package/src/cli/index.ts +14 -1
  71. package/src/client/index.ts +2 -0
  72. package/src/client/rpc.ts +25 -1
  73. package/src/client/stream/client.ts +107 -0
  74. package/src/compiler/config.ts +15 -7
  75. package/src/compiler/index.ts +43 -18
  76. package/src/compiler/toil-docs.generated.ts +3 -3
  77. package/src/devserver/daemon/index.ts +7 -7
  78. package/src/devserver/daemon/runtime.ts +48 -0
  79. package/src/devserver/db/catalog.ts +9 -13
  80. package/src/devserver/db/database.ts +14 -0
  81. package/src/devserver/db/derives.ts +121 -0
  82. package/src/devserver/db/index.ts +1 -0
  83. package/src/devserver/db/types.ts +6 -0
  84. package/src/devserver/http/proxy.ts +53 -39
  85. package/src/devserver/http/runtime.ts +287 -0
  86. package/src/devserver/index.ts +2 -0
  87. package/src/devserver/production-ipc.ts +63 -0
  88. package/src/devserver/production-worker.ts +83 -0
  89. package/src/devserver/production.ts +706 -0
  90. package/src/devserver/runtime/module.ts +95 -1
  91. package/src/devserver/server.ts +52 -201
  92. package/src/devserver/ssr.ts +23 -3
  93. package/src/devserver/stream/catalog.ts +106 -0
  94. package/src/devserver/stream/host.ts +42 -0
  95. package/src/devserver/stream/index.ts +308 -0
  96. package/src/devserver/stream/manager.ts +163 -0
  97. package/src/devserver/stream/router.ts +101 -0
  98. package/src/devserver/stream/wire.ts +58 -0
  99. package/src/devserver/stream/ws.ts +76 -0
  100. package/src/devserver/wasm/surface.ts +5 -7
  101. package/test/built-ssr.test.ts +98 -0
  102. package/test/daemon-build.test.ts +15 -7
  103. package/test/daemon-catalog.test.ts +17 -8
  104. package/test/devserver-database.test.ts +8 -8
  105. package/test/devserver.test.ts +20 -4
  106. package/test/example-guestbook.test.ts +8 -5
  107. package/test/fixtures/stream-echo.ts +26 -0
  108. package/test/fixtures/stream-gate.ts +24 -0
  109. package/test/fixtures/stream-trap.ts +18 -0
  110. package/test/rpc-bignum-wire.test.ts +8 -8
  111. package/test/stream-emulation.test.ts +394 -0
@@ -0,0 +1,103 @@
1
+ import fs from 'node:fs';
2
+ import { DevStreamBox } from './index.js';
3
+ export const STREAM_REJECTED = 0x0208;
4
+ export const STREAM_HOOK_TRAPPED = 0x0200;
5
+ export class StreamDevHost {
6
+ streamWasmPath;
7
+ bytes = null;
8
+ loadedMtimeMs = -1;
9
+ conns = new Map();
10
+ nextStreamId = 1n;
11
+ constructor(streamWasmPath) {
12
+ this.streamWasmPath = streamWasmPath;
13
+ }
14
+ get activeConnections() {
15
+ return this.conns.size;
16
+ }
17
+ has(connId) {
18
+ return this.conns.has(connId);
19
+ }
20
+ acceptUpgrade(connId, authority, path) {
21
+ if (this.conns.has(connId))
22
+ throw new Error(`stream connection '${connId}' is already open`);
23
+ this.refresh();
24
+ if (!this.bytes)
25
+ return { kind: 'rejected', code: STREAM_REJECTED };
26
+ let box;
27
+ try {
28
+ box = DevStreamBox.load(this.bytes);
29
+ }
30
+ catch {
31
+ return { kind: 'rejected', code: STREAM_REJECTED };
32
+ }
33
+ const streamId = this.allocStreamId();
34
+ let outcome;
35
+ try {
36
+ outcome = box.onConnect(streamId, authority, path);
37
+ }
38
+ catch {
39
+ return { kind: 'rejected', code: STREAM_HOOK_TRAPPED };
40
+ }
41
+ if (outcome.kind === 'reject')
42
+ return { kind: 'rejected', code: outcome.code };
43
+ this.conns.set(connId, { box, streamId });
44
+ return { kind: 'accepted', streamId };
45
+ }
46
+ dispatch(connId, inbound) {
47
+ const conn = this.conns.get(connId);
48
+ if (!conn)
49
+ return { kind: 'noConnection' };
50
+ try {
51
+ const out = conn.box.onMessage(conn.streamId, inbound);
52
+ if (out.kind === 'reply')
53
+ return { kind: 'reply', frames: out.frames };
54
+ return { kind: 'close', code: out.code };
55
+ }
56
+ catch {
57
+ this.conns.delete(connId);
58
+ return { kind: 'close', code: STREAM_HOOK_TRAPPED };
59
+ }
60
+ }
61
+ close(connId) {
62
+ const conn = this.conns.get(connId);
63
+ if (!conn)
64
+ return;
65
+ try {
66
+ conn.box.onClose(conn.streamId);
67
+ }
68
+ catch {
69
+ }
70
+ this.conns.delete(connId);
71
+ }
72
+ disconnect(connId) {
73
+ const conn = this.conns.get(connId);
74
+ if (!conn)
75
+ return;
76
+ try {
77
+ conn.box.onDisconnect(conn.streamId);
78
+ }
79
+ catch {
80
+ }
81
+ this.conns.delete(connId);
82
+ }
83
+ refresh() {
84
+ let mtimeMs;
85
+ try {
86
+ mtimeMs = fs.statSync(this.streamWasmPath).mtimeMs;
87
+ }
88
+ catch {
89
+ this.bytes = null;
90
+ this.loadedMtimeMs = -1;
91
+ return;
92
+ }
93
+ if (mtimeMs === this.loadedMtimeMs && this.bytes)
94
+ return;
95
+ this.bytes = fs.readFileSync(this.streamWasmPath);
96
+ this.loadedMtimeMs = mtimeMs;
97
+ }
98
+ allocStreamId() {
99
+ const id = this.nextStreamId;
100
+ this.nextStreamId = id + 1n;
101
+ return id;
102
+ }
103
+ }
@@ -0,0 +1,25 @@
1
+ import { type StreamDef } from './catalog.js';
2
+ export interface StreamWs {
3
+ send(data: Buffer, isBinary: boolean): void;
4
+ close(code: number): void;
5
+ on(event: 'message', cb: (message: Buffer, isBinary: boolean) => void): void;
6
+ on(event: 'close', cb: () => void): void;
7
+ }
8
+ export interface StreamUpgradeContext {
9
+ readonly kind: 'stream';
10
+ readonly route: string;
11
+ readonly url: string;
12
+ readonly authority: string;
13
+ }
14
+ export declare class StreamRouter {
15
+ private readonly streamWasmPath;
16
+ private catalog;
17
+ private catalogMtimeMs;
18
+ private readonly host;
19
+ private connSeq;
20
+ constructor(streamWasmPath: string);
21
+ get activeConnections(): number;
22
+ matchRoute(path: string): StreamDef | null;
23
+ onUpgrade(ws: StreamWs, ctx: StreamUpgradeContext): void;
24
+ private refreshCatalog;
25
+ }
@@ -0,0 +1,64 @@
1
+ import fs from 'node:fs';
2
+ import { matchStreamRoute, parseStreamCatalog, } from './catalog.js';
3
+ import { StreamDevHost } from './manager.js';
4
+ import { StreamWsSession } from './ws.js';
5
+ export class StreamRouter {
6
+ streamWasmPath;
7
+ catalog = new Map();
8
+ catalogMtimeMs = -1;
9
+ host;
10
+ connSeq = 0;
11
+ constructor(streamWasmPath) {
12
+ this.streamWasmPath = streamWasmPath;
13
+ this.host = new StreamDevHost(streamWasmPath);
14
+ this.refreshCatalog();
15
+ }
16
+ get activeConnections() {
17
+ return this.host.activeConnections;
18
+ }
19
+ matchRoute(path) {
20
+ this.refreshCatalog();
21
+ return matchStreamRoute(this.catalog, path);
22
+ }
23
+ onUpgrade(ws, ctx) {
24
+ const connId = `s${String(++this.connSeq)}`;
25
+ const q = ctx.url.indexOf('?');
26
+ const path = q >= 0 ? ctx.url.slice(0, q) : ctx.url;
27
+ const session = new StreamWsSession(this.host, connId, ctx.authority, path, {
28
+ send: (frame) => {
29
+ ws.send(frame, true);
30
+ },
31
+ close: (code) => {
32
+ ws.close(code);
33
+ },
34
+ });
35
+ if (!session.onOpen())
36
+ return;
37
+ ws.on('message', (message) => {
38
+ session.onMessage(message);
39
+ });
40
+ ws.on('close', () => {
41
+ session.onClose();
42
+ });
43
+ }
44
+ refreshCatalog() {
45
+ let mtimeMs;
46
+ try {
47
+ mtimeMs = fs.statSync(this.streamWasmPath).mtimeMs;
48
+ }
49
+ catch {
50
+ this.catalog = new Map();
51
+ this.catalogMtimeMs = -1;
52
+ return;
53
+ }
54
+ if (mtimeMs === this.catalogMtimeMs && this.catalog.size > 0)
55
+ return;
56
+ try {
57
+ this.catalog = parseStreamCatalog(fs.readFileSync(this.streamWasmPath));
58
+ }
59
+ catch {
60
+ this.catalog = new Map();
61
+ }
62
+ this.catalogMtimeMs = mtimeMs;
63
+ }
64
+ }
@@ -0,0 +1,5 @@
1
+ import { type Server } from '@dacely/hyper-express';
2
+ import { type ViteTarget } from '../http/proxy.js';
3
+ import { type StreamRouter } from './router.js';
4
+ export declare function streamEmulationEnabled(nodeMode: string): boolean;
5
+ export declare function wireStreams(app: Server, vite: ViteTarget, router: StreamRouter): void;
@@ -0,0 +1,33 @@
1
+ import { pipeToVite } from '../http/proxy.js';
2
+ export function streamEmulationEnabled(nodeMode) {
3
+ return nodeMode === 'regional' || nodeMode === 'continental' || nodeMode === 'all';
4
+ }
5
+ export function wireStreams(app, vite, router) {
6
+ app.upgrade('/*', (request, response) => {
7
+ const def = router.matchRoute(request.path);
8
+ if (def !== null) {
9
+ response.upgrade({
10
+ kind: 'stream',
11
+ route: def.route,
12
+ url: request.url,
13
+ authority: request.headers['host'] ?? '',
14
+ });
15
+ }
16
+ else {
17
+ response.upgrade({
18
+ kind: 'vite',
19
+ url: request.url,
20
+ protocol: request.headers['sec-websocket-protocol'] ?? '',
21
+ });
22
+ }
23
+ });
24
+ app.ws('/*', { message_type: 'Buffer', idle_timeout: 0, max_payload_length: 16 * 1024 * 1024 }, (ws) => {
25
+ const ctx = ws.context;
26
+ if (ctx.kind === 'stream') {
27
+ router.onUpgrade(ws, ws.context);
28
+ }
29
+ else {
30
+ pipeToVite(ws, vite, ws.context);
31
+ }
32
+ });
33
+ }
@@ -0,0 +1,18 @@
1
+ import type { StreamDevHost } from './manager.js';
2
+ export interface StreamWsTransport {
3
+ send(frame: Buffer): void;
4
+ close(code: number): void;
5
+ }
6
+ export declare class StreamWsSession {
7
+ private readonly host;
8
+ private readonly connId;
9
+ private readonly authority;
10
+ private readonly path;
11
+ private readonly transport;
12
+ private open;
13
+ constructor(host: StreamDevHost, connId: string, authority: string, path: string, transport: StreamWsTransport);
14
+ get isOpen(): boolean;
15
+ onOpen(): boolean;
16
+ onMessage(inbound: Buffer): void;
17
+ onClose(): void;
18
+ }
@@ -0,0 +1,46 @@
1
+ export class StreamWsSession {
2
+ host;
3
+ connId;
4
+ authority;
5
+ path;
6
+ transport;
7
+ open = false;
8
+ constructor(host, connId, authority, path, transport) {
9
+ this.host = host;
10
+ this.connId = connId;
11
+ this.authority = authority;
12
+ this.path = path;
13
+ this.transport = transport;
14
+ }
15
+ get isOpen() {
16
+ return this.open;
17
+ }
18
+ onOpen() {
19
+ const up = this.host.acceptUpgrade(this.connId, this.authority, this.path);
20
+ if (up.kind === 'rejected') {
21
+ this.transport.close(up.code);
22
+ return false;
23
+ }
24
+ this.open = true;
25
+ return true;
26
+ }
27
+ onMessage(inbound) {
28
+ if (!this.open)
29
+ return;
30
+ const r = this.host.dispatch(this.connId, inbound);
31
+ if (r.kind === 'reply') {
32
+ for (const frame of r.frames)
33
+ this.transport.send(frame);
34
+ }
35
+ else if (r.kind === 'close') {
36
+ this.open = false;
37
+ this.transport.close(r.code);
38
+ }
39
+ }
40
+ onClose() {
41
+ if (!this.open && !this.host.has(this.connId))
42
+ return;
43
+ this.open = false;
44
+ this.host.close(this.connId);
45
+ }
46
+ }
@@ -15,4 +15,4 @@ export interface Surface {
15
15
  readonly dataCoherenceHash: number;
16
16
  readonly pairCoherenceHash: number;
17
17
  }
18
- export declare function parseSurface(wasm: Buffer): Surface | 'absent' | 'invalid';
18
+ export declare function parseSurface(wasm: Buffer): Surface | 'invalid';
@@ -9,7 +9,7 @@ export function parseSurface(wasm) {
9
9
  return 'invalid';
10
10
  }
11
11
  if (sec === null)
12
- return 'absent';
12
+ return 'invalid';
13
13
  const r = new DataReader(sec);
14
14
  r.readU16();
15
15
  const targetMode = r.readU8() === 1 ? 'cold' : 'hot';
package/docs/cli.md CHANGED
@@ -8,7 +8,9 @@
8
8
  - `toiljs build`, production build. With a `toilconfig.json` it builds the server (toilscript,
9
9
  regenerating `shared/server.ts`) first, then the client → `build/client`. `--server` builds
10
10
  only the server. Every `server/` file declaring a surface (`@data`/`@rest`/...) is compiled.
11
- - `toiljs start`, self-host the built app (hyper-express) with a `/_toil` WebSocket channel.
11
+ - `toiljs start`, self-host the built app with production hyper-express/uWS static workers,
12
+ SSR/wasm dispatch, daemon support, and a `/_toil` WebSocket channel. Use `--threads <n>`
13
+ (or `server.threads`) to set the worker count; `1` disables the pool.
12
14
  - `toiljs configure`, toggle styling features on an existing project (see [styling.md](./styling.md)).
13
15
  - `toiljs doctor`, diagnose project setup (`--json` for CI). `--fix` auto-wires the typed-RPC
14
16
  setup (build scripts, tsconfig `shared` + alias, `.gitignore`, toilscript version, and the
@@ -95,13 +95,13 @@ store reached through a host binding.
95
95
 
96
96
  The `toiljs` CLI drives both halves:
97
97
 
98
- | Command | What it does |
99
- | --- | --- |
100
- | `toiljs create [name]` | Scaffold a new app (templates, styling, options). |
101
- | `toiljs dev` | Dev server with hot reload: watches `server/`, rebuilds the wasm via toilscript, regenerates `shared/server.ts`, and runs Vite for the client. Flags: `--root <dir>`, `--port <n>`, `--host`. |
102
- | `toiljs build` | Production build: server wasm first (so `shared/server.ts` is fresh), then the Vite client + static prerender. Flags: `--root <dir>`, `--server` (server only). |
103
- | `toiljs start` | Self-host a built app. Flags: `--root`, `--port`, `--host`. |
104
- | `toiljs doctor` | Diagnose setup/deps (`--json`, `--fix`). |
98
+ | Command | What it does |
99
+ | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
100
+ | `toiljs create [name]` | Scaffold a new app (templates, styling, options). |
101
+ | `toiljs dev` | Dev server with hot reload: watches `server/`, rebuilds the wasm via toilscript, regenerates `shared/server.ts`, and runs Vite for the client. Flags: `--root <dir>`, `--port <n>`, `--host`. |
102
+ | `toiljs build` | Production build: server wasm first (so `shared/server.ts` is fresh), then the Vite client + static prerender. Flags: `--root <dir>`, `--server` (server only). |
103
+ | `toiljs start` | Self-host a built app with production uWS/static workers, no Vite. Flags: `--root`, `--port`, `--host`, `--threads`. |
104
+ | `toiljs doctor` | Diagnose setup/deps (`--json`, `--fix`). |
105
105
 
106
106
  In dev, requests whose method matches a dispatchable verb go into the wasm
107
107
  first; if the guest reports "no route matched" (the `x-toil-unhandled` marker)
package/docs/tiers.md CHANGED
@@ -8,11 +8,11 @@ tier purely by adding its entry file and surface decorator; nothing else changes
8
8
 
9
9
  ## The tiers
10
10
 
11
- | Entry (`server/`) | Surface | Artifact | Tier | Lifetime / placement |
12
- | --- | --- | --- | --- | --- |
13
- | `main.ts` | `@rest` / `@service` / `@remote` | `build/server/release.wasm` | **L1** request | A fresh handler per request, anywhere on the edge. |
14
- | `main.stream.ts` | `@stream` | `build/server/release-stream.wasm` | **L2/L3** stream | One resident box per connection, pinned to a worker via QUIC connection-id steering; its state survives every event. See [Streams](./streams.md). |
15
- | `main.daemon.ts` | `@daemon` / `@scheduled` | `build/server/release-cold.wasm` | **L4** daemon | Exactly one leader-elected box per domain (warm standby, at-most-once failover) firing `@scheduled` tasks. See [Daemon](./daemon.md). |
11
+ | Entry (`server/`) | Surface | Artifact | Tier | Lifetime / placement |
12
+ | ----------------- | -------------------------------- | ---------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
13
+ | `main.ts` | `@rest` / `@service` / `@remote` | `build/server/release.wasm` | **L1** request | A fresh handler per request, anywhere on the edge. |
14
+ | `main.stream.ts` | `@stream` | `build/server/release-stream.wasm` | **L2/L3** stream | One resident box per connection, pinned to a worker via QUIC connection-id steering; its state survives every event. See [Streams](./streams.md). |
15
+ | `main.daemon.ts` | `@daemon` / `@scheduled` | `build/server/release-cold.wasm` | **L4** daemon | Exactly one leader-elected box per domain (warm standby, at-most-once failover) firing `@scheduled` tasks. See [Daemon](./daemon.md). |
16
16
 
17
17
  The three tiers differ in how long a box lives and how many of it exist:
18
18
 
@@ -79,7 +79,7 @@ build/server/release-cold.wasm # L4 daemon (exports: daemon_start, sche
79
79
 
80
80
  ## Single-artifact default
81
81
 
82
- A project with no `@stream` and no `@daemon` surface keeps the legacy
82
+ A project with no `@stream` and no `@daemon` surface keeps the default
83
83
  single-artifact build - just `build/server/release.wasm`. The stream and daemon
84
84
  tiers are opt-in: add `main.stream.ts` (and a `@stream` class) to get
85
85
  `release-stream.wasm`, add `main.daemon.ts` (and a `@daemon` class) to get
@@ -102,9 +102,15 @@ tiers are opt-in: add `main.stream.ts` (and a `@stream` class) to get
102
102
  class Echo {
103
103
  private count: i32 = 0;
104
104
 
105
- @connect onConnect(): void { this.count = 0; }
106
- @message onMessage(): void { this.count = this.count + 1; }
107
- @close onClose(): void { /* box torn down after this hook */ }
105
+ @connect onConnect(): void {
106
+ this.count = 0;
107
+ }
108
+ @message onMessage(): void {
109
+ this.count = this.count + 1;
110
+ }
111
+ @close onClose(): void {
112
+ /* box torn down after this hook */
113
+ }
108
114
  }
109
115
  ```
110
116
 
@@ -13,6 +13,12 @@ import { NewMessage } from '../models/NewMessage';
13
13
  *
14
14
  * await Server.REST.guestbook.sign({ body: new NewMessage('Ada', 'hi!') });
15
15
  * const book = await Server.REST.guestbook.list(); // { total, entries: [...] }
16
+ *
17
+ * Reading the newest entries is a SCAN (`events.latest`), which is barred in a
18
+ * request handler (a `@get` runs as a Query, a `@post` as an Action) because a
19
+ * scan can fan out across unbounded rows. So a `@derive` does the scan off the
20
+ * request path and `publish`es a materialized `GuestbookView`; the GET then
21
+ * serves that view with a single non-scan `view.get`.
16
22
  */
17
23
 
18
24
  // The guestbook is one global stream; a single fixed key addresses it.
@@ -28,33 +34,52 @@ class GuestKey {
28
34
  class GuestbookDb {
29
35
  @collection static entries: Events<GuestKey, GuestEntry>;
30
36
  @collection static totals: Counter<GuestKey>;
31
- }
37
+ // The materialized snapshot the GET serves: total + newest entries.
38
+ @collection static book: View<GuestKey, GuestbookView>;
32
39
 
33
- /** The current total + the 10 newest entries. */
34
- function snapshot(): GuestbookView {
35
- const key = new GuestKey('main');
36
- const view = new GuestbookView();
37
- view.total = GuestbookDb.totals.get(key);
38
- view.entries = GuestbookDb.entries.latest(key, 10);
39
- return view;
40
+ /**
41
+ * Recompute the materialized view from the source of truth (the event log +
42
+ * the counter). The runtime runs this under FunctionKind=Derive after a
43
+ * signature is appended (and rebuilds it when a box first loads), so the
44
+ * scan (`events.latest`) and the `view.publish` - both barred in a request
45
+ * handler - are allowed here. This is also where `GuestEntry`'s `@migrate`
46
+ * fires: `events.latest` decodes each stored event at ITS schema version, so
47
+ * an old pre-`at` entry is migrated as the view is rebuilt.
48
+ */
49
+ @derive
50
+ recompute(): void {
51
+ const key = new GuestKey('main');
52
+ const view = new GuestbookView();
53
+ view.total = GuestbookDb.totals.get(key);
54
+ view.entries = GuestbookDb.entries.latest(key, 10);
55
+ GuestbookDb.book.publish(key, view);
56
+ }
40
57
  }
41
58
 
42
59
  @rest('guestbook')
43
60
  class Guestbook {
44
- /** `GET /guestbook` - the running total + the most recent signatures. */
61
+ /** `GET /guestbook` - the running total + the most recent signatures, served
62
+ * from the materialized view (a non-scan `view.get`). */
45
63
  @get('/')
46
64
  public list(): GuestbookView {
47
- return snapshot();
65
+ const key = new GuestKey('main');
66
+ const view = GuestbookDb.book.get(key);
67
+ if (view == null) return new GuestbookView();
68
+ return view;
48
69
  }
49
70
 
50
- /** `POST /guestbook` - append a signature (PERSISTED) and return the
51
- * updated book. Sign twice and the total keeps climbing across requests. */
71
+ /** `POST /guestbook` - append a signature (PERSISTED) and acknowledge with
72
+ * the new running total. The entries list is served by the GET above from
73
+ * the view the `@derive` republishes right after this action. Sign twice
74
+ * and the total keeps climbing across requests. */
52
75
  @post('/')
53
76
  public sign(input: NewMessage): GuestbookView {
54
77
  const key = new GuestKey('main');
55
78
  const at = <u64>(Date.now() / 1000);
56
79
  GuestbookDb.entries.append(key, new GuestEntry(input.author, input.message, at));
57
80
  GuestbookDb.totals.add(key, 1);
58
- return snapshot();
81
+ const view = new GuestbookView();
82
+ view.total = GuestbookDb.totals.get(key); // Counter get: non-scan, action-legal
83
+ return view;
59
84
  }
60
85
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "toiljs",
3
3
  "type": "module",
4
- "version": "0.0.66",
4
+ "version": "0.0.68",
5
5
  "author": "Dacely",
6
6
  "description": "The modern React framework: a file-based React frontend and a ToilScript-compiled WebAssembly backend.",
7
7
  "repository": {
@@ -134,7 +134,7 @@
134
134
  "nodemailer": "^9.0.1",
135
135
  "picocolors": "^1.1.1",
136
136
  "sharp": "^0.35.2",
137
- "toilscript": "^0.1.41",
137
+ "toilscript": "^0.1.43",
138
138
  "typescript-eslint": "^8.62.0",
139
139
  "vite": "^8.1.0",
140
140
  "vite-imagetools": "^10.0.1",
package/src/cli/index.ts CHANGED
@@ -19,6 +19,7 @@ interface Flags {
19
19
  root?: string;
20
20
  port?: number;
21
21
  host?: string;
22
+ threads?: number;
22
23
  name?: string;
23
24
  template?: Template;
24
25
  preprocessor?: Preprocessor;
@@ -51,6 +52,12 @@ function parseArgs(argv: string[]): Flags {
51
52
  case '--host':
52
53
  flags.host = argv[++i];
53
54
  break;
55
+ case '--threads':
56
+ case '--workers': {
57
+ const threads = Number(argv[++i]);
58
+ if (!Number.isNaN(threads)) flags.threads = threads;
59
+ break;
60
+ }
54
61
  case '--template':
55
62
  case '-t': {
56
63
  const t = argv[++i];
@@ -138,6 +145,7 @@ function printHelp(): void {
138
145
  bold('Options'),
139
146
  cmd('--root <dir>', 'project root (default: current directory)'),
140
147
  cmd('--port <n>', 'dev server port'),
148
+ cmd('--threads <n>', 'start: production HTTP worker count'),
141
149
  cmd('-t, --template', 'create: app | minimal'),
142
150
  cmd('--style <name>', 'create/configure: css | sass | less | stylus'),
143
151
  cmd('--tailwind', 'create/configure: enable Tailwind (--no-tailwind to remove)'),
@@ -226,7 +234,12 @@ async function main(): Promise<void> {
226
234
  case 'start': {
227
235
  banner();
228
236
  process.stdout.write(dim(' self-hosting the built app…') + '\n\n');
229
- const server = await start({ root: flags.root, port: flags.port, host: flags.host });
237
+ const server = await start({
238
+ root: flags.root,
239
+ port: flags.port,
240
+ host: flags.host,
241
+ threads: flags.threads,
242
+ });
230
243
  process.stdout.write(
231
244
  accent(' ➜ ') +
232
245
  bold(`http://localhost:${String(server.port)}`) +
@@ -79,6 +79,8 @@ export { matchRoute } from './routing/match.js';
79
79
  export type { RouteParams } from './routing/match.js';
80
80
  export { connectChannel, useChannel, resolveChannelUrl } from './channel/channel.js';
81
81
  export type { Channel, ChannelOptions, ChannelHook, ChannelData } from './channel/channel.js';
82
+ export { makeStreamClient } from './stream/client.js';
83
+ export type { StreamChannel, StreamConnectable, StreamClient } from './stream/client.js';
82
84
  export { useHead, useTitle, Head, mergeHead } from './head/head.js';
83
85
  export type { HeadSpec, MetaTag, LinkTag, ResolvedHead } from './head/head.js';
84
86
  export { resolveMetadata, useMetadata, Metadata } from './head/metadata.js';
package/src/client/rpc.ts CHANGED
@@ -24,7 +24,26 @@ function restMissingStub(path: string): unknown {
24
24
  return new Proxy(call, {
25
25
  get(_target, prop) {
26
26
  if (typeof prop === 'symbol' || prop === 'then') return undefined;
27
- return restMissingStub(`${path}.${String(prop)}`);
27
+ return restMissingStub(`${path}.${prop}`);
28
+ },
29
+ apply() {
30
+ return call();
31
+ },
32
+ });
33
+ }
34
+
35
+ /** A recursive proxy that throws on call, used when the stream client hasn't loaded. */
36
+ function streamMissingStub(path: string): unknown {
37
+ const call = (): never => {
38
+ throw new Error(
39
+ `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'.`,
41
+ );
42
+ };
43
+ return new Proxy(call, {
44
+ get(_target, prop) {
45
+ if (typeof prop === 'symbol' || prop === 'then') return undefined;
46
+ return streamMissingStub(`${path}.${prop}`);
28
47
  },
29
48
  apply() {
30
49
  return call();
@@ -49,6 +68,11 @@ function rpcStub(path: string): unknown {
49
68
  const rest = (globalThis as { __toilRest?: unknown }).__toilRest;
50
69
  return rest !== undefined ? rest : restMissingStub('Server.REST');
51
70
  }
71
+ // `Server.Stream` surfaces the generated stream client attached by shared/server.ts.
72
+ if (path === 'Server' && prop === 'Stream') {
73
+ const stream = (globalThis as { __toilStream?: unknown }).__toilStream;
74
+ return stream !== undefined ? stream : streamMissingStub('Server.Stream');
75
+ }
52
76
  return rpcStub(`${path}.${prop}`);
53
77
  },
54
78
  apply() {