toiljs 0.0.60 → 0.0.62
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/.github/workflows/ci.yml +31 -0
- package/CHANGELOG.md +17 -0
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/index.js +2 -2
- 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/mount.js +11 -26
- package/build/client/ssr/markers.d.ts +1 -0
- package/build/client/ssr/markers.js +9 -2
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/config.d.ts +21 -0
- package/build/compiler/config.js +35 -0
- package/build/compiler/docs.d.ts +2 -1
- package/build/compiler/docs.js +33 -304
- package/build/compiler/index.d.ts +13 -0
- package/build/compiler/index.js +113 -21
- package/build/compiler/template-build.d.ts +23 -3
- package/build/compiler/template-build.js +120 -30
- package/build/compiler/toil-docs.generated.d.ts +1 -0
- package/build/compiler/toil-docs.generated.js +20 -0
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/daemon/catalog.d.ts +26 -0
- package/build/devserver/daemon/catalog.js +48 -0
- package/build/devserver/daemon/cron.d.ts +4 -0
- package/build/devserver/daemon/cron.js +50 -0
- package/build/devserver/daemon/host.d.ts +37 -0
- package/build/devserver/daemon/host.js +94 -0
- package/build/devserver/daemon/index.d.ts +34 -0
- package/build/devserver/daemon/index.js +241 -0
- package/build/devserver/db/catalog.d.ts +2 -1
- package/build/devserver/db/catalog.js +44 -44
- package/build/devserver/db/database.d.ts +27 -11
- package/build/devserver/db/database.js +539 -169
- package/build/devserver/db/index.d.ts +1 -1
- package/build/devserver/db/index.js +1 -1
- package/build/devserver/db/routeKinds.d.ts +8 -0
- package/build/devserver/db/routeKinds.js +139 -0
- package/build/devserver/db/types.d.ts +64 -1
- package/build/devserver/db/types.js +33 -1
- package/build/devserver/index.d.ts +10 -0
- package/build/devserver/index.js +7 -0
- package/build/devserver/mstore/store.d.ts +18 -0
- package/build/devserver/mstore/store.js +82 -0
- package/build/devserver/runtime/host.d.ts +6 -0
- package/build/devserver/runtime/host.js +45 -1
- package/build/devserver/runtime/module.d.ts +1 -0
- package/build/devserver/runtime/module.js +27 -1
- package/build/devserver/server.d.ts +6 -0
- package/build/devserver/server.js +59 -0
- package/build/devserver/ssr.d.ts +25 -0
- package/build/devserver/ssr.js +114 -0
- package/build/devserver/wasm/sections.d.ts +2 -0
- package/build/devserver/wasm/sections.js +42 -0
- package/build/devserver/wasm/surface.d.ts +18 -0
- package/build/devserver/wasm/surface.js +41 -0
- package/docs/README.md +4 -4
- package/docs/auth-todo.md +6 -6
- package/docs/caching.md +5 -5
- package/docs/cli.md +15 -0
- package/docs/client.md +40 -0
- package/docs/crypto.md +4 -4
- package/docs/data.md +6 -6
- package/docs/email.md +28 -28
- package/docs/environment.md +10 -10
- package/docs/index.md +26 -0
- package/docs/ratelimit.md +10 -10
- package/docs/routing.md +2 -2
- package/docs/server.md +61 -0
- package/docs/ssr.md +561 -113
- package/docs/styling.md +22 -0
- package/docs/time.md +1 -1
- package/eslint.config.js +10 -1
- package/examples/basic/client/components/Header.tsx +3 -0
- package/examples/basic/client/routes/features/actions.tsx +0 -2
- package/examples/basic/client/routes/hello.tsx +89 -19
- package/examples/basic/client/styles/main.css +48 -0
- package/examples/basic/server/SsrHelloRender.ts +97 -0
- package/examples/basic/server/main.ts +5 -0
- package/examples/basic/server/streams/Echo.ts +49 -0
- package/package.json +12 -10
- package/scripts/gen-toil-docs.mjs +96 -0
- package/src/cli/create.ts +2 -2
- package/src/client/index.ts +1 -1
- package/src/client/routing/mount.tsx +19 -31
- package/src/client/ssr/markers.tsx +33 -4
- package/src/compiler/config.ts +88 -2
- package/src/compiler/docs.ts +47 -308
- package/src/compiler/index.ts +236 -32
- package/src/compiler/ssr-codegen.ts +1 -1
- package/src/compiler/template-build.ts +271 -53
- package/src/compiler/toil-docs.generated.ts +26 -0
- package/src/devserver/daemon/catalog.ts +120 -0
- package/src/devserver/daemon/cron.ts +87 -0
- package/src/devserver/daemon/host.ts +224 -0
- package/src/devserver/daemon/index.ts +349 -0
- package/src/devserver/db/catalog.ts +61 -53
- package/src/devserver/db/database.ts +613 -149
- package/src/devserver/db/index.ts +1 -1
- package/src/devserver/db/routeKinds.ts +147 -0
- package/src/devserver/db/types.ts +65 -2
- package/src/devserver/index.ts +12 -0
- package/src/devserver/mstore/store.ts +121 -0
- package/src/devserver/runtime/host.ts +92 -1
- package/src/devserver/runtime/module.ts +35 -1
- package/src/devserver/server.ts +101 -0
- package/src/devserver/ssr.ts +166 -0
- package/src/devserver/wasm/sections.ts +59 -0
- package/src/devserver/wasm/surface.ts +88 -0
- package/test/daemon-build.test.ts +198 -0
- package/test/daemon-catalog.test.ts +265 -0
- package/test/daemon-emulation.test.ts +216 -0
- package/test/devserver-database.test.ts +396 -5
- package/test/email-preview.test.ts +6 -1
- package/test/fixtures/daemon-app.ts +56 -0
- package/test/global-setup.ts +17 -0
- package/test/ssr-hydration.test.tsx +107 -0
- package/test/ssr-render.test.ts +96 -27
- package/test/ssr-template.test.tsx +47 -2
- package/vitest.config.ts +3 -0
package/src/compiler/index.ts
CHANGED
|
@@ -15,15 +15,23 @@ import { loadConfig, type ResolvedToilConfig } from './config.js';
|
|
|
15
15
|
import { renderEmails } from './emails.js';
|
|
16
16
|
import { generate, TOIL_SERVER_ENV_DTS } from './generate.js';
|
|
17
17
|
import { prerenderStaticParams } from './ssg.js';
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
type DevSsrTemplate,
|
|
20
|
+
extractDevSsrTemplates,
|
|
21
|
+
extractServerSlots,
|
|
22
|
+
extractTemplates,
|
|
23
|
+
} from './template-build.js';
|
|
19
24
|
import { createViteConfig } from './vite.js';
|
|
20
25
|
|
|
21
26
|
/**
|
|
22
|
-
* A
|
|
23
|
-
*
|
|
24
|
-
*
|
|
27
|
+
* A surface declaration - a file with one defines client and/or server surface, so it must be
|
|
28
|
+
* handed to toilscript even when it is not a `toilconfig.json` entry. Matches the request/RPC
|
|
29
|
+
* surface (`@data`/`@rest`/`@service`/`@remote`) and the streams/daemon surface
|
|
30
|
+
* (`@stream`/`@daemon`/`@scheduled`); without the latter, a file whose ONLY decorator is `@daemon`
|
|
31
|
+
* or `@scheduled` would silently vanish from the cold artifact. Anchored to line-start (after
|
|
32
|
+
* indentation) so a mention in a comment (e.g. `// the @rest ...`) does not count.
|
|
25
33
|
*/
|
|
26
|
-
const SURFACE_DECORATOR = /^[ \t]*@(data|rest|service|remote)\b/m;
|
|
34
|
+
export const SURFACE_DECORATOR = /^[ \t]*@(data|rest|service|remote|stream|daemon|scheduled)\b/m;
|
|
27
35
|
|
|
28
36
|
/** The toilconfig `entries` (relative paths), or `null` when there is no readable toilconfig. */
|
|
29
37
|
function toilconfigEntries(root: string): string[] | null {
|
|
@@ -107,7 +115,7 @@ function serverEntryFiles(root: string): string[] {
|
|
|
107
115
|
* toilconfig entries) so dropped-in `@data`/`@rest` files are picked up. Runs the locally
|
|
108
116
|
* installed `toilscript`, resolved + invoked via Node (no `.bin` shim / PATH assumptions).
|
|
109
117
|
*/
|
|
110
|
-
async function buildServer(root: string): Promise<void> {
|
|
118
|
+
export async function buildServer(root: string): Promise<void> {
|
|
111
119
|
if (!fs.existsSync(path.join(root, 'toilconfig.json'))) return;
|
|
112
120
|
|
|
113
121
|
// Regenerate the editor-only server-globals d.ts each build (the same way
|
|
@@ -123,8 +131,50 @@ async function buildServer(root: string): Promise<void> {
|
|
|
123
131
|
}
|
|
124
132
|
}
|
|
125
133
|
|
|
134
|
+
const binJs = resolveToilscriptBin(root);
|
|
135
|
+
|
|
136
|
+
// Explicit entries (every server file) override the toilconfig entries; the target options
|
|
137
|
+
// (optimization, features, runtime) still come from the toilconfig's `release` target.
|
|
138
|
+
const files = serverEntryFiles(root);
|
|
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.
|
|
145
|
+
const split = splitSurfaceFiles(root, files);
|
|
146
|
+
if (split.hasDaemon) {
|
|
147
|
+
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, {
|
|
164
|
+
mode: 'hot',
|
|
165
|
+
outFile: serverWasmFile(root),
|
|
166
|
+
withRpc: true,
|
|
167
|
+
});
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Legacy single-artifact path (no daemon surface): exactly today's invocation.
|
|
172
|
+
await runToilscriptPass(root, binJs, files, { mode: null, outFile: null, withRpc: true });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Resolve the locally installed `toilscript` bin via Node (no `.bin` shim / PATH assumptions). */
|
|
176
|
+
function resolveToilscriptBin(root: string): string {
|
|
126
177
|
const require = createRequire(path.join(root, 'package.json'));
|
|
127
|
-
let binJs: string;
|
|
128
178
|
try {
|
|
129
179
|
const pkgPath = require.resolve('toilscript/package.json');
|
|
130
180
|
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) as {
|
|
@@ -132,38 +182,101 @@ async function buildServer(root: string): Promise<void> {
|
|
|
132
182
|
};
|
|
133
183
|
const binRel = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.toilscript;
|
|
134
184
|
if (!binRel) throw new Error('toilscript declares no bin');
|
|
135
|
-
|
|
185
|
+
return path.join(path.dirname(pkgPath), binRel);
|
|
136
186
|
} catch {
|
|
137
187
|
throw new Error(
|
|
138
188
|
"toiljs: this project has a server target (toilconfig.json) but 'toilscript' is not " +
|
|
139
189
|
'installed. Run `npm i -D toilscript`, or remove toilconfig.json for a client-only build.',
|
|
140
190
|
);
|
|
141
191
|
}
|
|
192
|
+
}
|
|
142
193
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
194
|
+
/** Files classified per target mode for the two-pass build. */
|
|
195
|
+
interface SurfaceSplit {
|
|
196
|
+
/** Whether any file declares a `@daemon` (so a cold pass is needed at all). */
|
|
197
|
+
readonly hasDaemon: boolean;
|
|
198
|
+
/** Files eligible for the COLD pass (everything except hot-only request files). */
|
|
199
|
+
readonly cold: string[];
|
|
200
|
+
/** Files eligible for the HOT pass (everything except daemon-only cold files). */
|
|
201
|
+
readonly hot: string[];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** A `@daemon`/`@scheduled` decorator at line start (a cold-only surface). */
|
|
205
|
+
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;
|
|
208
|
+
|
|
209
|
+
/**
|
|
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.
|
|
217
|
+
*/
|
|
218
|
+
export function splitSurfaceFiles(root: string, files: string[]): SurfaceSplit {
|
|
219
|
+
let hasDaemon = false;
|
|
220
|
+
const cold: string[] = [];
|
|
221
|
+
const hot: string[] = [];
|
|
222
|
+
for (const rel of files) {
|
|
223
|
+
let src = '';
|
|
224
|
+
try {
|
|
225
|
+
src = fs.readFileSync(path.join(root, rel), 'utf8');
|
|
226
|
+
} catch {
|
|
227
|
+
// unreadable: keep it in both passes (let toilscript surface the error).
|
|
228
|
+
cold.push(rel);
|
|
229
|
+
hot.push(rel);
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
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);
|
|
240
|
+
}
|
|
241
|
+
return { hasDaemon, cold, hot };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
interface PassOptions {
|
|
245
|
+
/** `--targetMode` value; `null` keeps the legacy single-artifact invocation (no flag). */
|
|
246
|
+
readonly mode: 'hot' | 'cold' | null;
|
|
247
|
+
/** Explicit `--outFile` for a two-pass build; `null` uses the toilconfig default. */
|
|
248
|
+
readonly outFile: string | null;
|
|
249
|
+
/** Only the hot/legacy pass carries `--rpcModule` (the cold artifact has no client surface). */
|
|
250
|
+
readonly withRpc: boolean;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Run one toilscript pass. The toilscript CLI flag is `--targetMode` (camelCase). */
|
|
254
|
+
function runToilscriptPass(
|
|
255
|
+
root: string,
|
|
256
|
+
binJs: string,
|
|
257
|
+
files: string[],
|
|
258
|
+
opts: PassOptions,
|
|
259
|
+
): Promise<void> {
|
|
260
|
+
// Suppress AS235 ("only variables/functions/enums become wasm exports"): a `@data`/`@rest`
|
|
261
|
+
// class is intentionally `export class` (so other server files import it), but never a wasm
|
|
262
|
+
// export — the warning is pure noise here.
|
|
263
|
+
const args = [binJs, ...files, '--target', 'release'];
|
|
264
|
+
if (opts.mode !== null) args.push('--targetMode', opts.mode);
|
|
265
|
+
if (opts.outFile !== null) args.push('--outFile', opts.outFile);
|
|
266
|
+
if (opts.withRpc) args.push('--rpcModule', 'shared/server.ts');
|
|
267
|
+
args.push('--disableWarning', '235');
|
|
268
|
+
|
|
269
|
+
return new Promise<void>((resolve, reject) => {
|
|
161
270
|
const child = spawn(process.execPath, args, { cwd: root, stdio: 'inherit' });
|
|
162
271
|
child.on('error', reject);
|
|
163
272
|
child.on('close', (code) =>
|
|
164
273
|
code === 0
|
|
165
274
|
? resolve()
|
|
166
|
-
: reject(
|
|
275
|
+
: reject(
|
|
276
|
+
new Error(
|
|
277
|
+
`toilscript ${opts.mode ?? 'release'} build failed (exit ${String(code)})`,
|
|
278
|
+
),
|
|
279
|
+
),
|
|
167
280
|
);
|
|
168
281
|
});
|
|
169
282
|
}
|
|
@@ -285,7 +398,8 @@ function installDevShutdown(close: () => Promise<void> | void): void {
|
|
|
285
398
|
for (const sig of ['SIGINT', 'SIGTERM'] as const) process.once(sig, shutdown);
|
|
286
399
|
}
|
|
287
400
|
|
|
288
|
-
/** The server wasm artifact path from the toilconfig `release` target (toilscript's output).
|
|
401
|
+
/** The server wasm artifact path from the toilconfig `release` target (toilscript's output).
|
|
402
|
+
* This is the LEGACY single-artifact path (= the hot artifact under the two-pass build). */
|
|
289
403
|
function serverWasmFile(root: string): string {
|
|
290
404
|
let outFile = 'build/server/release.wasm';
|
|
291
405
|
try {
|
|
@@ -299,6 +413,39 @@ function serverWasmFile(root: string): string {
|
|
|
299
413
|
return path.resolve(root, outFile);
|
|
300
414
|
}
|
|
301
415
|
|
|
416
|
+
/** The hot + cold artifact paths for the two-pass build. `hotFile`/`coldFile` are honored when
|
|
417
|
+
* present in the toilconfig `release` target; otherwise derived from `outFile` by inserting the
|
|
418
|
+
* mode before the extension (`release.wasm` -> `release-hot.wasm` / `release-cold.wasm`). */
|
|
419
|
+
export interface ServerArtifacts {
|
|
420
|
+
/** Absolute path to the hot (request/stream) artifact. */
|
|
421
|
+
readonly hot: string;
|
|
422
|
+
/** Absolute path to the cold (daemon) artifact. */
|
|
423
|
+
readonly cold: string;
|
|
424
|
+
}
|
|
425
|
+
export function serverArtifacts(root: string): ServerArtifacts {
|
|
426
|
+
let out = 'build/server/release.wasm';
|
|
427
|
+
let hot: string | undefined;
|
|
428
|
+
let cold: string | undefined;
|
|
429
|
+
try {
|
|
430
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(root, 'toilconfig.json'), 'utf8')) as {
|
|
431
|
+
targets?: Record<string, { outFile?: string; hotFile?: string; coldFile?: string }>;
|
|
432
|
+
};
|
|
433
|
+
out = cfg.targets?.release?.outFile ?? out;
|
|
434
|
+
hot = cfg.targets?.release?.hotFile;
|
|
435
|
+
cold = cfg.targets?.release?.coldFile;
|
|
436
|
+
} catch {
|
|
437
|
+
// No readable toilconfig: caller already gated on its existence; keep defaults.
|
|
438
|
+
}
|
|
439
|
+
const ins = (mode: 'hot' | 'cold'): string => {
|
|
440
|
+
const ext = path.extname(out);
|
|
441
|
+
return out.slice(0, ext ? -ext.length : undefined) + '-' + mode + (ext || '.wasm');
|
|
442
|
+
};
|
|
443
|
+
return {
|
|
444
|
+
hot: path.resolve(root, hot ?? ins('hot')),
|
|
445
|
+
cold: path.resolve(root, cold ?? ins('cold')),
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
302
449
|
/** An OS-assigned free loopback port (for the internal Vite server behind the dev front). */
|
|
303
450
|
async function freeLoopbackPort(): Promise<number> {
|
|
304
451
|
return new Promise((resolve, reject) => {
|
|
@@ -359,9 +506,15 @@ export async function dev(opts: ToilCommandOptions = {}): Promise<ViteDevServer>
|
|
|
359
506
|
if (hasServer) process.stdout.write(pc.dim(' building the server (toilscript)…') + '\n');
|
|
360
507
|
// Compile emails/*.tsx -> generated server module BEFORE toilscript builds it in.
|
|
361
508
|
await renderEmails(cfg);
|
|
509
|
+
// Generate the client codegen first so the SSR slots pre-pass can load the route graph, then
|
|
510
|
+
// emit the server-importable `<server>/_ssr/<name>.slots.ts` BEFORE the server build so its
|
|
511
|
+
// `render` can import them. Dev reuses the prior build's shell (or the template) for the HASH;
|
|
512
|
+
// `dispatchRender` checks coherence against the same `.slots`, so a hash drift surfaces as the
|
|
513
|
+
// documented fail-safe 500 until the next full `build`. A no-op without an `ssr = true` route.
|
|
514
|
+
generate(cfg);
|
|
515
|
+
if (hasServer) await extractServerSlots(cfg);
|
|
362
516
|
await buildServer(cfg.root);
|
|
363
517
|
if (hasServer) process.stdout.write(pc.green(' ✓ ') + pc.dim('server built') + '\n');
|
|
364
|
-
generate(cfg);
|
|
365
518
|
|
|
366
519
|
if (!hasServer) {
|
|
367
520
|
const server = await createServer(await createViteConfig(cfg));
|
|
@@ -380,13 +533,44 @@ export async function dev(opts: ToilCommandOptions = {}): Promise<ViteDevServer>
|
|
|
380
533
|
const server = await createServer(viteConfig);
|
|
381
534
|
await server.listen();
|
|
382
535
|
|
|
536
|
+
// Edge SSR in dev: render each `ssr = true` route against the LIVE (Vite-
|
|
537
|
+
// transformed) dev shell into a template-with-holes, so the dev server can
|
|
538
|
+
// splice the guest `render` values into it and serve real server-rendered
|
|
539
|
+
// HTML (the prod-edge path), which then hydrates in place. Extracted once at
|
|
540
|
+
// startup; a route's MARKUP change needs a dev restart to re-extract, but its
|
|
541
|
+
// per-request VALUES are always live via `render`. Best-effort: on failure the
|
|
542
|
+
// routes simply client-render as before.
|
|
543
|
+
let ssrTemplates: DevSsrTemplate[] = [];
|
|
544
|
+
try {
|
|
545
|
+
const rawIndex = fs.readFileSync(path.join(cfg.toilDir, 'index.html'), 'utf8');
|
|
546
|
+
const devShell = await server.transformIndexHtml('/', rawIndex);
|
|
547
|
+
ssrTemplates = await extractDevSsrTemplates(cfg, devShell);
|
|
548
|
+
if (ssrTemplates.length > 0) {
|
|
549
|
+
process.stdout.write(
|
|
550
|
+
pc.green(' ✓ ') +
|
|
551
|
+
pc.dim(`edge SSR: ${String(ssrTemplates.length)} route(s) server-rendered`) +
|
|
552
|
+
'\n',
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
} catch (e) {
|
|
556
|
+
process.stdout.write(
|
|
557
|
+
pc.yellow(' ! ') + pc.dim(`SSR dev extraction skipped: ${String(e)}`) + '\n',
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
|
|
383
561
|
const { startDevServer } = await import('toiljs/devserver');
|
|
384
562
|
const front = await startDevServer({
|
|
385
563
|
root: cfg.root,
|
|
386
564
|
port: cfg.port,
|
|
387
565
|
wasmFile: serverWasmFile(cfg.root),
|
|
566
|
+
// The daemon (cold) emulator drives `release-cold.wasm` per `nodeMode`; absent for a
|
|
567
|
+
// project with no `@daemon` (the cold artifact never gets built, so the host stays idle).
|
|
568
|
+
coldWasmFile: serverArtifacts(cfg.root).cold,
|
|
569
|
+
nodeMode: cfg.nodeMode,
|
|
570
|
+
daemon: cfg.daemon,
|
|
388
571
|
vite: { host: '127.0.0.1', port: vitePort },
|
|
389
572
|
email: cfg.email ?? undefined,
|
|
573
|
+
ssrTemplates,
|
|
390
574
|
});
|
|
391
575
|
server.httpServer?.once('close', () => {
|
|
392
576
|
void front.close();
|
|
@@ -425,20 +609,40 @@ export async function build(opts: ToilCommandOptions = {}): Promise<void> {
|
|
|
425
609
|
process.stdout.write(pc.dim(' building the server (toilscript)…') + '\n');
|
|
426
610
|
// Compile emails/*.tsx -> generated server module BEFORE toilscript builds it in.
|
|
427
611
|
await renderEmails(cfg);
|
|
612
|
+
// Generate the client codegen (`.toil/globals.ts`, `.toil/index.html`, …) NOW — before the
|
|
613
|
+
// server build — so the SSR slots pre-pass below can load the route/layout module graph and
|
|
614
|
+
// render the opted-in routes.
|
|
615
|
+
generate(cfg);
|
|
616
|
+
// SSR slots PRE-PASS: emit the server-importable `<server>/_ssr/<name>.slots.ts` (the `Slot`
|
|
617
|
+
// enum + `HASH`) the guest `render` imports, so toilscript can compile it. This is what makes a
|
|
618
|
+
// CLEAN build work with zero hand-maintained slots: the modules are generated here, before the
|
|
619
|
+
// server compiles. (The `HASH` is finalized by the post-Vite `extractTemplates` below, which
|
|
620
|
+
// recompiles the server only if it rotated.) A no-op for a project with no `ssr = true` route.
|
|
621
|
+
const priorServerSlots = hasServer ? await extractServerSlots(cfg) : new Map<string, string>();
|
|
428
622
|
await buildServer(cfg.root);
|
|
429
623
|
if (opts.serverOnly) return;
|
|
430
624
|
if (hasServer)
|
|
431
625
|
process.stdout.write(
|
|
432
626
|
pc.green(' ✓ ') + pc.dim('server built; building the client (vite)…') + '\n',
|
|
433
627
|
);
|
|
434
|
-
generate(cfg);
|
|
435
628
|
await viteBuild(await createViteConfig(cfg));
|
|
436
629
|
// SSG: bake per-URL HTML + sitemap for dynamic routes that opt in via `generateStaticParams`.
|
|
437
630
|
await prerenderStaticParams(cfg);
|
|
438
631
|
// Edge SSR: render `export const ssr = true` routes to template-with-holes
|
|
439
632
|
// (`_ssr/*.tmpl|slots` + the guest `Slot` module), copied into the edge host
|
|
440
|
-
// bundle.
|
|
441
|
-
|
|
633
|
+
// bundle. This also rewrites the server-importable slots module against the REAL built shell
|
|
634
|
+
// (authoritative `HASH`). No-op when no route opts in.
|
|
635
|
+
const ssr = await extractTemplates(cfg, 'edge', priorServerSlots);
|
|
636
|
+
// If the authoritative `HASH` (or `Slot` ids) rotated since the pre-pass the server was
|
|
637
|
+
// compiled against, recompile the server ONCE so the guest bakes the deployed hash; otherwise
|
|
638
|
+
// the host rejects the response as a deploy skew. The common case (an unchanged rebuild) reuses
|
|
639
|
+
// the prior shell in the pre-pass, so the hashes already match and this is skipped.
|
|
640
|
+
if (ssr.serverSlotsChanged) {
|
|
641
|
+
process.stdout.write(
|
|
642
|
+
pc.dim(' SSR template changed; recompiling the server with the new hash…') + '\n',
|
|
643
|
+
);
|
|
644
|
+
await buildServer(cfg.root);
|
|
645
|
+
}
|
|
442
646
|
}
|
|
443
647
|
|
|
444
648
|
/**
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* ```ts
|
|
15
15
|
* import { Request } from 'toiljs/server/runtime';
|
|
16
16
|
* import { SlotValues } from 'toiljs/server/runtime/ssr/slots';
|
|
17
|
-
* import { Slot, HASH } from './u_name.slots';
|
|
17
|
+
* import { Slot, HASH } from './_ssr/u_name.slots';
|
|
18
18
|
*
|
|
19
19
|
* export function renderUName(req: Request): SlotValues {
|
|
20
20
|
* const v = new SlotValues(HASH);
|