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.
- package/CHANGELOG.md +10 -0
- package/build/cli/.tsbuildinfo +1 -1
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/index.d.ts +1 -1
- package/build/client/index.js +1 -1
- package/build/client/routing/hooks.d.ts +1 -0
- package/build/client/routing/hooks.js +7 -1
- package/build/client/ssr/markers.js +1 -1
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/index.d.ts +4 -1
- package/build/compiler/index.js +46 -18
- package/build/compiler/template-build.d.ts +3 -2
- package/build/compiler/template-build.js +16 -5
- package/examples/basic/server/services/Stats.ts +2 -3
- package/examples/basic/server/services/remotes.ts +2 -2
- package/package.json +1 -1
- package/src/client/index.ts +1 -0
- package/src/client/routing/hooks.ts +16 -3
- package/src/client/ssr/markers.tsx +4 -1
- package/src/compiler/index.ts +104 -53
- package/src/compiler/template-build.ts +38 -7
- package/test/daemon-build.test.ts +31 -12
- package/test/ssr-hydration.test.tsx +20 -5
- package/test/ssr-template.test.tsx +5 -3
- package/examples/basic/server/streams/Echo.ts +0 -49
package/build/compiler/index.js
CHANGED
|
@@ -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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
155
|
+
stream.push(rel);
|
|
156
|
+
request.push(rel);
|
|
139
157
|
continue;
|
|
140
158
|
}
|
|
141
|
-
const isCold = COLD_DECORATOR.test(src);
|
|
142
|
-
const
|
|
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 (
|
|
146
|
-
|
|
147
|
-
|
|
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,
|
|
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
|
|
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
|
-
...(
|
|
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
|
-
|
|
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
|
+
}*/
|
package/package.json
CHANGED
package/src/client/index.ts
CHANGED
|
@@ -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
|
|
106
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/compiler/index.ts
CHANGED
|
@@ -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)
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
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
|
-
//
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
201
|
-
readonly
|
|
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 (
|
|
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
|
|
207
|
-
const
|
|
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
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
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
|
|
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
|
|
265
|
+
// unreadable: keep it in EVERY pass (let toilscript surface the error).
|
|
228
266
|
cold.push(rel);
|
|
229
|
-
|
|
267
|
+
stream.push(rel);
|
|
268
|
+
request.push(rel);
|
|
230
269
|
continue;
|
|
231
270
|
}
|
|
232
|
-
const isCold = COLD_DECORATOR.test(src);
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
if (
|
|
238
|
-
|
|
239
|
-
|
|
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,
|
|
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
|
|
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<
|
|
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
|
|