toiljs 0.0.62 → 0.0.64

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 (37) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/build/cli/.tsbuildinfo +1 -1
  3. package/build/client/.tsbuildinfo +1 -1
  4. package/build/client/index.d.ts +1 -1
  5. package/build/client/index.js +1 -1
  6. package/build/client/routing/hooks.d.ts +1 -0
  7. package/build/client/routing/hooks.js +7 -1
  8. package/build/client/ssr/markers.js +1 -1
  9. package/build/compiler/.tsbuildinfo +1 -1
  10. package/build/compiler/index.d.ts +4 -1
  11. package/build/compiler/index.js +47 -18
  12. package/build/compiler/template-build.d.ts +3 -2
  13. package/build/compiler/template-build.js +16 -5
  14. package/build/compiler/toil-docs.generated.js +4 -1
  15. package/build/devserver/.tsbuildinfo +1 -1
  16. package/build/devserver/runtime/module.d.ts +3 -0
  17. package/build/devserver/runtime/module.js +5 -7
  18. package/docs/daemon.md +123 -0
  19. package/docs/index.md +5 -1
  20. package/docs/streams.md +147 -0
  21. package/docs/tiers.md +127 -0
  22. package/examples/basic/server/services/Stats.ts +2 -3
  23. package/examples/basic/server/services/remotes.ts +2 -2
  24. package/package.json +2 -2
  25. package/scripts/gen-toil-docs.mjs +3 -0
  26. package/src/client/index.ts +1 -0
  27. package/src/client/routing/hooks.ts +16 -3
  28. package/src/client/ssr/markers.tsx +4 -1
  29. package/src/compiler/index.ts +109 -53
  30. package/src/compiler/template-build.ts +38 -7
  31. package/src/compiler/toil-docs.generated.ts +4 -1
  32. package/src/devserver/runtime/module.ts +12 -14
  33. package/test/daemon-build.test.ts +31 -12
  34. package/test/devserver-database.test.ts +26 -0
  35. package/test/ssr-hydration.test.tsx +20 -5
  36. package/test/ssr-template.test.tsx +5 -3
  37. package/examples/basic/server/streams/Echo.ts +0 -49
@@ -0,0 +1,147 @@
1
+ # Streams
2
+
3
+ A `@stream` declares a long-lived, stateful protocol handler over WebTransport -
4
+ the **L2/L3** (regional / continental) stream tier of the Toil edge. Unlike a
5
+ `@rest` route, which is a fresh handler per request, a `@stream` is a **resident
6
+ WebAssembly box per connection**: it is created when the connection opens, lives
7
+ for the whole connection, and is torn down on close. State stored on its fields
8
+ **persists across events**, because it is the same box every time.
9
+
10
+ ```ts
11
+ @stream('echo')
12
+ class Echo {
13
+ private count: i32 = 0;
14
+
15
+ @connect
16
+ onConnect(): void {
17
+ this.count = 0;
18
+ }
19
+
20
+ @message
21
+ onMessage(): void {
22
+ this.count = this.count + 1;
23
+ }
24
+
25
+ @close
26
+ onClose(): void {}
27
+ }
28
+ ```
29
+
30
+ ## Declaring a stream
31
+
32
+ `@stream(name)` marks a class as a stream handler and mounts it at the given
33
+ name/route. The class becomes a resident box; its fields are the connection's
34
+ state.
35
+
36
+ ```ts
37
+ @stream('echo') // mounted at /echo
38
+ class Echo { /* ... */ }
39
+ ```
40
+
41
+ A stream lives on the **L2/L3 stream tier** and its default scope is **Regional
42
+ (L2)**. See [Tiers](./tiers.md) for the full tier model.
43
+
44
+ ## Lifecycle hooks
45
+
46
+ A stream method is a lifecycle hook, chosen by its decorator. All hooks are
47
+ optional - declare only the ones you need; a missing hook is a no-op.
48
+
49
+ | Decorator | Fires when |
50
+ | --- | --- |
51
+ | `@connect` | the connection opens (the box has just been created). |
52
+ | `@message` | an inbound frame arrives. |
53
+ | `@close` | the connection closes gracefully (the box is torn down after this hook). |
54
+ | `@disconnect` | the transport is lost abruptly. |
55
+ | `@channel` | an opt-in distributed channel delivers a message (advanced; see below). |
56
+
57
+ The `Echo` example above shows why state survives: `count` is set to `0` in
58
+ `@connect`, incremented on every `@message`, and the increments **accumulate**.
59
+ That is only possible because the same resident box handles every event for the
60
+ connection. A `@rest` handler's fields would reset on each request, since a
61
+ fresh handler is constructed per request.
62
+
63
+ `@channel` is an opt-in **distributed** channel (advanced) - a way for boxes to
64
+ exchange messages beyond a single connection. It is mentioned here for
65
+ completeness; most streams use only the four connection-lifecycle hooks.
66
+
67
+ ## Placement
68
+
69
+ A `@stream` is distributed across the eligible L2/L3 stream nodes and pinned to
70
+ **ONE worker** for the connection's lifetime via QUIC connection-id steering. The
71
+ connection always lands on the same worker, so the box - and the state on its
72
+ fields - survives every event. You do not manage placement; the edge steers each
73
+ connection to its resident box automatically.
74
+
75
+ ## The entry: `main.stream.ts`
76
+
77
+ The stream surface has its own entry, `server/main.stream.ts`, distinct from the
78
+ request entry (`server/main.ts`). It re-exports the WASM runtime exports and
79
+ imports the `@stream` classes, which pulls their compiler-generated
80
+ `stream_dispatch` export into the artifact.
81
+
82
+ ```ts
83
+ import { revertOnError } from 'toiljs/server/runtime/abort/abort';
84
+
85
+ import './streams/Echo';
86
+
87
+ // Re-export the WASM entry points the host binds, exactly like main.ts.
88
+ export * from 'toiljs/server/runtime/exports';
89
+ export function abort(message: string, fileName: string, line: u32, column: u32): void {
90
+ revertOnError(message, fileName, line, column);
91
+ }
92
+ ```
93
+
94
+ This entry compiles into its **own artifact**, `build/server/release-stream.wasm`
95
+ - the resident stream box - separate from the request build,
96
+ `build/server/release.wasm`. Add a stream as you grow by importing it here:
97
+
98
+ ```ts
99
+ import './streams/Echo';
100
+ ```
101
+
102
+ ## Build
103
+
104
+ `toiljs build` produces `release-stream.wasm` automatically when the project
105
+ declares a `@stream` surface. The single build runs one toilscript pass per tier,
106
+ handing each pass only the entries that belong to it, so `release.wasm` never
107
+ contains `stream_dispatch` and the stream artifact never contains the request
108
+ `handle`. Plain `@data` and helper modules are shared into every artifact.
109
+
110
+ ```sh
111
+ $ toiljs build
112
+ $ ls build/server/*.wasm
113
+ build/server/release.wasm # L1 request (exports: handle)
114
+ build/server/release-stream.wasm # L2/L3 stream (exports: stream_dispatch)
115
+ build/server/release-cold.wasm # L4 daemon (exports: daemon_start, scheduled_tick)
116
+ ```
117
+
118
+ See [Tiers](./tiers.md) for how the three artifacts map to the deployment tiers.
119
+
120
+ ## What runs today
121
+
122
+ The stream lifecycle hooks (`@connect` / `@message` / `@close` / `@disconnect`)
123
+ run **today**, and this proves a resident box keeps state across events - that is
124
+ exactly what the `Echo` example demonstrates by counting frames.
125
+
126
+ Reading the inbound frame **bytes** and replying is the **next increment**, not
127
+ yet available. That bridge is the `StreamPacket` / `StreamOutbound` API and the
128
+ typed `Server.STREAM.echo.connect()` client. The intended shape, once it lands:
129
+
130
+ ```ts
131
+ @message reply(packet: StreamPacket): StreamOutbound {
132
+ return StreamOutbound.reply(packet.bytes()); // echo the bytes back
133
+ }
134
+ ```
135
+
136
+ ```ts
137
+ const stream = await Server.STREAM.echo.connect();
138
+ stream.send(new TextEncoder().encode('hello'));
139
+ ```
140
+
141
+ Until then, the hooks run on the connection lifecycle and you observe state
142
+ through fields, as `Echo` does. See the comments in
143
+ `examples/streams/server/streams/Echo.ts` for the authoritative note.
144
+
145
+ ---
146
+
147
+ See also: [Tiers](./tiers.md), [Daemon](./daemon.md), [Routing](./routing.md).
package/docs/tiers.md ADDED
@@ -0,0 +1,127 @@
1
+ # Deployment tiers
2
+
3
+ A Toil app's server runs across several deployment **tiers** from one source
4
+ tree. Each tier has a different lifetime and placement on the edge, and compiles
5
+ into its own WebAssembly artifact. You write one project; `toiljs build` decides
6
+ which entries belong to which tier and emits one `.wasm` per tier. You opt into a
7
+ tier purely by adding its entry file and surface decorator; nothing else changes.
8
+
9
+ ## The tiers
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). |
16
+
17
+ The three tiers differ in how long a box lives and how many of it exist:
18
+
19
+ - **L1 request** is stateless. A `@rest` handler's fields reset each request,
20
+ because a fresh box serves each one, anywhere on the edge.
21
+ - **L2/L3 stream** is resident per connection. A `@stream` box is created when a
22
+ connection opens, lives for its lifetime, and is torn down on close, so its
23
+ fields persist across every event.
24
+ - **L4 daemon** is a single elected leader per domain - the global coordination
25
+ tier - running recurring background work on a cadence.
26
+
27
+ ## How the build works
28
+
29
+ `toiljs build` runs one toilscript pass per tier, handing each pass only the
30
+ entries that belong to it. Tier membership is decided by the surface decorator or
31
+ by the entry naming convention:
32
+
33
+ - a runtime-export entry that is **not** `*.stream.ts` or `*.daemon.ts` is the
34
+ **request** entry (`main.ts`), which compiles `@rest` / `@service` / `@remote`;
35
+ - `*.stream.ts` is the **stream** entry, which compiles `@stream`;
36
+ - `*.daemon.ts` is the **daemon** entry, which compiles `@daemon` / `@scheduled`.
37
+
38
+ Plain `@data` and helper modules carry no tier of their own, so they are shared
39
+ into every artifact. Routing each entry to exactly one tier is what keeps
40
+ `release.wasm` free of `stream_dispatch` and keeps the daemon artifact free of
41
+ the request `handle`.
42
+
43
+ Each entry is a thin file that imports its tier's modules and re-exports the
44
+ right runtime hooks. The stream and request entries re-export the request runtime
45
+ exports; the daemon entry does not, because a cold artifact exposes
46
+ `daemon_start` / `scheduled_tick`, not `handle`:
47
+
48
+ ```ts
49
+ // server/main.stream.ts - the L2/L3 stream entry
50
+ import { revertOnError } from 'toiljs/server/runtime/abort/abort';
51
+ import './streams/Echo';
52
+
53
+ export * from 'toiljs/server/runtime/exports';
54
+ export function abort(message: string, fileName: string, line: u32, column: u32): void {
55
+ revertOnError(message, fileName, line, column);
56
+ }
57
+ ```
58
+
59
+ ```ts
60
+ // server/main.daemon.ts - the L4 daemon entry
61
+ import { revertOnError } from 'toiljs/server/runtime/abort/abort';
62
+ import './daemon/Jobs';
63
+
64
+ // NOTE: no `export *` from the request runtime - a cold artifact exposes
65
+ // daemon_start/scheduled_tick, not the request `handle`.
66
+ export function abort(message: string, fileName: string, line: u32, column: u32): void {
67
+ revertOnError(message, fileName, line, column);
68
+ }
69
+ ```
70
+
71
+ A single build produces the artifacts side by side:
72
+
73
+ ```sh
74
+ $ ls build/server/*.wasm
75
+ build/server/release.wasm # L1 request (exports: handle)
76
+ build/server/release-stream.wasm # L2/L3 stream (exports: stream_dispatch)
77
+ build/server/release-cold.wasm # L4 daemon (exports: daemon_start, scheduled_tick)
78
+ ```
79
+
80
+ ## Single-artifact default
81
+
82
+ A project with no `@stream` and no `@daemon` surface keeps the legacy
83
+ single-artifact build - just `build/server/release.wasm`. The stream and daemon
84
+ tiers are opt-in: add `main.stream.ts` (and a `@stream` class) to get
85
+ `release-stream.wasm`, add `main.daemon.ts` (and a `@daemon` class) to get
86
+ `release-cold.wasm`. Existing request-only apps build exactly as before.
87
+
88
+ ## When to use each tier
89
+
90
+ - **L1 request** for request/response and RPC: `@rest` controllers, `@service` /
91
+ `@remote` callable surface. The default tier; most code lives here.
92
+ - **L2/L3 stream** for stateful, long-lived connections where per-connection
93
+ state must survive across events - the resident box is pinned to one worker for
94
+ the connection's lifetime.
95
+ - **L4 daemon** for scheduled and coordination work: rollups, cleanup, polling an
96
+ upstream, anything that should run exactly once per domain on a cadence rather
97
+ than per request.
98
+
99
+ ```ts
100
+ // server/streams/Echo.ts - L2/L3: the box is resident, so `count` persists.
101
+ @stream('echo')
102
+ class Echo {
103
+ private count: i32 = 0;
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 */ }
108
+ }
109
+ ```
110
+
111
+ ```ts
112
+ // server/daemon/Jobs.ts - L4: one leader per domain runs this hourly.
113
+ @daemon
114
+ class Jobs {
115
+ @scheduled('1h')
116
+ hourly(): void {
117
+ // Recurring background work: rollups, cleanup, polling an upstream, ...
118
+ }
119
+ }
120
+ ```
121
+
122
+ ## See also
123
+
124
+ - [Streams](./streams.md) - the `@stream` surface and the L2/L3 tier.
125
+ - [Daemon](./daemon.md) - the `@daemon` surface and the L4 tier.
126
+ - [Routing](./routing.md) - `@rest` controllers on the L1 request tier.
127
+ - [RPC](./rpc.md) - `@service` / `@remote` and the generated client.
@@ -1,11 +1,10 @@
1
1
  import { store } from '../core/store';
2
2
 
3
3
  /** Typed RPC service (transport still a TODO): reached as `Server.stats.playerCount()` on the client. */
4
- @service
4
+ /*@service
5
5
  class Stats {
6
- /** Number of seeded players (the RPC transport is a TODO, so this throws on the client for now). */
7
6
  @remote
8
7
  public playerCount(): i32 {
9
8
  return store.size;
10
9
  }
11
- }
10
+ }*/
@@ -1,7 +1,7 @@
1
1
  /** Free `@remote` functions: callable as `Server.<name>()` on the client. */
2
2
 
3
3
  /** `Server.ping(n)` on the client. */
4
- @remote
4
+ /*@remote
5
5
  function ping(n: i32): i32 {
6
6
  return n + 1;
7
- }
7
+ }*/
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "toiljs",
3
3
  "type": "module",
4
- "version": "0.0.62",
4
+ "version": "0.0.64",
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.39",
137
+ "toilscript": "^0.1.41",
138
138
  "typescript-eslint": "^8.62.0",
139
139
  "vite": "^8.1.0",
140
140
  "vite-imagetools": "^10.0.1",
@@ -42,6 +42,9 @@ const ORDER = [
42
42
  'server.md',
43
43
  'ssr.md',
44
44
  'rpc.md',
45
+ 'tiers.md',
46
+ 'streams.md',
47
+ 'daemon.md',
45
48
  'data.md',
46
49
  'caching.md',
47
50
  'ratelimit.md',
@@ -40,6 +40,7 @@ export {
40
40
  useSearchParams,
41
41
  useRouter,
42
42
  useNavigationPending,
43
+ __setSsrLocation,
43
44
  } from './routing/hooks.js';
44
45
  export type { RouterInstance } from './routing/hooks.js';
45
46
  export {
@@ -101,12 +101,25 @@ function useLocationSubscription(): void {
101
101
  );
102
102
  }
103
103
 
104
+ /** Build-only override for the SSR pathname, set by the template extractor per route via
105
+ * {@link __setSsrLocation}. Lets location-dependent markup (a `NavLink`'s active class /
106
+ * `aria-current`) render as the route's own URL so it matches what the client computes on
107
+ * hydration, instead of the `/` default. Ignored in the browser (the live URL wins). */
108
+ let ssrLocationOverride: string | null = null;
109
+
110
+ /** Build-only: set the pathname the extractor is currently rendering (or `null` to clear).
111
+ * No effect in the browser. Exported through `toiljs/client` for the compiler. */
112
+ export function __setSsrLocation(path: string | null): void {
113
+ ssrLocationOverride = path;
114
+ }
115
+
104
116
  /** Subscribes to and returns the current `location.pathname`. SSR-safe: during a
105
- * server render (build-time template extraction / edge SSR) there is no `window`,
106
- * so it reports `/`; the client recomputes on hydration. */
117
+ * server render there is no `window`, so it reports the extractor's override (the route
118
+ * being rendered) or `/`; the client recomputes on hydration. */
107
119
  export function useLocation(): string {
108
120
  useLocationSubscription();
109
- return typeof window === 'undefined' ? '/' : window.location.pathname;
121
+ if (typeof window === 'undefined') return ssrLocationOverride ?? '/';
122
+ return window.location.pathname;
110
123
  }
111
124
 
112
125
  /** Alias of {@link useLocation}: the current `location.pathname`. */
@@ -145,7 +145,10 @@ export function Repeat<T>(props: RepeatProps<T>): ReactNode {
145
145
  return createElement(
146
146
  Fragment,
147
147
  null,
148
- props.each.map((item, i) => props.children(item, i)),
148
+ // Each row is wrapped in a keyed Fragment so React has a stable list key (the
149
+ // row markup itself need not carry one). Index keys are fine here: an SSR'd
150
+ // region hydrates 1:1 against the host's pre-stamped rows and does not reorder.
151
+ props.each.map((item, i) => createElement(Fragment, { key: i }, props.children(item, i))),
149
152
  );
150
153
  }
151
154
 
@@ -137,30 +137,42 @@ export async function buildServer(root: string): Promise<void> {
137
137
  // (optimization, features, runtime) still come from the toilconfig's `release` target.
138
138
  const files = serverEntryFiles(root);
139
139
 
140
- // A project that declares a `@daemon` (cold surface) compiles the ONE source tree into TWO
141
- // artifacts via two toilscript passes (one per --targetMode); a project with only the legacy
142
- // request surface keeps the single-artifact path (byte-identical to before). The cold pass
143
- // runs FIRST (cheap, no client surface); the hot pass runs LAST because it (re)writes
144
- // shared/server.ts via --rpcModule, which the downstream client build imports.
140
+ // A project that declares a `@daemon` (L4 cold surface) and/or a `@stream` (L2/L3 stream
141
+ // surface) compiles the ONE source tree into SEPARATE artifacts, one per deployment tier, via
142
+ // one toilscript pass each; a project with only the legacy request surface keeps the
143
+ // single-artifact path (byte-identical to before). The three tiers:
144
+ // - REQUEST (L1) `server/main.ts` + `@rest`/`@service`/`@remote` -> `release.wasm`
145
+ // - STREAM (L2/L3) `server/main.stream.ts` + `@stream` -> `release-stream.wasm`
146
+ // - DAEMON (L4) `@daemon`/`@scheduled` -> `release-cold.wasm`
147
+ // toilscript's gating matrix HARD-ERRORS a class compiled under the wrong --targetMode, so each
148
+ // pass is handed only the files eligible for its tier (`@data`/`@database`/plain helpers are
149
+ // SHARED into every pass). The request pass runs LAST because it (re)writes shared/server.ts via
150
+ // --rpcModule, which the downstream client build imports.
145
151
  const split = splitSurfaceFiles(root, files);
146
- if (split.hasDaemon) {
152
+ if (split.hasDaemon || split.hasStream) {
147
153
  const artifacts = serverArtifacts(root);
148
- // toilscript's gating matrix HARD-ERRORS a `@daemon`/`@scheduled` class compiled under
149
- // `--targetMode hot` (and a `@rest`/`@stream`/`@service`/`@remote` class under cold). So
150
- // each pass is handed only the files eligible for that mode: the cold pass drops hot-only
151
- // files, the hot pass drops daemon-only files. `@data`/`@database`/plain files are shared.
152
- await runToilscriptPass(root, binJs, split.cold, {
153
- mode: 'cold',
154
- outFile: artifacts.cold,
155
- withRpc: false,
156
- });
157
- // The hot pass writes the legacy `outFile` (= hotFile alias, AN-1) so the request path
158
- // and the dev server's `serverWasmFile` are unchanged; the request box loads it as today.
159
- // A daemon-only project (no request/stream surface) has no hot files; skip the hot pass so
160
- // toilscript is not handed an empty entry set. The request path then stays idle (no
161
- // `handle` export), which is correct for a pure background worker.
162
- if (split.hot.length > 0)
163
- await runToilscriptPass(root, binJs, split.hot, {
154
+ // DAEMON (cold) pass: --targetMode cold, no client RPC surface.
155
+ if (split.hasDaemon)
156
+ await runToilscriptPass(root, binJs, split.cold, {
157
+ mode: 'cold',
158
+ outFile: artifacts.cold,
159
+ withRpc: false,
160
+ });
161
+ // STREAM pass: --targetMode hot into its OWN `release-stream.wasm`, no client RPC surface
162
+ // (a resident stream box exposes `stream_dispatch`, not the request client surface). Driven
163
+ // by `server/main.stream.ts` + the `@stream` classes; the request box never loads it.
164
+ if (split.hasStream && split.stream.length > 0)
165
+ await runToilscriptPass(root, binJs, split.stream, {
166
+ mode: 'hot',
167
+ outFile: artifacts.stream,
168
+ withRpc: false,
169
+ });
170
+ // REQUEST pass: the L1 artifact (= the legacy `outFile`, AN-1), WITH the client RPC surface.
171
+ // A pure daemon/stream project (no request files) skips it so toilscript is not handed an
172
+ // empty entry set; the request path then stays idle (no `handle` export), correct for a
173
+ // background-only worker.
174
+ if (split.request.length > 0)
175
+ await runToilscriptPass(root, binJs, split.request, {
164
176
  mode: 'hot',
165
177
  outFile: serverWasmFile(root),
166
178
  withRpc: true,
@@ -168,7 +180,7 @@ export async function buildServer(root: string): Promise<void> {
168
180
  return;
169
181
  }
170
182
 
171
- // Legacy single-artifact path (no daemon surface): exactly today's invocation.
183
+ // Legacy single-artifact path (no daemon/stream surface): exactly today's invocation.
172
184
  await runToilscriptPass(root, binJs, files, { mode: null, outFile: null, withRpc: true });
173
185
  }
174
186
 
@@ -191,54 +203,85 @@ function resolveToilscriptBin(root: string): string {
191
203
  }
192
204
  }
193
205
 
194
- /** Files classified per target mode for the two-pass build. */
206
+ /** Files classified per deployment TIER for the multi-artifact build. */
195
207
  interface SurfaceSplit {
196
- /** Whether any file declares a `@daemon` (so a cold pass is needed at all). */
208
+ /** Whether any file declares a `@daemon` (so a cold/daemon pass is needed at all). */
197
209
  readonly hasDaemon: boolean;
198
- /** Files eligible for the COLD pass (everything except hot-only request files). */
210
+ /** Whether any file declares a `@stream` (or is a `*.stream.ts` entry), so a stream pass is needed. */
211
+ readonly hasStream: boolean;
212
+ /** Files for the DAEMON (cold) pass: `@daemon`/`@scheduled` surfaces + shared helpers. */
199
213
  readonly cold: string[];
200
- /** Files eligible for the HOT pass (everything except daemon-only cold files). */
201
- readonly hot: string[];
214
+ /** Files for the STREAM pass: `@stream` surfaces + the `*.stream.ts` entry + shared helpers. */
215
+ readonly stream: string[];
216
+ /** Files for the REQUEST pass: `@rest`/`@service`/`@remote` surfaces + the request entry + shared helpers. */
217
+ readonly request: string[];
202
218
  }
203
219
 
204
- /** A `@daemon`/`@scheduled` decorator at line start (a cold-only surface). */
220
+ /** A `@daemon`/`@scheduled` decorator at line start (the L4 cold/daemon surface). */
205
221
  const COLD_DECORATOR = /^[ \t]*@(daemon|scheduled)\b/m;
206
- /** A request/stream-surface decorator at line start (a hot-only surface). */
207
- const HOT_DECORATOR = /^[ \t]*@(rest|route|stream|service|remote)\b/m;
222
+ /** A `@stream` decorator at line start (the L2/L3 stream surface). */
223
+ const STREAM_DECORATOR = /^[ \t]*@stream\b/m;
224
+ /** A request-surface decorator at line start (`@rest`/`@route`/`@service`/`@remote`, the L1 tier). */
225
+ const REQUEST_DECORATOR = /^[ \t]*@(rest|route|service|remote)\b/m;
226
+ /** A server ENTRY re-exports the runtime WASM entry points; this marks `main.ts` / `main.stream.ts`
227
+ * (vs a plain `@data`/helper), so each entry is routed to exactly ONE tier and two entries never
228
+ * collide on a duplicate `export *` in the same pass. */
229
+ const RUNTIME_ENTRY = /from\s+['"]toiljs\/server\/runtime\/exports['"]/;
230
+
231
+ /** True for a STREAM-tier entry by the `*.stream.ts` naming convention (e.g. `main.stream.ts`). */
232
+ function isStreamEntryFile(rel: string): boolean {
233
+ return rel.endsWith('.stream.ts');
234
+ }
235
+
236
+ /** True for a COLD/daemon-tier entry by the `*.daemon.ts` naming convention (e.g. `main.daemon.ts`). */
237
+ function isDaemonEntryFile(rel: string): boolean {
238
+ return rel.endsWith('.daemon.ts');
239
+ }
208
240
 
209
241
  /**
210
- * Classify each server source file by the surface decorators it declares, so each toilscript pass
211
- * is handed only the files valid for its `--targetMode` (toilscript HARD-ERRORS a cold class in
212
- * the hot artifact and vice versa). A file with a cold-only surface (`@daemon`/`@scheduled` and no
213
- * hot decorator) is dropped from the hot pass; a file with a hot-only surface is dropped from the
214
- * cold pass. Shared files (`@data`/`@database`/plain helpers, or a file mixing both surfaces) stay
215
- * in both passes, matching toilscript's class-level gating which admits `@data`/`@database`
216
- * everywhere.
242
+ * Classify each server source file by its deployment TIER, so each toilscript pass is handed only
243
+ * the files valid for its `--targetMode` (toilscript HARD-ERRORS a class compiled under the wrong
244
+ * mode). Three tiers:
245
+ * - COLD/daemon: a file declaring `@daemon`/`@scheduled` -> `release-cold.wasm`.
246
+ * - STREAM (L2/L3): a file declaring `@stream`, OR a `*.stream.ts` entry (`main.stream.ts`) ->
247
+ * `release-stream.wasm`.
248
+ * - REQUEST (L1): a file declaring `@rest`/`@service`/`@remote`, OR a non-`*.stream.ts` runtime
249
+ * ENTRY (`main.ts`) -> `release.wasm`.
250
+ * A file with NONE of these (a plain `@data`/`@database`/helper) is SHARED into every pass, matching
251
+ * toilscript's class-level gating. Routing each entry to exactly one tier keeps `release.wasm` free
252
+ * of `stream_dispatch` and stops two entries re-exporting the runtime in the same pass.
217
253
  */
218
254
  export function splitSurfaceFiles(root: string, files: string[]): SurfaceSplit {
219
255
  let hasDaemon = false;
256
+ let hasStream = false;
220
257
  const cold: string[] = [];
221
- const hot: string[] = [];
258
+ const stream: string[] = [];
259
+ const request: string[] = [];
222
260
  for (const rel of files) {
223
261
  let src = '';
224
262
  try {
225
263
  src = fs.readFileSync(path.join(root, rel), 'utf8');
226
264
  } catch {
227
- // unreadable: keep it in both passes (let toilscript surface the error).
265
+ // unreadable: keep it in EVERY pass (let toilscript surface the error).
228
266
  cold.push(rel);
229
- hot.push(rel);
267
+ stream.push(rel);
268
+ request.push(rel);
230
269
  continue;
231
270
  }
232
- const isCold = COLD_DECORATOR.test(src);
233
- const isHot = HOT_DECORATOR.test(src);
234
- if (isCold) hasDaemon ||= /^[ \t]*@daemon\b/m.test(src);
235
- // Drop a file from the hot pass only when it is cold-only (cold surface, no hot surface);
236
- // a mixed file stays in both (toilscript gates per class, not per file).
237
- if (!(isCold && !isHot)) hot.push(rel);
238
- // Drop a file from the cold pass only when it is hot-only.
239
- if (!(isHot && !isCold)) cold.push(rel);
271
+ const isCold = COLD_DECORATOR.test(src) || isDaemonEntryFile(rel);
272
+ const isStream = STREAM_DECORATOR.test(src) || isStreamEntryFile(rel);
273
+ const isRequest =
274
+ REQUEST_DECORATOR.test(src) ||
275
+ (RUNTIME_ENTRY.test(src) && !isStreamEntryFile(rel) && !isDaemonEntryFile(rel));
276
+ if (isCold) hasDaemon ||= /^[ \t]*@daemon\b/m.test(src) || isDaemonEntryFile(rel);
277
+ if (isStream) hasStream = true;
278
+ // A file with no tier-specific surface is a SHARED helper, compiled into every pass.
279
+ const shared = !isCold && !isStream && !isRequest;
280
+ if (isCold || shared) cold.push(rel);
281
+ if (isStream || shared) stream.push(rel);
282
+ if (isRequest || shared) request.push(rel);
240
283
  }
241
- return { hasDaemon, cold, hot };
284
+ return { hasDaemon, hasStream, cold, stream, request };
242
285
  }
243
286
 
244
287
  interface PassOptions {
@@ -264,6 +307,11 @@ function runToilscriptPass(
264
307
  if (opts.mode !== null) args.push('--targetMode', opts.mode);
265
308
  if (opts.outFile !== null) args.push('--outFile', opts.outFile);
266
309
  if (opts.withRpc) args.push('--rpcModule', 'shared/server.ts');
310
+ // Each pass is handed its OWN entry subset (the per-tier `files`); suppress the toilconfig
311
+ // `entries` so toilscript does not ALSO append every project entry to every pass (which would
312
+ // pull, e.g., a `@stream` class into the cold daemon pass). serverEntryFiles already folds
313
+ // config.entries into `files`, so no entry is lost by ignoring them here.
314
+ args.push('--noConfigEntries');
267
315
  args.push('--disableWarning', '235');
268
316
 
269
317
  return new Promise<void>((resolve, reject) => {
@@ -417,32 +465,40 @@ function serverWasmFile(root: string): string {
417
465
  * present in the toilconfig `release` target; otherwise derived from `outFile` by inserting the
418
466
  * mode before the extension (`release.wasm` -> `release-hot.wasm` / `release-cold.wasm`). */
419
467
  export interface ServerArtifacts {
420
- /** Absolute path to the hot (request/stream) artifact. */
468
+ /** Absolute path to the hot (request) artifact. */
421
469
  readonly hot: string;
422
470
  /** Absolute path to the cold (daemon) artifact. */
423
471
  readonly cold: string;
472
+ /** Absolute path to the stream (L2/L3 `@stream`) artifact (`release-stream.wasm`). */
473
+ readonly stream: string;
424
474
  }
425
475
  export function serverArtifacts(root: string): ServerArtifacts {
426
476
  let out = 'build/server/release.wasm';
427
477
  let hot: string | undefined;
428
478
  let cold: string | undefined;
479
+ let stream: string | undefined;
429
480
  try {
430
481
  const cfg = JSON.parse(fs.readFileSync(path.join(root, 'toilconfig.json'), 'utf8')) as {
431
- targets?: Record<string, { outFile?: string; hotFile?: string; coldFile?: string }>;
482
+ targets?: Record<
483
+ string,
484
+ { outFile?: string; hotFile?: string; coldFile?: string; streamFile?: string }
485
+ >;
432
486
  };
433
487
  out = cfg.targets?.release?.outFile ?? out;
434
488
  hot = cfg.targets?.release?.hotFile;
435
489
  cold = cfg.targets?.release?.coldFile;
490
+ stream = cfg.targets?.release?.streamFile;
436
491
  } catch {
437
492
  // No readable toilconfig: caller already gated on its existence; keep defaults.
438
493
  }
439
- const ins = (mode: 'hot' | 'cold'): string => {
494
+ const ins = (mode: 'hot' | 'cold' | 'stream'): string => {
440
495
  const ext = path.extname(out);
441
496
  return out.slice(0, ext ? -ext.length : undefined) + '-' + mode + (ext || '.wasm');
442
497
  };
443
498
  return {
444
499
  hot: path.resolve(root, hot ?? ins('hot')),
445
500
  cold: path.resolve(root, cold ?? ins('cold')),
501
+ stream: path.resolve(root, stream ?? ins('stream')),
446
502
  };
447
503
  }
448
504