toiljs 0.0.62 → 0.0.63

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.
@@ -90,15 +90,22 @@ export async function buildServer(root) {
90
90
  const binJs = resolveToilscriptBin(root);
91
91
  const files = serverEntryFiles(root);
92
92
  const split = splitSurfaceFiles(root, files);
93
- if (split.hasDaemon) {
93
+ if (split.hasDaemon || split.hasStream) {
94
94
  const artifacts = serverArtifacts(root);
95
- await runToilscriptPass(root, binJs, split.cold, {
96
- mode: 'cold',
97
- outFile: artifacts.cold,
98
- withRpc: false,
99
- });
100
- if (split.hot.length > 0)
101
- await runToilscriptPass(root, binJs, split.hot, {
95
+ if (split.hasDaemon)
96
+ await runToilscriptPass(root, binJs, split.cold, {
97
+ mode: 'cold',
98
+ outFile: artifacts.cold,
99
+ withRpc: false,
100
+ });
101
+ if (split.hasStream && split.stream.length > 0)
102
+ await runToilscriptPass(root, binJs, split.stream, {
103
+ mode: 'hot',
104
+ outFile: artifacts.stream,
105
+ withRpc: false,
106
+ });
107
+ if (split.request.length > 0)
108
+ await runToilscriptPass(root, binJs, split.request, {
102
109
  mode: 'hot',
103
110
  outFile: serverWasmFile(root),
104
111
  withRpc: true,
@@ -123,11 +130,21 @@ function resolveToilscriptBin(root) {
123
130
  }
124
131
  }
125
132
  const COLD_DECORATOR = /^[ \t]*@(daemon|scheduled)\b/m;
126
- const HOT_DECORATOR = /^[ \t]*@(rest|route|stream|service|remote)\b/m;
133
+ const STREAM_DECORATOR = /^[ \t]*@stream\b/m;
134
+ const REQUEST_DECORATOR = /^[ \t]*@(rest|route|service|remote)\b/m;
135
+ const RUNTIME_ENTRY = /from\s+['"]toiljs\/server\/runtime\/exports['"]/;
136
+ function isStreamEntryFile(rel) {
137
+ return rel.endsWith('.stream.ts');
138
+ }
139
+ function isDaemonEntryFile(rel) {
140
+ return rel.endsWith('.daemon.ts');
141
+ }
127
142
  export function splitSurfaceFiles(root, files) {
128
143
  let hasDaemon = false;
144
+ let hasStream = false;
129
145
  const cold = [];
130
- const hot = [];
146
+ const stream = [];
147
+ const request = [];
131
148
  for (const rel of files) {
132
149
  let src = '';
133
150
  try {
@@ -135,19 +152,27 @@ export function splitSurfaceFiles(root, files) {
135
152
  }
136
153
  catch {
137
154
  cold.push(rel);
138
- hot.push(rel);
155
+ stream.push(rel);
156
+ request.push(rel);
139
157
  continue;
140
158
  }
141
- const isCold = COLD_DECORATOR.test(src);
142
- const isHot = HOT_DECORATOR.test(src);
159
+ const isCold = COLD_DECORATOR.test(src) || isDaemonEntryFile(rel);
160
+ const isStream = STREAM_DECORATOR.test(src) || isStreamEntryFile(rel);
161
+ const isRequest = REQUEST_DECORATOR.test(src) ||
162
+ (RUNTIME_ENTRY.test(src) && !isStreamEntryFile(rel) && !isDaemonEntryFile(rel));
143
163
  if (isCold)
144
- hasDaemon ||= /^[ \t]*@daemon\b/m.test(src);
145
- if (!(isCold && !isHot))
146
- hot.push(rel);
147
- if (!(isHot && !isCold))
164
+ hasDaemon ||= /^[ \t]*@daemon\b/m.test(src) || isDaemonEntryFile(rel);
165
+ if (isStream)
166
+ hasStream = true;
167
+ const shared = !isCold && !isStream && !isRequest;
168
+ if (isCold || shared)
148
169
  cold.push(rel);
170
+ if (isStream || shared)
171
+ stream.push(rel);
172
+ if (isRequest || shared)
173
+ request.push(rel);
149
174
  }
150
- return { hasDaemon, cold, hot };
175
+ return { hasDaemon, hasStream, cold, stream, request };
151
176
  }
152
177
  function runToilscriptPass(root, binJs, files, opts) {
153
178
  const args = [binJs, ...files, '--target', 'release'];
@@ -259,11 +284,13 @@ export function serverArtifacts(root) {
259
284
  let out = 'build/server/release.wasm';
260
285
  let hot;
261
286
  let cold;
287
+ let stream;
262
288
  try {
263
289
  const cfg = JSON.parse(fs.readFileSync(path.join(root, 'toilconfig.json'), 'utf8'));
264
290
  out = cfg.targets?.release?.outFile ?? out;
265
291
  hot = cfg.targets?.release?.hotFile;
266
292
  cold = cfg.targets?.release?.coldFile;
293
+ stream = cfg.targets?.release?.streamFile;
267
294
  }
268
295
  catch {
269
296
  }
@@ -274,6 +301,7 @@ export function serverArtifacts(root) {
274
301
  return {
275
302
  hot: path.resolve(root, hot ?? ins('hot')),
276
303
  cold: path.resolve(root, cold ?? ins('cold')),
304
+ stream: path.resolve(root, stream ?? ins('stream')),
277
305
  };
278
306
  }
279
307
  async function freeLoopbackPort() {
@@ -1,4 +1,4 @@
1
- import { type ComponentType, type Context, createElement, type ReactNode } from 'react';
1
+ import { type ComponentType, type Context, createElement, type ReactNode, Suspense } from 'react';
2
2
  import { renderToString } from 'react-dom/server';
3
3
  import { type ResolvedToilConfig } from './config.js';
4
4
  export interface RouteRenderInput {
@@ -13,6 +13,7 @@ export interface RouteRenderInput {
13
13
  shell: string;
14
14
  createElement?: typeof createElement;
15
15
  renderToString?: typeof renderToString;
16
+ Suspense?: typeof Suspense;
16
17
  }
17
18
  export interface TemplateArtifacts {
18
19
  name: string;
@@ -24,7 +25,7 @@ export interface TemplateArtifacts {
24
25
  }
25
26
  export declare function assembleRouteElement(Page: ComponentType, layouts: ComponentType<{
26
27
  children?: ReactNode;
27
- }>[], loaderData: unknown, loaderContext: Context<unknown> | null, h?: typeof createElement): ReactNode;
28
+ }>[], loaderData: unknown, loaderContext: Context<unknown> | null, h?: typeof createElement, SuspenseComp?: typeof Suspense): ReactNode;
28
29
  export declare function injectIntoShell(shell: string, routeHtml: string): string;
29
30
  export declare function extractRouteTemplate(input: RouteRenderInput): TemplateArtifacts;
30
31
  export declare function writeTemplateArtifacts(ssrDir: string, art: TemplateArtifacts): void;
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import { createRequire } from 'node:module';
3
3
  import path from 'node:path';
4
- import { createElement } from 'react';
4
+ import { createElement, Suspense } from 'react';
5
5
  import { renderToString } from 'react-dom/server';
6
6
  import { createServer } from 'vite';
7
7
  import { findLayout, findSpecialChain } from './generate.js';
@@ -11,13 +11,14 @@ import { assignSlotIds, coherenceHash, encodeSlots, extractFromHtml, } from './t
11
11
  import { createViteConfig } from './vite.js';
12
12
  const SSR_MARKER = '<template id="__toil_ssr"></template>';
13
13
  const ROOT_DIV = '<div id="root"></div>';
14
- export function assembleRouteElement(Page, layouts, loaderData, loaderContext, h = createElement) {
14
+ export function assembleRouteElement(Page, layouts, loaderData, loaderContext, h = createElement, SuspenseComp = Suspense) {
15
15
  let node = h(Page);
16
16
  if (loaderContext) {
17
17
  node = h(loaderContext.Provider, { value: loaderData }, node);
18
18
  }
19
+ node = h(SuspenseComp, { fallback: null }, node);
19
20
  for (let i = layouts.length - 1; i >= 0; i--) {
20
- node = h(layouts[i], null, node);
21
+ node = h(SuspenseComp, { fallback: null }, h(layouts[i], null, node));
21
22
  }
22
23
  return node;
23
24
  }
@@ -36,7 +37,8 @@ function stripHoistedResourceTags(html) {
36
37
  export function extractRouteTemplate(input) {
37
38
  const h = input.createElement ?? createElement;
38
39
  const render = input.renderToString ?? renderToString;
39
- const element = assembleRouteElement(input.Page, input.layouts, input.loaderData, input.loaderContext, h);
40
+ const SuspenseComp = input.Suspense ?? Suspense;
41
+ const element = assembleRouteElement(input.Page, input.layouts, input.loaderData, input.loaderContext, h, SuspenseComp);
40
42
  input.setSsrBuild(true);
41
43
  let routeHtml;
42
44
  try {
@@ -139,8 +141,9 @@ async function renderSsrRoutes(cfg, shell) {
139
141
  const loaderData = typeof mod.loader === 'function'
140
142
  ? await mod.loader({ params, searchParams: new URLSearchParams() })
141
143
  : undefined;
144
+ const rootLayout = findLayout(cfg);
142
145
  const layoutFiles = [
143
- ...(findLayout(cfg) ? [findLayout(cfg)] : []),
146
+ ...(rootLayout ? [rootLayout] : []),
144
147
  ...findSpecialChain(cfg, r.file, 'layout', false),
145
148
  ];
146
149
  const layouts = [];
@@ -149,6 +152,7 @@ async function renderSsrRoutes(cfg, shell) {
149
152
  layouts.push(lm.default);
150
153
  }
151
154
  const name = routeTemplateName(r.pattern);
155
+ client.__setSsrLocation(samplePath(r.pattern));
152
156
  const art = extractRouteTemplate({
153
157
  name,
154
158
  Page: mod.default,
@@ -159,12 +163,16 @@ async function renderSsrRoutes(cfg, shell) {
159
163
  shell,
160
164
  createElement: react.createElement,
161
165
  renderToString: reactDomServer.renderToString,
166
+ Suspense: react.Suspense,
162
167
  });
163
168
  rendered.push({ pattern: r.pattern, art });
164
169
  }
165
170
  catch (err) {
166
171
  warn(`skipped ${r.pattern} (render failed: ${err instanceof Error ? err.message : String(err)}) — falls back to client rendering`);
167
172
  }
173
+ finally {
174
+ client.__setSsrLocation(null);
175
+ }
168
176
  }
169
177
  }
170
178
  finally {
@@ -219,6 +227,9 @@ function sampleParams(pattern) {
219
227
  }
220
228
  return params;
221
229
  }
230
+ function samplePath(pattern) {
231
+ return pattern.replace(/[:*]+([A-Za-z0-9_]+)/g, 'sample');
232
+ }
222
233
  export async function extractTemplates(cfg, hostName = 'edge', priorServerSlots = new Map()) {
223
234
  const shell = resolveShell(cfg, true);
224
235
  if (shell === null)
@@ -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.63",
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": {
@@ -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 {
@@ -417,32 +460,40 @@ function serverWasmFile(root: string): string {
417
460
  * present in the toilconfig `release` target; otherwise derived from `outFile` by inserting the
418
461
  * mode before the extension (`release.wasm` -> `release-hot.wasm` / `release-cold.wasm`). */
419
462
  export interface ServerArtifacts {
420
- /** Absolute path to the hot (request/stream) artifact. */
463
+ /** Absolute path to the hot (request) artifact. */
421
464
  readonly hot: string;
422
465
  /** Absolute path to the cold (daemon) artifact. */
423
466
  readonly cold: string;
467
+ /** Absolute path to the stream (L2/L3 `@stream`) artifact (`release-stream.wasm`). */
468
+ readonly stream: string;
424
469
  }
425
470
  export function serverArtifacts(root: string): ServerArtifacts {
426
471
  let out = 'build/server/release.wasm';
427
472
  let hot: string | undefined;
428
473
  let cold: string | undefined;
474
+ let stream: string | undefined;
429
475
  try {
430
476
  const cfg = JSON.parse(fs.readFileSync(path.join(root, 'toilconfig.json'), 'utf8')) as {
431
- targets?: Record<string, { outFile?: string; hotFile?: string; coldFile?: string }>;
477
+ targets?: Record<
478
+ string,
479
+ { outFile?: string; hotFile?: string; coldFile?: string; streamFile?: string }
480
+ >;
432
481
  };
433
482
  out = cfg.targets?.release?.outFile ?? out;
434
483
  hot = cfg.targets?.release?.hotFile;
435
484
  cold = cfg.targets?.release?.coldFile;
485
+ stream = cfg.targets?.release?.streamFile;
436
486
  } catch {
437
487
  // No readable toilconfig: caller already gated on its existence; keep defaults.
438
488
  }
439
- const ins = (mode: 'hot' | 'cold'): string => {
489
+ const ins = (mode: 'hot' | 'cold' | 'stream'): string => {
440
490
  const ext = path.extname(out);
441
491
  return out.slice(0, ext ? -ext.length : undefined) + '-' + mode + (ext || '.wasm');
442
492
  };
443
493
  return {
444
494
  hot: path.resolve(root, hot ?? ins('hot')),
445
495
  cold: path.resolve(root, cold ?? ins('cold')),
496
+ stream: path.resolve(root, stream ?? ins('stream')),
446
497
  };
447
498
  }
448
499