toiljs 0.0.115 → 0.0.117
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/README.md +16 -7
- package/TYPESCRIPT_LAW.md +1946 -2073
- package/build/backend/.tsbuildinfo +1 -1
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/index.js +2375 -258
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/dev/devtools.js +1 -1
- package/build/client/dev/error-overlay.js +1 -1
- package/build/client/routing/mount.js +1 -1
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/auth-emails.js +7 -3
- package/build/compiler/emails.js +1 -1
- package/build/compiler/index.d.ts +1 -0
- package/build/compiler/index.js +25 -4
- package/build/compiler/pages.js +2 -3
- package/build/compiler/prerender.d.ts +3 -8
- package/build/compiler/prerender.js +88 -127
- package/build/compiler/ssg.js +3 -4
- package/build/compiler/template-build.js +3 -4
- package/build/compiler/toil-docs.generated.js +6 -5
- package/build/compiler/vite.js +3 -6
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/db/database.js +7 -3
- package/build/devserver/db/types.js +1 -1
- package/build/devserver/production.js +4 -3
- package/build/devserver/runtime/crypto.d.ts +1 -1
- package/build/io/.tsbuildinfo +1 -1
- package/build/logger/.tsbuildinfo +1 -1
- package/build/shared/.tsbuildinfo +1 -1
- package/examples/basic/client/routes/analytics.tsx +7 -5
- package/examples/basic/server/tsconfig.json +1 -3
- package/package.json +45 -44
- package/presets/no-uint8array-tostring.js +120 -153
- package/presets/oxlint.d.ts +5 -0
- package/presets/oxlint.js +224 -0
- package/server/runtime/tsconfig.json +1 -3
- package/tsconfig.json +2 -11
- package/.github/ISSUE_TEMPLATE/bug_report.md +0 -38
- package/.github/ISSUE_TEMPLATE/bug_report.yml +0 -90
- package/.github/ISSUE_TEMPLATE/config.yml +0 -8
- package/.github/ISSUE_TEMPLATE/feature_request.md +0 -20
- package/.github/PULL_REQUEST_TEMPLATE.md +0 -43
- package/.github/changelog-config.json +0 -45
- package/.github/dependabot.yml +0 -27
- package/.github/workflows/ci.yml +0 -222
- package/.vscode/settings.json +0 -10
- package/docs/README.md +0 -107
- package/docs/auth/README.md +0 -104
- package/docs/auth/configuration.md +0 -105
- package/docs/auth/emails.md +0 -92
- package/docs/auth/extending.md +0 -202
- package/docs/auth/how-it-works.md +0 -138
- package/docs/auth/usage.md +0 -188
- package/docs/backend/README.md +0 -143
- package/docs/backend/data.md +0 -351
- package/docs/backend/rest.md +0 -402
- package/docs/backend/rpc.md +0 -226
- package/docs/background/README.md +0 -114
- package/docs/background/daemons.md +0 -276
- package/docs/background/derive.md +0 -179
- package/docs/cli/README.md +0 -379
- package/docs/concepts/ai-guide.md +0 -132
- package/docs/concepts/config.md +0 -416
- package/docs/concepts/decorators.md +0 -127
- package/docs/concepts/security.md +0 -108
- package/docs/concepts/tiers.md +0 -166
- package/docs/concepts/types.md +0 -216
- package/docs/database/README.md +0 -143
- package/docs/database/capacity.md +0 -350
- package/docs/database/counters.md +0 -174
- package/docs/database/documents.md +0 -277
- package/docs/database/events.md +0 -307
- package/docs/database/membership.md +0 -246
- package/docs/database/setup.md +0 -155
- package/docs/database/unique.md +0 -216
- package/docs/database/views.md +0 -246
- package/docs/frontend/README.md +0 -104
- package/docs/frontend/components.md +0 -286
- package/docs/frontend/data-fetching.md +0 -260
- package/docs/frontend/images.md +0 -148
- package/docs/frontend/metadata.md +0 -344
- package/docs/frontend/navigation.md +0 -296
- package/docs/frontend/rendering.md +0 -236
- package/docs/frontend/routing.md +0 -346
- package/docs/frontend/scripts.md +0 -118
- package/docs/frontend/search.md +0 -191
- package/docs/frontend/styling.md +0 -147
- package/docs/frontend/toil-global.md +0 -216
- package/docs/getting-started/README.md +0 -81
- package/docs/getting-started/create-project.md +0 -131
- package/docs/getting-started/deploy.md +0 -101
- package/docs/getting-started/first-app.md +0 -215
- package/docs/getting-started/installation.md +0 -124
- package/docs/getting-started/migrating.md +0 -125
- package/docs/getting-started/project-structure.md +0 -163
- package/docs/introduction/README.md +0 -66
- package/docs/introduction/design-principles.md +0 -81
- package/docs/introduction/distributed.md +0 -91
- package/docs/introduction/how-it-works.md +0 -121
- package/docs/introduction/hyperscale.md +0 -70
- package/docs/introduction/modern-stack.md +0 -85
- package/docs/introduction/vs-other-frameworks.md +0 -64
- package/docs/introduction/why-toil.md +0 -99
- package/docs/llms.txt +0 -95
- package/docs/realtime/README.md +0 -102
- package/docs/realtime/channels.md +0 -211
- package/docs/realtime/streams.md +0 -369
- package/docs/services/README.md +0 -60
- package/docs/services/analytics.md +0 -279
- package/docs/services/caching.md +0 -175
- package/docs/services/cookies.md +0 -235
- package/docs/services/crypto.md +0 -209
- package/docs/services/email.md +0 -289
- package/docs/services/environment.md +0 -141
- package/docs/services/ratelimit.md +0 -174
- package/docs/services/time.md +0 -85
- package/eslint.config.js +0 -57
- package/examples/basic/client/routes/docs/[...slug].tsx +0 -12
- package/presets/eslint.js +0 -88
- package/src/backend/index.ts +0 -204
- package/src/cli/configure.ts +0 -363
- package/src/cli/create.ts +0 -924
- package/src/cli/db.ts +0 -158
- package/src/cli/diagnostics.ts +0 -665
- package/src/cli/doctor.ts +0 -982
- package/src/cli/features.ts +0 -170
- package/src/cli/index.ts +0 -326
- package/src/cli/notify.ts +0 -120
- package/src/cli/proc.ts +0 -56
- package/src/cli/ui.ts +0 -169
- package/src/cli/update.ts +0 -248
- package/src/cli/updates.ts +0 -92
- package/src/cli/validate.ts +0 -31
- package/src/cli/version-check.ts +0 -125
- package/src/client/auth.ts +0 -869
- package/src/client/channel/channel.ts +0 -146
- package/src/client/components/Form.tsx +0 -65
- package/src/client/components/Image.tsx +0 -160
- package/src/client/components/Script.tsx +0 -113
- package/src/client/components/Slot.tsx +0 -21
- package/src/client/dev/devtools.tsx +0 -1089
- package/src/client/dev/error-overlay.tsx +0 -225
- package/src/client/errors.ts +0 -11
- package/src/client/head/head.ts +0 -182
- package/src/client/head/metadata.ts +0 -110
- package/src/client/index.ts +0 -130
- package/src/client/navigation/Link.tsx +0 -108
- package/src/client/navigation/NavLink.tsx +0 -86
- package/src/client/navigation/navigation.ts +0 -235
- package/src/client/navigation/prefetch.ts +0 -169
- package/src/client/navigation/scroll.ts +0 -53
- package/src/client/routing/Router.tsx +0 -216
- package/src/client/routing/action.ts +0 -122
- package/src/client/routing/error-boundary.tsx +0 -43
- package/src/client/routing/hooks.ts +0 -140
- package/src/client/routing/lazy.ts +0 -77
- package/src/client/routing/loader.ts +0 -381
- package/src/client/routing/match.ts +0 -47
- package/src/client/routing/mount.tsx +0 -90
- package/src/client/routing/params-context.ts +0 -10
- package/src/client/routing/slot-context.ts +0 -7
- package/src/client/rpc.ts +0 -97
- package/src/client/search/search.ts +0 -189
- package/src/client/search/use-page-search.ts +0 -73
- package/src/client/ssr/markers.tsx +0 -172
- package/src/client/stream/client.ts +0 -237
- package/src/client/types.ts +0 -73
- package/src/compiler/auth-emails.ts +0 -312
- package/src/compiler/config.ts +0 -369
- package/src/compiler/docs.ts +0 -127
- package/src/compiler/email-preview.ts +0 -314
- package/src/compiler/emails.ts +0 -404
- package/src/compiler/fonts.ts +0 -89
- package/src/compiler/generate.ts +0 -471
- package/src/compiler/image-blur.ts +0 -42
- package/src/compiler/image-report.ts +0 -88
- package/src/compiler/index.ts +0 -1362
- package/src/compiler/pages.ts +0 -49
- package/src/compiler/plugin.ts +0 -391
- package/src/compiler/prerender.ts +0 -263
- package/src/compiler/routes.ts +0 -170
- package/src/compiler/seo.ts +0 -415
- package/src/compiler/sri.ts +0 -144
- package/src/compiler/ssg.ts +0 -177
- package/src/compiler/ssr-codegen.ts +0 -85
- package/src/compiler/template-build.ts +0 -676
- package/src/compiler/template.ts +0 -262
- package/src/compiler/toil-docs.generated.ts +0 -77
- package/src/compiler/vite.ts +0 -245
- package/src/devserver/analytics/index.ts +0 -317
- package/src/devserver/config/dotenv.ts +0 -102
- package/src/devserver/config/env.ts +0 -30
- package/src/devserver/config/ratelimit.ts +0 -123
- package/src/devserver/daemon/catalog.ts +0 -120
- package/src/devserver/daemon/cron.ts +0 -87
- package/src/devserver/daemon/host.ts +0 -134
- package/src/devserver/daemon/index.ts +0 -369
- package/src/devserver/daemon/runtime.ts +0 -48
- package/src/devserver/db/catalog.ts +0 -109
- package/src/devserver/db/database.ts +0 -1834
- package/src/devserver/db/derives.ts +0 -121
- package/src/devserver/db/index.ts +0 -20
- package/src/devserver/db/routeKinds.ts +0 -191
- package/src/devserver/db/types.ts +0 -168
- package/src/devserver/email/caps.ts +0 -0
- package/src/devserver/email/config.ts +0 -141
- package/src/devserver/email/console.ts +0 -348
- package/src/devserver/email/index.ts +0 -155
- package/src/devserver/email/providers.ts +0 -131
- package/src/devserver/email/status.ts +0 -23
- package/src/devserver/email/validate.ts +0 -37
- package/src/devserver/email/wire.ts +0 -55
- package/src/devserver/http/cache.ts +0 -0
- package/src/devserver/http/envelope.ts +0 -157
- package/src/devserver/http/proxy.ts +0 -172
- package/src/devserver/http/runtime.ts +0 -387
- package/src/devserver/index.ts +0 -43
- package/src/devserver/production-ipc.ts +0 -63
- package/src/devserver/production-worker.ts +0 -83
- package/src/devserver/production.ts +0 -718
- package/src/devserver/runtime/crypto.ts +0 -587
- package/src/devserver/runtime/host.ts +0 -427
- package/src/devserver/runtime/module.ts +0 -442
- package/src/devserver/server.ts +0 -251
- package/src/devserver/ssr.ts +0 -208
- package/src/devserver/stream/catalog.ts +0 -106
- package/src/devserver/stream/host.ts +0 -42
- package/src/devserver/stream/index.ts +0 -301
- package/src/devserver/stream/manager.ts +0 -167
- package/src/devserver/stream/router.ts +0 -101
- package/src/devserver/stream/wire.ts +0 -58
- package/src/devserver/stream/ws.ts +0 -78
- package/src/devserver/wasm/sections.ts +0 -59
- package/src/devserver/wasm/surface.ts +0 -115
- package/src/io/FastMap.ts +0 -151
- package/src/io/FastSet.ts +0 -113
- package/src/io/codec.ts +0 -362
- package/src/io/index.ts +0 -10
- package/src/io/lengths.ts +0 -14
- package/src/io/types.ts +0 -19
- package/src/logger/index.ts +0 -22
- package/src/shared/index.ts +0 -46
- package/test/analytics-dev.test.ts +0 -113
- package/test/assembly/aspect-shim.ts +0 -33
- package/test/assembly/cookie.spec.ts +0 -303
- package/test/assembly/example.spec.ts +0 -22
- package/test/assembly/ssr.spec.ts +0 -95
- package/test/assembly.test.ts +0 -59
- package/test/auth-emails.test.ts +0 -118
- package/test/built-ssr.test.ts +0 -148
- package/test/channel.test.ts +0 -21
- package/test/config-host-port.test.ts +0 -59
- package/test/configure.test.ts +0 -94
- package/test/daemon-build.test.ts +0 -278
- package/test/daemon-catalog.test.ts +0 -349
- package/test/daemon-emulation.test.ts +0 -256
- package/test/db.test.ts +0 -0
- package/test/devserver-database.test.ts +0 -1102
- package/test/devserver-email.test.ts +0 -306
- package/test/devserver-pqauth.test.ts +0 -93
- package/test/devserver-secrets.test.ts +0 -63
- package/test/devserver.test.ts +0 -259
- package/test/doctor.test.ts +0 -298
- package/test/dom/Image.test.tsx +0 -144
- package/test/dom/Link.test.tsx +0 -67
- package/test/dom/NavLink.test.tsx +0 -37
- package/test/dom/Script.test.tsx +0 -48
- package/test/dom/action.test.tsx +0 -146
- package/test/dom/error-overlay.test.tsx +0 -44
- package/test/dom/loader.test.tsx +0 -121
- package/test/dom/navigation.test.ts +0 -59
- package/test/dom/revalidate.test.tsx +0 -38
- package/test/dom/route-head.test.tsx +0 -56
- package/test/dom/router-loading.test.tsx +0 -44
- package/test/dom/scroll.test.ts +0 -56
- package/test/dom/slot.test.tsx +0 -131
- package/test/dom/use-metadata.test.tsx +0 -58
- package/test/dom/view-transitions.test.tsx +0 -53
- package/test/dynamic-routing.test.ts +0 -132
- package/test/email-console.test.ts +0 -86
- package/test/email-preview.test.ts +0 -73
- package/test/emails.test.ts +0 -58
- package/test/errors.test.ts +0 -21
- package/test/example-guestbook.test.ts +0 -123
- package/test/features.test.ts +0 -149
- package/test/fixtures/bignum-wire/spec.ts +0 -25
- package/test/fixtures/daemon-app.ts +0 -65
- package/test/fixtures/stream-echo.ts +0 -26
- package/test/fixtures/stream-gate.ts +0 -28
- package/test/fixtures/stream-trap.ts +0 -18
- package/test/fixtures/stream-typed.ts +0 -41
- package/test/fonts.test.ts +0 -28
- package/test/global-setup.ts +0 -17
- package/test/head.test.ts +0 -42
- package/test/image-blur.test.ts +0 -50
- package/test/io.test.ts +0 -117
- package/test/metadata.test.ts +0 -40
- package/test/navlink.test.ts +0 -28
- package/test/pages.test.ts +0 -105
- package/test/placeholder.test.ts +0 -9
- package/test/pqauth-e2e.test.ts +0 -216
- package/test/pqauth-email.test.ts +0 -801
- package/test/prerender.test.ts +0 -116
- package/test/prettier-plugin.test.ts +0 -46
- package/test/routes.test.ts +0 -76
- package/test/rpc-bignum-wire.test.ts +0 -164
- package/test/rpc-dispatch.test.ts +0 -132
- package/test/rpc-kinds.test.ts +0 -18
- package/test/rpc.test.ts +0 -66
- package/test/search.test.ts +0 -114
- package/test/seo.test.ts +0 -175
- package/test/slot-layouts.test.ts +0 -69
- package/test/sri.test.ts +0 -129
- package/test/ssg.test.ts +0 -36
- package/test/ssr-hydration.test.tsx +0 -122
- package/test/ssr-render.test.ts +0 -197
- package/test/ssr-template.test.tsx +0 -482
- package/test/stream-emulation.test.ts +0 -479
- package/test/ui.test.ts +0 -40
- package/test/update.test.ts +0 -58
- package/test/validate.test.ts +0 -42
- package/test/version-check.test.ts +0 -130
- package/tests/data-parity/generated-parity.ts +0 -99
- package/tests/data-parity/parity.ts +0 -80
- package/tests/data-parity/spec.ts +0 -46
package/src/compiler/index.ts
DELETED
|
@@ -1,1362 +0,0 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
|
-
import fs from 'node:fs';
|
|
3
|
-
import { createRequire } from 'node:module';
|
|
4
|
-
import net from 'node:net';
|
|
5
|
-
import path from 'node:path';
|
|
6
|
-
import { fileURLToPath } from 'node:url';
|
|
7
|
-
|
|
8
|
-
import pc from 'picocolors';
|
|
9
|
-
import { build as viteBuild, createServer, mergeConfig, type ViteDevServer } from 'vite';
|
|
10
|
-
// The server modules pull in @dacely/hyper-express, whose uWebSockets.js native
|
|
11
|
-
// addon loads at import time. Only `dev`/`start` need them, so they are imported
|
|
12
|
-
// lazily; `create`/`build`/`doctor` must never touch the native binary.
|
|
13
|
-
import type { RunningBackend } from 'toiljs/backend';
|
|
14
|
-
|
|
15
|
-
import { loadConfig, type ResolvedToilConfig } from './config.js';
|
|
16
|
-
import { renderEmails } from './emails.js';
|
|
17
|
-
import {
|
|
18
|
-
AUTH_EMAILS_GENERATED_BASENAME,
|
|
19
|
-
AUTH_EMAILS_LIB_SUBDIR,
|
|
20
|
-
renderAuthEmails,
|
|
21
|
-
} from './auth-emails.js';
|
|
22
|
-
import { generate, TOIL_SERVER_ENV_DTS } from './generate.js';
|
|
23
|
-
import { prerenderStaticParams } from './ssg.js';
|
|
24
|
-
import {
|
|
25
|
-
type DevSsrTemplate,
|
|
26
|
-
extractDevSsrTemplates,
|
|
27
|
-
extractServerSlots,
|
|
28
|
-
extractTemplates,
|
|
29
|
-
} from './template-build.js';
|
|
30
|
-
import { createViteConfig } from './vite.js';
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* A surface declaration - a file with one defines client and/or server surface, so it must be
|
|
34
|
-
* handed to toilscript even when it is not a `toilconfig.json` entry. Matches the request/RPC
|
|
35
|
-
* surface (`@data`/`@rest`/`@service`/`@remote`) and the streams/daemon surface
|
|
36
|
-
* (`@stream`/`@daemon`/`@scheduled`); without the latter, a file whose ONLY decorator is `@daemon`
|
|
37
|
-
* or `@scheduled` would silently vanish from the cold artifact. Anchored to line-start (after
|
|
38
|
-
* indentation) so a mention in a comment (e.g. `// the @rest ...`) does not count.
|
|
39
|
-
*/
|
|
40
|
-
export const SURFACE_DECORATOR = /^[ \t]*@(data|rest|service|remote|stream|daemon|scheduled)\b/m;
|
|
41
|
-
|
|
42
|
-
/** The toilconfig `entries` (relative paths), or `null` when there is no readable toilconfig. */
|
|
43
|
-
function toilconfigEntries(root: string): string[] | null {
|
|
44
|
-
try {
|
|
45
|
-
const cfg = JSON.parse(fs.readFileSync(path.join(root, 'toilconfig.json'), 'utf8')) as {
|
|
46
|
-
entries?: unknown;
|
|
47
|
-
};
|
|
48
|
-
return Array.isArray(cfg.entries)
|
|
49
|
-
? cfg.entries.filter((e): e is string => typeof e === 'string')
|
|
50
|
-
: [];
|
|
51
|
-
} catch {
|
|
52
|
-
return null;
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** The directories that hold server source (the toilconfig entries' dirs, or `server/`). */
|
|
57
|
-
function serverDirs(root: string): string[] {
|
|
58
|
-
const entries = toilconfigEntries(root);
|
|
59
|
-
if (entries === null) return [];
|
|
60
|
-
const dirs = new Set<string>();
|
|
61
|
-
for (const e of entries) dirs.add(path.dirname(path.resolve(root, e)));
|
|
62
|
-
if (dirs.size === 0) dirs.add(path.join(root, 'server'));
|
|
63
|
-
return [...dirs];
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Every server `.ts` source file (under the directories of the toilconfig `entries`, or `server/`
|
|
68
|
-
* by default). Passed to toilscript as explicit entries so a dropped-in `@data`/`@rest` file is
|
|
69
|
-
* compiled - and its surface picked up into `shared/server.ts` - even if the toilconfig lists only
|
|
70
|
-
* `main.ts`. Paths are returned relative to `root`.
|
|
71
|
-
*/
|
|
72
|
-
function serverEntryFiles(root: string): string[] {
|
|
73
|
-
const entries = toilconfigEntries(root);
|
|
74
|
-
if (entries === null) return [];
|
|
75
|
-
|
|
76
|
-
// Start from the toilconfig entries (normalized), then add any server file that declares a
|
|
77
|
-
// surface, so a dropped-in @data/@rest file is compiled even when it is not listed. Non-surface
|
|
78
|
-
// helpers stay out of the entry list - they are still compiled when imported - which also avoids
|
|
79
|
-
// toilscript's "class is not a WebAssembly export" warning for handler classes.
|
|
80
|
-
const result = new Set<string>(entries.map((e) => path.relative(root, path.resolve(root, e))));
|
|
81
|
-
|
|
82
|
-
const dirs = serverDirs(root);
|
|
83
|
-
|
|
84
|
-
let scanned = 0;
|
|
85
|
-
const cap = 500;
|
|
86
|
-
const visit = (dir: string, depth: number): void => {
|
|
87
|
-
if (scanned >= cap || depth > 16) return;
|
|
88
|
-
let listing: fs.Dirent[];
|
|
89
|
-
try {
|
|
90
|
-
listing = fs.readdirSync(dir, { withFileTypes: true });
|
|
91
|
-
} catch {
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
for (const entry of listing) {
|
|
95
|
-
if (scanned >= cap) break;
|
|
96
|
-
const full = path.join(dir, entry.name);
|
|
97
|
-
if (entry.isDirectory()) {
|
|
98
|
-
if (entry.name !== 'node_modules') visit(full, depth + 1);
|
|
99
|
-
} else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) {
|
|
100
|
-
scanned++;
|
|
101
|
-
try {
|
|
102
|
-
if (SURFACE_DECORATOR.test(fs.readFileSync(full, 'utf8'))) {
|
|
103
|
-
result.add(path.relative(root, full));
|
|
104
|
-
}
|
|
105
|
-
} catch {
|
|
106
|
-
// unreadable: skip
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
};
|
|
111
|
-
for (const dir of dirs) visit(dir, 0);
|
|
112
|
-
return [...result].sort();
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* The framework-shipped built-in-auth entry files (root-relative), appended to the toilscript entry
|
|
117
|
-
* set when `server.auth` is on. `AuthUser.ts` (the `@user` shape) is FIRST because `AuthController.ts`
|
|
118
|
-
* (the `@rest('auth')` controller) imports it. Primary lookup is the conventional install location
|
|
119
|
-
* under the app's `node_modules/toiljs` (which transparently follows a symlinked/linked toiljs); the
|
|
120
|
-
* fallback resolves the running toiljs package (`…/build/compiler/index.js` -> package root) for a
|
|
121
|
-
* hoisted install. Throws a clear error if the shipped files are missing.
|
|
122
|
-
*/
|
|
123
|
-
function authEntryFiles(root: string, appHasUser: boolean): string[] {
|
|
124
|
-
// EXTEND mode: the app declares its own `@user`, so the build injects the reserved `toilUserId` +
|
|
125
|
-
// `username` identity fields into it (via `--authUser`) and does NOT append the shipped empty `@user`,
|
|
126
|
-
// only the controller. BUILTIN mode (no app `@user`): append both (the shipped empty `SessionUser`
|
|
127
|
-
// gets the same injected identity fields).
|
|
128
|
-
const names = appHasUser ? ['AuthController.ts'] : ['AuthUser.ts', 'AuthController.ts'];
|
|
129
|
-
const primary = names.map((n) => path.posix.join('node_modules/toiljs/server/auth', n));
|
|
130
|
-
if (primary.every((rel) => fs.existsSync(path.join(root, rel)))) return primary;
|
|
131
|
-
// Fallback: locate this running toiljs package dir and make the paths root-relative.
|
|
132
|
-
try {
|
|
133
|
-
const pkgDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
|
134
|
-
const abs = names.map((n) => path.join(pkgDir, 'server', 'auth', n));
|
|
135
|
-
if (abs.every((p) => fs.existsSync(p)))
|
|
136
|
-
return abs.map((p) => path.relative(root, p).replace(/\\/g, '/'));
|
|
137
|
-
} catch {
|
|
138
|
-
// fall through to the error below
|
|
139
|
-
}
|
|
140
|
-
throw new Error(
|
|
141
|
-
'toiljs: server.auth is enabled but the built-in auth controller ' +
|
|
142
|
-
'(server/auth/AuthController.ts) was not found under node_modules/toiljs. ' +
|
|
143
|
-
'Reinstall toiljs (npm i toiljs), or remove server.auth from toil.config.ts.',
|
|
144
|
-
);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* Whether any server entry declares the escape-hatch `import 'toiljs/server/auth'` (a bare side-effect
|
|
149
|
-
* import) — the lighter opt-in that turns on the built-in auth surface without the `server.auth` config
|
|
150
|
-
* flag. Framework-shipped decorator sources under node_modules only WEAVE as explicit toilscript entries
|
|
151
|
-
* (a transitive import lands them under the `~lib/` LIBRARY prefix, where `@data`/`@rest`/`@user` do not
|
|
152
|
-
* weave), so the bare import is only a SIGNAL: it triggers the same entry injection as the flag. `import
|
|
153
|
-
* type` is skipped (erased). The marker module `server/auth/index.ts` is intentionally empty so it never
|
|
154
|
-
* pulls a conflicting `~lib/` copy of the controller into the compile.
|
|
155
|
-
*/
|
|
156
|
-
/**
|
|
157
|
-
* Whether any server entry declares its own `@user` (EXTEND mode). When built-in auth is on and the app
|
|
158
|
-
* declares a `@user`, the build extends THAT class: toilscript injects the reserved `toilUserId` +
|
|
159
|
-
* `username` fields into it (via `--authUser`, erroring on a name collision) instead of appending the
|
|
160
|
-
* shipped empty built-in `@user`, so the app carries the identity plus its own fields with no boilerplate.
|
|
161
|
-
*/
|
|
162
|
-
function serverDeclaresUser(root: string, files: string[]): boolean {
|
|
163
|
-
const re = /(^|[^.\w])@user\b/m;
|
|
164
|
-
for (const rel of files) {
|
|
165
|
-
try {
|
|
166
|
-
if (re.test(fs.readFileSync(path.join(root, rel), 'utf8'))) return true;
|
|
167
|
-
} catch {
|
|
168
|
-
// unreadable: skip
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
return false;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
function serverImportsAuth(root: string, files: string[]): boolean {
|
|
175
|
-
// Bare side-effect import only (`import 'toiljs/server/auth'`); not `import type`, not a subpath.
|
|
176
|
-
const re = /(^|[^.\w])import\s+['"]toiljs\/server\/auth['"]/m;
|
|
177
|
-
for (const rel of files) {
|
|
178
|
-
try {
|
|
179
|
-
if (re.test(fs.readFileSync(path.join(root, rel), 'utf8'))) return true;
|
|
180
|
-
} catch {
|
|
181
|
-
// unreadable: skip
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
return false;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* Whether the built-in auth surface is on for this project: the `server.auth` config flag OR the
|
|
189
|
-
* escape-hatch `import 'toiljs/server/auth'` in a server entry. This is the SAME predicate
|
|
190
|
-
* `buildServer` uses to decide whether to inject `AuthController` (see `authOn` there); the email
|
|
191
|
-
* pipeline reuses it so `_auth_emails.ts` is generated exactly when the controller that calls
|
|
192
|
-
* `AuthEmail.*` is compiled in (a mismatch would be a missing-symbol compile error). Client-only
|
|
193
|
-
* projects (no `toilconfig.json`) are never auth-on.
|
|
194
|
-
*/
|
|
195
|
-
function serverAuthEnabled(root: string, configAuth: boolean): boolean {
|
|
196
|
-
if (configAuth) return true;
|
|
197
|
-
if (!fs.existsSync(path.join(root, 'toilconfig.json'))) return false;
|
|
198
|
-
return serverImportsAuth(root, serverEntryFiles(root));
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/**
|
|
202
|
-
* Builds the toilscript server target (which also regenerates `shared/server.ts` via
|
|
203
|
-
* `--rpcModule`) when the project has one, signalled by a `toilconfig.json` at the root. This
|
|
204
|
-
* runs before the client build/dev so the generated `@data` + `Server` module the client
|
|
205
|
-
* imports is always current; without it a stale or missing `shared/server.ts` breaks the
|
|
206
|
-
* client build. A no-op for client-only projects. Compiles every server `.ts` file (not just the
|
|
207
|
-
* toilconfig entries) so dropped-in `@data`/`@rest` files are picked up. Runs the locally
|
|
208
|
-
* installed `toilscript`, resolved + invoked via Node (no `.bin` shim / PATH assumptions).
|
|
209
|
-
*/
|
|
210
|
-
export async function buildServer(root: string, auth: boolean = false): Promise<void> {
|
|
211
|
-
if (!fs.existsSync(path.join(root, 'toilconfig.json'))) return;
|
|
212
|
-
|
|
213
|
-
// Regenerate the editor-only server-globals d.ts each build (the same way
|
|
214
|
-
// `generate` rewrites `toil-env.d.ts`), so an existing project auto-migrates
|
|
215
|
-
// to the current shapes without re-scaffolding or running doctor. Best
|
|
216
|
-
// effort; an unwritable dir never blocks the build.
|
|
217
|
-
for (const dir of serverDirs(root)) {
|
|
218
|
-
try {
|
|
219
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
220
|
-
fs.writeFileSync(path.join(dir, 'toil-server-env.d.ts'), TOIL_SERVER_ENV_DTS);
|
|
221
|
-
} catch {
|
|
222
|
-
// editor-only; ignore write failures
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
const binJs = resolveToilscriptBin(root);
|
|
227
|
-
|
|
228
|
-
// Explicit entries (every server file) override the toilconfig entries; the target options
|
|
229
|
-
// (optimization, features, runtime) still come from the toilconfig's `release` target.
|
|
230
|
-
const files = serverEntryFiles(root);
|
|
231
|
-
|
|
232
|
-
// Built-in auth opt-in: APPEND the framework-shipped auth surface to the entry set BEFORE the tier
|
|
233
|
-
// split, so its `@user`/`@data`/`@database`/`@rest('auth')` decorators weave (a source under the
|
|
234
|
-
// `server/globals` LIB set — or transitively imported from node_modules under the `~lib/` prefix —
|
|
235
|
-
// would NOT weave) and the controller self-mounts at `/auth/*` with no hand-written boilerplate.
|
|
236
|
-
// AuthUser is ordered first (the controller imports it); AuthController lands in the REQUEST tier (it
|
|
237
|
-
// declares `@rest`) and AuthUser is shared into every pass (only `@user`/`@data`). They live under
|
|
238
|
-
// node_modules, so serverEntryFiles never scanned them. Opt-in is either the `server.auth` config flag
|
|
239
|
-
// OR the escape-hatch `import 'toiljs/server/auth'` in a server entry (which only MARKS the intent —
|
|
240
|
-
// it cannot weave on its own, being a `~lib/` import — so the build turns it into the same injection).
|
|
241
|
-
const authOn = auth || serverImportsAuth(root, files);
|
|
242
|
-
if (authOn) {
|
|
243
|
-
// EXTEND vs BUILTIN: if the app declares its own `@user`, extend that class (do not append the
|
|
244
|
-
// shipped `@user`); otherwise append the built-in empty one. Either way toilscript injects the
|
|
245
|
-
// reserved identity fields (`--authUser`, threaded into every pass below).
|
|
246
|
-
const appHasUser = serverDeclaresUser(root, files);
|
|
247
|
-
const authFiles = authEntryFiles(root, appHasUser);
|
|
248
|
-
const authSet = new Set(authFiles);
|
|
249
|
-
files.unshift(...authFiles);
|
|
250
|
-
for (let i = files.length - 1; i >= authFiles.length; i--) {
|
|
251
|
-
if (authSet.has(files[i])) files.splice(i, 1); // defensive dedup (normally none)
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// A project that declares a `@daemon` (L4 cold surface) and/or a `@stream` (L2/L3 stream
|
|
256
|
-
// surface) compiles the ONE source tree into SEPARATE artifacts, one per deployment tier, via
|
|
257
|
-
// one toilscript pass each; a project with only the request surface keeps the default
|
|
258
|
-
// single-artifact path. The three tiers:
|
|
259
|
-
// - REQUEST (L1) `server/main.ts` + `@rest`/`@service`/`@remote` -> `release.wasm`
|
|
260
|
-
// - STREAM (L2/L3) `server/main.stream.ts` + `@stream` -> `release-stream.wasm`
|
|
261
|
-
// - DAEMON (L4) `@daemon`/`@scheduled` -> `release-cold.wasm`
|
|
262
|
-
// toilscript's gating matrix HARD-ERRORS a class compiled under the wrong --targetMode, so each
|
|
263
|
-
// pass is handed only the files eligible for its tier (`@data`/`@database`/plain helpers are
|
|
264
|
-
// SHARED into every pass). The request pass runs LAST because it (re)writes shared/server.ts via
|
|
265
|
-
// --rpcModule, which the downstream client build imports.
|
|
266
|
-
const split = splitSurfaceFiles(root, files);
|
|
267
|
-
assertNoStreamInRequestTier(root, split);
|
|
268
|
-
if (split.hasDaemon || split.hasStream) {
|
|
269
|
-
const artifacts = serverArtifacts(root);
|
|
270
|
-
// DAEMON (cold) pass: --targetMode cold, no client RPC surface.
|
|
271
|
-
if (split.hasDaemon)
|
|
272
|
-
await runToilscriptPass(root, binJs, split.cold, {
|
|
273
|
-
mode: 'cold',
|
|
274
|
-
outFile: artifacts.cold,
|
|
275
|
-
withRpc: false,
|
|
276
|
-
authUser: authOn,
|
|
277
|
-
});
|
|
278
|
-
// STREAM pass: --targetMode hot into its OWN `release-stream.wasm`, no client RPC surface
|
|
279
|
-
// (a resident stream box exposes `stream_dispatch`, not the request client surface). Driven
|
|
280
|
-
// by `server/main.stream.ts` + the `@stream` classes; the request box never loads it.
|
|
281
|
-
if (split.hasStream && split.stream.length > 0)
|
|
282
|
-
await runToilscriptPass(root, binJs, split.stream, {
|
|
283
|
-
mode: 'hot',
|
|
284
|
-
outFile: artifacts.stream,
|
|
285
|
-
withRpc: false,
|
|
286
|
-
authUser: authOn,
|
|
287
|
-
});
|
|
288
|
-
// REQUEST pass: the L1 artifact (= `outFile`), WITH the client RPC surface.
|
|
289
|
-
// A pure daemon/stream project (no request files) skips it so toilscript is not handed an
|
|
290
|
-
// empty entry set; the request path then stays idle (no `handle` export), correct for a
|
|
291
|
-
// background-only worker.
|
|
292
|
-
if (split.request.length > 0)
|
|
293
|
-
await runToilscriptPass(root, binJs, split.request, {
|
|
294
|
-
mode: 'hot',
|
|
295
|
-
outFile: serverWasmFile(root),
|
|
296
|
-
withRpc: true,
|
|
297
|
-
// Fold the @stream tier's classes into this pass's client surface so toilscript emits
|
|
298
|
-
// the typed `Server.Stream` (class names, message-type encoders, merged @rest type)
|
|
299
|
-
// WITHOUT compiling stream code into release.wasm.
|
|
300
|
-
rpcSurfaceFiles: split.hasStream ? split.stream : undefined,
|
|
301
|
-
authUser: authOn,
|
|
302
|
-
});
|
|
303
|
-
// The stream pass carries no client RPC surface (withRpc:false), so toilscript never emits the
|
|
304
|
-
// `Server.Stream` client into shared/server.ts. Append it here from the compiled stream
|
|
305
|
-
// artifact's `toilstream.catalog` (the origin stays runtime-resolved, so this is origin-agnostic).
|
|
306
|
-
if (split.hasStream && split.stream.length > 0)
|
|
307
|
-
emitStreamClientSurface(root, artifacts.stream, split.stream);
|
|
308
|
-
return;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
// Default request-only single-artifact path (no daemon/stream surface). Declare `hot` rather
|
|
312
|
-
// than leaving --targetMode off: with no explicit mode toilscript treats a source tree that
|
|
313
|
-
// declares no surface decorator as a plain AssemblyScript compile and omits `toil.surface`
|
|
314
|
-
// (to keep an ordinary AS build byte-identical). A toiljs server IS a Toil request artifact
|
|
315
|
-
// even before it grows its first `@rest`/`@data`, and the host refuses to load an artifact
|
|
316
|
-
// without that section, so a freshly scaffolded app would never deploy. `hot` is the mode the
|
|
317
|
-
// section already records for the default request artifact, so the bytes are unchanged for a
|
|
318
|
-
// project that has a surface; it only stops the section from going missing for one that
|
|
319
|
-
// doesn't. (`@daemon`/`@scheduled` are the only decorators `hot` rejects, and this branch runs
|
|
320
|
-
// exactly when the tree has neither.)
|
|
321
|
-
await runToilscriptPass(root, binJs, files, {
|
|
322
|
-
mode: 'hot',
|
|
323
|
-
outFile: null,
|
|
324
|
-
withRpc: true,
|
|
325
|
-
authUser: authOn,
|
|
326
|
-
});
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
/** Resolve the locally installed `toilscript` bin via Node (no `.bin` shim / PATH assumptions). */
|
|
330
|
-
function resolveToilscriptBin(root: string): string {
|
|
331
|
-
const require = createRequire(path.join(root, 'package.json'));
|
|
332
|
-
try {
|
|
333
|
-
const pkgPath = require.resolve('toilscript/package.json');
|
|
334
|
-
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) as {
|
|
335
|
-
bin?: string | Record<string, string>;
|
|
336
|
-
};
|
|
337
|
-
const binRel = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.toilscript;
|
|
338
|
-
if (!binRel) throw new Error('toilscript declares no bin');
|
|
339
|
-
return path.join(path.dirname(pkgPath), binRel);
|
|
340
|
-
} catch {
|
|
341
|
-
throw new Error(
|
|
342
|
-
"toiljs: this project has a server target (toilconfig.json) but 'toilscript' is not " +
|
|
343
|
-
'installed. Run `npm i -D toilscript`, or remove toilconfig.json for a client-only build.',
|
|
344
|
-
);
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
/** Files classified per deployment TIER for the multi-artifact build. */
|
|
349
|
-
interface SurfaceSplit {
|
|
350
|
-
/** Whether any file declares a `@daemon` (so a cold/daemon pass is needed at all). */
|
|
351
|
-
readonly hasDaemon: boolean;
|
|
352
|
-
/** Whether any file declares a `@stream` (or is a `*.stream.ts` entry), so a stream pass is needed. */
|
|
353
|
-
readonly hasStream: boolean;
|
|
354
|
-
/** Files for the DAEMON (cold) pass: `@daemon`/`@scheduled` surfaces + shared helpers. */
|
|
355
|
-
readonly cold: string[];
|
|
356
|
-
/** Files for the STREAM pass: `@stream` surfaces + the `*.stream.ts` entry + shared helpers. */
|
|
357
|
-
readonly stream: string[];
|
|
358
|
-
/** Files for the REQUEST pass: `@rest`/`@service`/`@remote` surfaces + the request entry + shared helpers. */
|
|
359
|
-
readonly request: string[];
|
|
360
|
-
/** The `@stream` / `*.stream.ts` modules (NOT the shared helpers). If a request-tier file imports one,
|
|
361
|
-
* `stream_dispatch` + its ring buffers would compile into release.wasm (audit #17). */
|
|
362
|
-
readonly streamModules: string[];
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
/** A `@daemon`/`@scheduled` decorator at line start (the L4 cold/daemon surface). */
|
|
366
|
-
const COLD_DECORATOR = /^[ \t]*@(daemon|scheduled)\b/m;
|
|
367
|
-
/** A `@stream` decorator at line start (the L2/L3 stream surface). */
|
|
368
|
-
const STREAM_DECORATOR = /^[ \t]*@stream\b/m;
|
|
369
|
-
/** A request-surface decorator at line start (`@rest`/`@route`/`@service`/`@remote`, the L1 tier). */
|
|
370
|
-
const REQUEST_DECORATOR = /^[ \t]*@(rest|route|service|remote)\b/m;
|
|
371
|
-
/** A server ENTRY re-exports the runtime WASM entry points; this marks `main.ts` / `main.stream.ts`
|
|
372
|
-
* (vs a plain `@data`/helper), so each entry is routed to exactly ONE tier and two entries never
|
|
373
|
-
* collide on a duplicate `export *` in the same pass. */
|
|
374
|
-
const RUNTIME_ENTRY = /from\s+['"]toiljs\/server\/runtime\/exports['"]/;
|
|
375
|
-
|
|
376
|
-
/** True for a STREAM-tier entry by the `*.stream.ts` naming convention (e.g. `main.stream.ts`). */
|
|
377
|
-
function isStreamEntryFile(rel: string): boolean {
|
|
378
|
-
return rel.endsWith('.stream.ts');
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
/** True for a COLD/daemon-tier entry by the `*.daemon.ts` naming convention (e.g. `main.daemon.ts`). */
|
|
382
|
-
function isDaemonEntryFile(rel: string): boolean {
|
|
383
|
-
return rel.endsWith('.daemon.ts');
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
/**
|
|
387
|
-
* Classify each server source file by its deployment TIER, so each toilscript pass is handed only
|
|
388
|
-
* the files valid for its `--targetMode` (toilscript HARD-ERRORS a class compiled under the wrong
|
|
389
|
-
* mode). Three tiers:
|
|
390
|
-
* - COLD/daemon: a file declaring `@daemon`/`@scheduled` -> `release-cold.wasm`.
|
|
391
|
-
* - STREAM (L2/L3): a file declaring `@stream`, OR a `*.stream.ts` entry (`main.stream.ts`) ->
|
|
392
|
-
* `release-stream.wasm`.
|
|
393
|
-
* - REQUEST (L1): a file declaring `@rest`/`@service`/`@remote`, OR a non-`*.stream.ts` runtime
|
|
394
|
-
* ENTRY (`main.ts`) -> `release.wasm`.
|
|
395
|
-
* A file with NONE of these (a plain `@data`/`@database`/helper) is SHARED into every pass, matching
|
|
396
|
-
* toilscript's class-level gating. Routing each entry to exactly one tier keeps `release.wasm` free
|
|
397
|
-
* of `stream_dispatch` and stops two entries re-exporting the runtime in the same pass.
|
|
398
|
-
*/
|
|
399
|
-
export function splitSurfaceFiles(root: string, files: string[]): SurfaceSplit {
|
|
400
|
-
let hasDaemon = false;
|
|
401
|
-
let hasStream = false;
|
|
402
|
-
const cold: string[] = [];
|
|
403
|
-
const stream: string[] = [];
|
|
404
|
-
const request: string[] = [];
|
|
405
|
-
const streamModules: string[] = [];
|
|
406
|
-
for (const rel of files) {
|
|
407
|
-
let src = '';
|
|
408
|
-
try {
|
|
409
|
-
src = fs.readFileSync(path.join(root, rel), 'utf8');
|
|
410
|
-
} catch {
|
|
411
|
-
// unreadable: keep it in EVERY pass (let toilscript surface the error).
|
|
412
|
-
cold.push(rel);
|
|
413
|
-
stream.push(rel);
|
|
414
|
-
request.push(rel);
|
|
415
|
-
continue;
|
|
416
|
-
}
|
|
417
|
-
const isCold = COLD_DECORATOR.test(src) || isDaemonEntryFile(rel);
|
|
418
|
-
const isStream = STREAM_DECORATOR.test(src) || isStreamEntryFile(rel);
|
|
419
|
-
const isRequest =
|
|
420
|
-
REQUEST_DECORATOR.test(src) ||
|
|
421
|
-
(RUNTIME_ENTRY.test(src) && !isStreamEntryFile(rel) && !isDaemonEntryFile(rel));
|
|
422
|
-
if (isCold) hasDaemon ||= /^[ \t]*@daemon\b/m.test(src) || isDaemonEntryFile(rel);
|
|
423
|
-
if (isStream) {
|
|
424
|
-
hasStream = true;
|
|
425
|
-
streamModules.push(rel);
|
|
426
|
-
}
|
|
427
|
-
// A file with no tier-specific surface is a SHARED helper, compiled into every pass.
|
|
428
|
-
const shared = !isCold && !isStream && !isRequest;
|
|
429
|
-
if (isCold || shared) cold.push(rel);
|
|
430
|
-
if (isStream || shared) stream.push(rel);
|
|
431
|
-
if (isRequest || shared) request.push(rel);
|
|
432
|
-
}
|
|
433
|
-
return { hasDaemon, hasStream, cold, stream, request, streamModules };
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
/** The module specifiers a source statically imports / re-exports / dynamically imports. `import type` /
|
|
437
|
-
* `export type` are skipped: they are erased and never compile the target (so they cannot leak code). */
|
|
438
|
-
function* importSpecifiers(rawSrc: string): Generator<string> {
|
|
439
|
-
// Strip comments first, so a commented-out `// import { X } from './streams/...'` cannot trip the
|
|
440
|
-
// guard (mirrors emitStreamClientSurface's comment strip). A string literal containing import-like
|
|
441
|
-
// text is a rarer case this does not cover; the realistic dev-time scenario is a commented import.
|
|
442
|
-
const src = rawSrc.replace(/\/\/[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, '');
|
|
443
|
-
const fromRe = /\b(import|export)(\s+type)?\b[^'";]*?\bfrom\s*['"]([^'"]+)['"]/g;
|
|
444
|
-
let m: RegExpExecArray | null;
|
|
445
|
-
while ((m = fromRe.exec(src)) !== null) {
|
|
446
|
-
if (m[2]) continue; // `import type` / `export type` - erased
|
|
447
|
-
yield m[3];
|
|
448
|
-
}
|
|
449
|
-
const bareRe = /\bimport\s+['"]([^'"]+)['"]/g; // bare side-effect `import '...'`
|
|
450
|
-
while ((m = bareRe.exec(src)) !== null) yield m[1];
|
|
451
|
-
const dynRe = /\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g; // dynamic `import('...')`
|
|
452
|
-
while ((m = dynRe.exec(src)) !== null) yield m[1];
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
/** Resolve a RELATIVE import specifier to a real file under `root` (a repo-relative posix path), or null
|
|
456
|
-
* for a bare/external/alias import or an unresolved path. Resolves against the FILESYSTEM (not just the
|
|
457
|
-
* entry+surface file list) so the guard's import graph also traverses plain UNDECORATED helper modules:
|
|
458
|
-
* a @stream reached transitively through one (main.ts -> util.ts -> Echo.ts) must still be caught (#6). */
|
|
459
|
-
function resolveServerImport(root: string, fromRel: string, spec: string): string | null {
|
|
460
|
-
if (!spec.startsWith('.')) return null;
|
|
461
|
-
const base = path
|
|
462
|
-
.normalize(path.join(path.dirname(fromRel), spec))
|
|
463
|
-
.replace(/\\/g, '/')
|
|
464
|
-
.replace(/\.(js|mjs|jsx|ts|tsx)$/, '');
|
|
465
|
-
for (const cand of [
|
|
466
|
-
base,
|
|
467
|
-
`${base}.ts`,
|
|
468
|
-
`${base}.tsx`,
|
|
469
|
-
`${base}.js`,
|
|
470
|
-
`${base}.mjs`,
|
|
471
|
-
`${base}/index.ts`,
|
|
472
|
-
`${base}/index.tsx`,
|
|
473
|
-
`${base}/index.js`,
|
|
474
|
-
]) {
|
|
475
|
-
if (fs.existsSync(path.join(root, cand))) return cand;
|
|
476
|
-
}
|
|
477
|
-
return null;
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
/**
|
|
481
|
-
* Fail closed (audit #17): a `@stream` class compiled into the REQUEST tier would bake `stream_dispatch`
|
|
482
|
-
* + its 128 KiB ring buffers into release.wasm. `splitSurfaceFiles` keeps `@stream` files out of the
|
|
483
|
-
* request SET, but a stray import from a request-tier file still pulls one into release.wasm's compile
|
|
484
|
-
* graph - the tier boundary is otherwise only structural. Reject any request-tier file that (transitively)
|
|
485
|
-
* reaches a `@stream`/`*.stream.ts` module. The `Server.Stream` TYPE surface arrives via `--rpcSurfaceFiles`,
|
|
486
|
-
* NOT an import, so legitimate stream typing is unaffected; `import type` is likewise erased and ignored.
|
|
487
|
-
*/
|
|
488
|
-
export function assertNoStreamInRequestTier(root: string, split: SurfaceSplit): void {
|
|
489
|
-
const streamSet = new Set(split.streamModules);
|
|
490
|
-
if (streamSet.size === 0) return;
|
|
491
|
-
const offenders = new Set<string>();
|
|
492
|
-
const seen = new Set<string>();
|
|
493
|
-
const queue = [...split.request];
|
|
494
|
-
while (queue.length > 0) {
|
|
495
|
-
const rel = queue.pop()!;
|
|
496
|
-
if (seen.has(rel)) continue;
|
|
497
|
-
seen.add(rel);
|
|
498
|
-
if (streamSet.has(rel)) {
|
|
499
|
-
// A @stream module sits directly in the request compile set (a file mixing @stream with
|
|
500
|
-
// request-tier code).
|
|
501
|
-
offenders.add(rel);
|
|
502
|
-
continue;
|
|
503
|
-
}
|
|
504
|
-
let src: string;
|
|
505
|
-
try {
|
|
506
|
-
src = fs.readFileSync(path.join(root, rel), 'utf8');
|
|
507
|
-
} catch {
|
|
508
|
-
continue;
|
|
509
|
-
}
|
|
510
|
-
for (const spec of importSpecifiers(src)) {
|
|
511
|
-
const target = resolveServerImport(root, rel, spec);
|
|
512
|
-
if (target === null) continue;
|
|
513
|
-
if (streamSet.has(target)) offenders.add(`${rel} -> ${target}`);
|
|
514
|
-
else if (!seen.has(target)) queue.push(target);
|
|
515
|
-
}
|
|
516
|
-
}
|
|
517
|
-
if (offenders.size > 0) {
|
|
518
|
-
throw new Error(
|
|
519
|
-
'toiljs: a @stream class would be compiled into the REQUEST tier (release.wasm). @stream ' +
|
|
520
|
-
'handlers (stream_dispatch + ring buffers) belong only in the stream tier ' +
|
|
521
|
-
'(release-stream.wasm), driven by server/main.stream.ts. A request-tier file reaches a ' +
|
|
522
|
-
'@stream module:\n ' +
|
|
523
|
-
[...offenders].join('\n ') +
|
|
524
|
-
'\nRemove the import, or move the code into a *.stream.ts module.',
|
|
525
|
-
);
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
interface PassOptions {
|
|
530
|
-
/** `--targetMode` value; `null` keeps the default request-artifact invocation (no flag). */
|
|
531
|
-
readonly mode: 'hot' | 'cold' | null;
|
|
532
|
-
/** Explicit `--outFile` for a two-pass build; `null` uses the toilconfig default. */
|
|
533
|
-
readonly outFile: string | null;
|
|
534
|
-
/** Only the hot/default request pass carries `--rpcModule` (the cold artifact has no client surface). */
|
|
535
|
-
readonly withRpc: boolean;
|
|
536
|
-
/** Files parsed for the client surface only (e.g. a sibling tier's `@stream` classes) - NOT compiled
|
|
537
|
-
* into this artifact. Lets the request pass emit `Server.Stream` without pulling stream code into
|
|
538
|
-
* release.wasm. */
|
|
539
|
-
readonly rpcSurfaceFiles?: readonly string[];
|
|
540
|
-
/** Built-in auth: pass `--authUser` so toilscript injects the reserved `toilUserId` + `username` fields
|
|
541
|
-
* into the single `@user`. Threaded into every pass so the `@user` codec is identical across tiers. */
|
|
542
|
-
readonly authUser?: boolean;
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
/** Run one toilscript pass. The toilscript CLI flag is `--targetMode` (camelCase). */
|
|
546
|
-
function runToilscriptPass(
|
|
547
|
-
root: string,
|
|
548
|
-
binJs: string,
|
|
549
|
-
files: string[],
|
|
550
|
-
opts: PassOptions,
|
|
551
|
-
): Promise<void> {
|
|
552
|
-
// Suppress AS235 ("only variables/functions/enums become wasm exports"): a `@data`/`@rest`
|
|
553
|
-
// class is intentionally `export class` (so other server files import it), but never a wasm
|
|
554
|
-
// export — the warning is pure noise here.
|
|
555
|
-
const args = [binJs, ...files, '--target', 'release'];
|
|
556
|
-
if (opts.mode !== null) args.push('--targetMode', opts.mode);
|
|
557
|
-
if (opts.outFile !== null) args.push('--outFile', opts.outFile);
|
|
558
|
-
if (opts.withRpc) args.push('--rpcModule', 'shared/server.ts');
|
|
559
|
-
if (opts.rpcSurfaceFiles)
|
|
560
|
-
for (const surfaceFile of opts.rpcSurfaceFiles) args.push('--rpcSurfaceFiles', surfaceFile);
|
|
561
|
-
if (opts.authUser) args.push('--authUser');
|
|
562
|
-
// Built-in auth: add the app-local `.toil/authlib` as a library path so the
|
|
563
|
-
// generated `AuthEmail` (see renderAuthEmails) is an ambient global the
|
|
564
|
-
// node_modules-compiled AuthController resolves with no import. `--lib` merges
|
|
565
|
-
// with the toilconfig `lib` (it does not replace it). Written before this pass.
|
|
566
|
-
if (opts.authUser) args.push('--lib', AUTH_EMAILS_LIB_SUBDIR);
|
|
567
|
-
// Each pass is handed its OWN entry subset (the per-tier `files`); suppress the toilconfig
|
|
568
|
-
// `entries` so toilscript does not ALSO append every project entry to every pass (which would
|
|
569
|
-
// pull, e.g., a `@stream` class into the cold daemon pass). serverEntryFiles already folds
|
|
570
|
-
// config.entries into `files`, so no entry is lost by ignoring them here.
|
|
571
|
-
args.push('--noConfigEntries');
|
|
572
|
-
args.push('--disableWarning', '235');
|
|
573
|
-
|
|
574
|
-
return new Promise<void>((resolve, reject) => {
|
|
575
|
-
const child = spawn(process.execPath, args, { cwd: root, stdio: 'inherit' });
|
|
576
|
-
child.on('error', reject);
|
|
577
|
-
child.on('close', (code) =>
|
|
578
|
-
code === 0
|
|
579
|
-
? resolve()
|
|
580
|
-
: reject(
|
|
581
|
-
new Error(
|
|
582
|
-
`toilscript ${opts.mode ?? 'release'} build failed (exit ${String(code)})`,
|
|
583
|
-
),
|
|
584
|
-
),
|
|
585
|
-
);
|
|
586
|
-
});
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
/**
|
|
590
|
-
* Watches the server source dirs and rebuilds the server (toilscript) on change, so editing a
|
|
591
|
-
* `@data`/`@rest` file under `toiljs dev` regenerates `shared/server.ts` - which Vite then HMRs
|
|
592
|
-
* into the client - and the dev server hot-swaps the recompiled wasm: the server-side equivalent
|
|
593
|
-
* of Vite's client HMR. Client-only edits never touch these dirs, so they only trigger Vite,
|
|
594
|
-
* never a server rebuild. Rebuilds are debounced and never overlap. Rides Vite's chokidar
|
|
595
|
-
* watcher instead of a separate `fs.watch`: the native recursive watcher silently stops
|
|
596
|
-
* delivering events on Linux after editors replace files via rename, which left hot reload
|
|
597
|
-
* working exactly once. A no-op for client-only projects.
|
|
598
|
-
*/
|
|
599
|
-
function watchServer(cfg: ResolvedToilConfig, watcher: ViteDevServer['watcher']): void {
|
|
600
|
-
const root = cfg.root;
|
|
601
|
-
const dirs = serverDirs(root);
|
|
602
|
-
if (dirs.length === 0) return;
|
|
603
|
-
const emailsDir = path.join(root, 'emails');
|
|
604
|
-
|
|
605
|
-
let building = false;
|
|
606
|
-
let queued = false;
|
|
607
|
-
const rebuild = (): void => {
|
|
608
|
-
if (building) {
|
|
609
|
-
queued = true;
|
|
610
|
-
return;
|
|
611
|
-
}
|
|
612
|
-
building = true;
|
|
613
|
-
process.stdout.write(pc.dim(' server changed, rebuilding…') + '\n');
|
|
614
|
-
// Recompile emails/*.tsx -> the generated module before the server build,
|
|
615
|
-
// so editing an email template hot-reloads like any other server change.
|
|
616
|
-
const authOn = serverAuthEnabled(root, cfg.auth);
|
|
617
|
-
renderEmails(cfg, authOn)
|
|
618
|
-
.then(() => renderAuthEmails(cfg, authOn))
|
|
619
|
-
.then(() => buildServer(root, cfg.auth))
|
|
620
|
-
.then(() => process.stdout.write(pc.green(' ✓ ') + pc.dim('server rebuilt') + '\n'))
|
|
621
|
-
.catch((e: unknown) =>
|
|
622
|
-
process.stdout.write(pc.red(` ✗ server rebuild failed: ${String(e)}`) + '\n'),
|
|
623
|
-
)
|
|
624
|
-
.finally(() => {
|
|
625
|
-
building = false;
|
|
626
|
-
if (queued) {
|
|
627
|
-
queued = false;
|
|
628
|
-
rebuild();
|
|
629
|
-
}
|
|
630
|
-
});
|
|
631
|
-
};
|
|
632
|
-
|
|
633
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
634
|
-
const isServerSource = (file: string): boolean =>
|
|
635
|
-
file.endsWith('.ts') &&
|
|
636
|
-
!file.endsWith('.d.ts') &&
|
|
637
|
-
// `_emails.ts` / `_auth_emails.ts` are GENERATED on every rebuild; reacting to
|
|
638
|
-
// our own output would loop forever (rebuild -> write -> rebuild -> ...).
|
|
639
|
-
path.basename(file) !== '_emails.ts' &&
|
|
640
|
-
path.basename(file) !== AUTH_EMAILS_GENERATED_BASENAME &&
|
|
641
|
-
dirs.some((dir) => file === dir || file.startsWith(dir + path.sep));
|
|
642
|
-
const isEmailSource = (file: string): boolean =>
|
|
643
|
-
/\.(tsx|jsx)$/.test(file) && (file === emailsDir || file.startsWith(emailsDir + path.sep));
|
|
644
|
-
// A transient watch error must NOT crash the dev server: an unhandled 'error'
|
|
645
|
-
// on the chokidar watcher takes down the whole process. Windows throws EBUSY /
|
|
646
|
-
// EPERM when a file is momentarily locked (an editor save, a formatter, our own
|
|
647
|
-
// rebuild, a just-written file). Swallow it — the next change still fires.
|
|
648
|
-
watcher.on('error', (err: unknown) => {
|
|
649
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
650
|
-
process.stdout.write(pc.yellow(' ! ') + pc.dim(`file watcher: ${msg}`) + '\n');
|
|
651
|
-
});
|
|
652
|
-
watcher.add([...dirs, emailsDir]);
|
|
653
|
-
watcher.on('all', (_event, file) => {
|
|
654
|
-
if (!isServerSource(file) && !isEmailSource(file)) return;
|
|
655
|
-
if (timer) clearTimeout(timer);
|
|
656
|
-
timer = setTimeout(rebuild, 150); // debounce bursts (save-all, formatters)
|
|
657
|
-
});
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
/**
|
|
661
|
-
* Make `Ctrl+C` actually kill the dev server. Without this the process can hang
|
|
662
|
-
* on shutdown (the native uWebSockets listener / Vite's watcher don't always
|
|
663
|
-
* close promptly), so an old `toiljs dev` is left ORPHANED — still watching and
|
|
664
|
-
* rebuilding — and the next run races it (parallel double rebuilds), while the
|
|
665
|
-
* console is left with a hidden cursor. On SIGINT/SIGTERM we restore the cursor,
|
|
666
|
-
* close the servers, and force-exit after a short grace period no matter what.
|
|
667
|
-
*/
|
|
668
|
-
function installDevShutdown(close: () => Promise<void> | void): void {
|
|
669
|
-
// Final, SYNCHRONOUS terminal restore — `exit` runs no matter how we go
|
|
670
|
-
// (signal, throw, normal), so the console can't be left in a broken state.
|
|
671
|
-
const restoreTerminal = (): void => {
|
|
672
|
-
// Cooked input mode back. This is the important one on Windows: if anything
|
|
673
|
-
// in the dev stack (a dep that reads stdin, or libuv) left stdin in raw /
|
|
674
|
-
// VT-input mode, our force-exit below skips the automatic tty reset, and
|
|
675
|
-
// cmd is left echoing arrow keys as `^[[A` and mis-reading typed input.
|
|
676
|
-
// `setRawMode(false)` forces the console back to normal line editing.
|
|
677
|
-
try {
|
|
678
|
-
const stdin = process.stdin;
|
|
679
|
-
if (stdin.isTTY && typeof stdin.setRawMode === 'function') stdin.setRawMode(false);
|
|
680
|
-
} catch {
|
|
681
|
-
/* not a TTY / already torn down */
|
|
682
|
-
}
|
|
683
|
-
// Show the cursor and reset styles (a build spinner may have hidden it).
|
|
684
|
-
try {
|
|
685
|
-
process.stdout.write('\x1b[0m\x1b[?25h');
|
|
686
|
-
} catch {
|
|
687
|
-
/* stream already closed */
|
|
688
|
-
}
|
|
689
|
-
};
|
|
690
|
-
process.on('exit', restoreTerminal);
|
|
691
|
-
|
|
692
|
-
let closing = false;
|
|
693
|
-
const shutdown = (): void => {
|
|
694
|
-
if (closing) return;
|
|
695
|
-
closing = true;
|
|
696
|
-
restoreTerminal();
|
|
697
|
-
process.stdout.write(pc.dim('\n shutting down dev server…') + '\n');
|
|
698
|
-
// Force-exit even if a server hangs on close (the orphan-prevention).
|
|
699
|
-
const hard = setTimeout(() => process.exit(0), 1500);
|
|
700
|
-
hard.unref();
|
|
701
|
-
Promise.resolve()
|
|
702
|
-
.then(close)
|
|
703
|
-
.catch(() => {})
|
|
704
|
-
.finally(() => process.exit(0));
|
|
705
|
-
};
|
|
706
|
-
for (const sig of ['SIGINT', 'SIGTERM'] as const) process.once(sig, shutdown);
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
/** The server wasm artifact path from the toilconfig `release` target (toilscript's output).
|
|
710
|
-
* This is the default request artifact path (= the hot artifact under the two-pass build). */
|
|
711
|
-
function serverWasmFile(root: string): string {
|
|
712
|
-
let outFile = 'build/server/release.wasm';
|
|
713
|
-
try {
|
|
714
|
-
const cfg = JSON.parse(fs.readFileSync(path.join(root, 'toilconfig.json'), 'utf8')) as {
|
|
715
|
-
targets?: Record<string, { outFile?: string }>;
|
|
716
|
-
};
|
|
717
|
-
outFile = cfg.targets?.release?.outFile ?? outFile;
|
|
718
|
-
} catch {
|
|
719
|
-
// No readable toilconfig: caller already gated on its existence; keep the default.
|
|
720
|
-
}
|
|
721
|
-
return path.resolve(root, outFile);
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
/** The hot + cold artifact paths for the two-pass build. `hotFile`/`coldFile` are honored when
|
|
725
|
-
* present in the toilconfig `release` target; otherwise derived from `outFile` by inserting the
|
|
726
|
-
* mode before the extension (`release.wasm` -> `release-hot.wasm` / `release-cold.wasm`). */
|
|
727
|
-
export interface ServerArtifacts {
|
|
728
|
-
/** Absolute path to the hot (request) artifact. */
|
|
729
|
-
readonly hot: string;
|
|
730
|
-
/** Absolute path to the cold (daemon) artifact. */
|
|
731
|
-
readonly cold: string;
|
|
732
|
-
/** Absolute path to the stream (L2/L3 `@stream`) artifact (`release-stream.wasm`). */
|
|
733
|
-
readonly stream: string;
|
|
734
|
-
}
|
|
735
|
-
export function serverArtifacts(root: string): ServerArtifacts {
|
|
736
|
-
let out = 'build/server/release.wasm';
|
|
737
|
-
let hot: string | undefined;
|
|
738
|
-
let cold: string | undefined;
|
|
739
|
-
let stream: string | undefined;
|
|
740
|
-
try {
|
|
741
|
-
const cfg = JSON.parse(fs.readFileSync(path.join(root, 'toilconfig.json'), 'utf8')) as {
|
|
742
|
-
targets?: Record<
|
|
743
|
-
string,
|
|
744
|
-
{ outFile?: string; hotFile?: string; coldFile?: string; streamFile?: string }
|
|
745
|
-
>;
|
|
746
|
-
};
|
|
747
|
-
out = cfg.targets?.release?.outFile ?? out;
|
|
748
|
-
hot = cfg.targets?.release?.hotFile;
|
|
749
|
-
cold = cfg.targets?.release?.coldFile;
|
|
750
|
-
stream = cfg.targets?.release?.streamFile;
|
|
751
|
-
} catch {
|
|
752
|
-
// No readable toilconfig: caller already gated on its existence; keep defaults.
|
|
753
|
-
}
|
|
754
|
-
const ins = (mode: 'hot' | 'cold' | 'stream'): string => {
|
|
755
|
-
const ext = path.extname(out);
|
|
756
|
-
return out.slice(0, ext ? -ext.length : undefined) + '-' + mode + (ext || '.wasm');
|
|
757
|
-
};
|
|
758
|
-
return {
|
|
759
|
-
hot: path.resolve(root, hot ?? ins('hot')),
|
|
760
|
-
cold: path.resolve(root, cold ?? ins('cold')),
|
|
761
|
-
stream: path.resolve(root, stream ?? ins('stream')),
|
|
762
|
-
};
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
/** An OS-assigned free loopback port (for the internal Vite server behind the dev front). */
|
|
766
|
-
async function freeLoopbackPort(): Promise<number> {
|
|
767
|
-
return new Promise((resolve, reject) => {
|
|
768
|
-
const probe = net.createServer();
|
|
769
|
-
probe.once('error', reject);
|
|
770
|
-
probe.listen(0, '127.0.0.1', () => {
|
|
771
|
-
const address = probe.address();
|
|
772
|
-
if (address === null || typeof address === 'string') {
|
|
773
|
-
probe.close();
|
|
774
|
-
reject(new Error('could not allocate a loopback port'));
|
|
775
|
-
return;
|
|
776
|
-
}
|
|
777
|
-
probe.close(() => resolve(address.port));
|
|
778
|
-
});
|
|
779
|
-
});
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
export interface ToilCommandOptions {
|
|
783
|
-
readonly root?: string;
|
|
784
|
-
readonly port?: number;
|
|
785
|
-
/** Bind host for `start`. Defaults to loopback (`127.0.0.1`); pass `0.0.0.0` to expose. */
|
|
786
|
-
readonly host?: string;
|
|
787
|
-
/** `start` only: production HTTP worker count. Defaults to `server.threads` / auto. */
|
|
788
|
-
readonly threads?: number;
|
|
789
|
-
/** `build` only: build the server (regenerate `shared/server.ts` + the wasm) and skip the client. */
|
|
790
|
-
readonly serverOnly?: boolean;
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
/** Prints the email-preview URL under the dev banner, when the project has an
|
|
794
|
-
* `emails/` folder. `localUrl` is the resolved base (ends in `/`); skipped if
|
|
795
|
-
* the server didn't report one. */
|
|
796
|
-
function printEmailsUrl(cfg: ResolvedToilConfig, localUrl: string | undefined): void {
|
|
797
|
-
if (!localUrl || !fs.existsSync(path.join(cfg.root, 'emails'))) return;
|
|
798
|
-
process.stdout.write(
|
|
799
|
-
' ' +
|
|
800
|
-
pc.green('✉') +
|
|
801
|
-
' ' +
|
|
802
|
-
pc.bold('Emails') +
|
|
803
|
-
': ' +
|
|
804
|
-
pc.cyan(`${localUrl}__toil/emails`) +
|
|
805
|
-
pc.dim(' (preview)') +
|
|
806
|
-
'\n',
|
|
807
|
-
);
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
/**
|
|
811
|
-
* Starts the dev server. Client-only projects get the plain Vite dev server on
|
|
812
|
-
* the configured port, unchanged. Projects with a server target
|
|
813
|
-
* (toilconfig.json) get the WASM dev server in front: a uWebSockets.js server
|
|
814
|
-
* on the configured port that dispatches requests into the ToilScript server
|
|
815
|
-
* wasm (same envelope ABI as the production edge) and transparently proxies
|
|
816
|
-
* everything the server does not claim, HMR websocket included, to a Vite dev
|
|
817
|
-
* server on an internal loopback port. Vite keeps 100% of its dev behavior;
|
|
818
|
-
* it just stops being the public listener. Returns the running Vite server.
|
|
819
|
-
*/
|
|
820
|
-
/** Stylesheet module ids (incl. preprocessors), to find CSS in the module graph. */
|
|
821
|
-
const DEV_CSS_RE = /\.(css|scss|sass|less|styl|stylus|pcss|postcss|sss)(\?|$)/;
|
|
822
|
-
|
|
823
|
-
/**
|
|
824
|
-
* Collects the CSS reachable from the dev client entry (`/entry.tsx`) by walking Vite's module graph
|
|
825
|
-
* over static imports, then fetching each stylesheet's raw text (`?direct`). The dev SSR shell INLINES
|
|
826
|
-
* this so the server-rendered first paint is styled with NO extra request: a render-blocking `<link>`
|
|
827
|
-
* would otherwise stall the paint while Vite cold-transforms+serves the CSS (a visible blank beat on a
|
|
828
|
-
* cold load). Best-effort + transitive; returns '' if the entry can't be walked.
|
|
829
|
-
*/
|
|
830
|
-
async function collectDevCss(server: ViteDevServer): Promise<string> {
|
|
831
|
-
const ENTRY = '/entry.tsx';
|
|
832
|
-
const cssUrls = new Set<string>();
|
|
833
|
-
const seen = new Set<string>();
|
|
834
|
-
try {
|
|
835
|
-
await server.transformRequest(ENTRY);
|
|
836
|
-
} catch {
|
|
837
|
-
return '';
|
|
838
|
-
}
|
|
839
|
-
const entry = await server.moduleGraph.getModuleByUrl(ENTRY);
|
|
840
|
-
if (!entry) return '';
|
|
841
|
-
const queue = [entry];
|
|
842
|
-
while (queue.length > 0) {
|
|
843
|
-
const mod = queue.shift();
|
|
844
|
-
if (!mod || mod.id === null || seen.has(mod.id)) continue;
|
|
845
|
-
seen.add(mod.id);
|
|
846
|
-
// Transform (once) so the module's imports are populated in the graph.
|
|
847
|
-
if (!mod.transformResult && mod.url) {
|
|
848
|
-
try {
|
|
849
|
-
await server.transformRequest(mod.url);
|
|
850
|
-
} catch {
|
|
851
|
-
continue;
|
|
852
|
-
}
|
|
853
|
-
}
|
|
854
|
-
for (const dep of mod.importedModules) {
|
|
855
|
-
if (dep.id === null) continue;
|
|
856
|
-
if (DEV_CSS_RE.test(dep.id)) {
|
|
857
|
-
if (dep.url) cssUrls.add(dep.url);
|
|
858
|
-
} else if (!seen.has(dep.id)) {
|
|
859
|
-
queue.push(dep);
|
|
860
|
-
}
|
|
861
|
-
}
|
|
862
|
-
}
|
|
863
|
-
// Extract each stylesheet's real CSS from its normal (non-`?direct`) module. Vite serves a
|
|
864
|
-
// JS-imported CSS file as a JS module that carries the CSS in a `const __vite__css = "..."`
|
|
865
|
-
// literal (JSON-encoded); that module is valid JS, so vite-plugin-node-polyfills' inject plugin
|
|
866
|
-
// passes it through intact. We deliberately AVOID the `?direct` variant: it serves raw CSS, which
|
|
867
|
-
// the inject plugin then fails to parse-as-JS and truncates to the leading comment (whether via
|
|
868
|
-
// transformRequest OR an HTTP fetch). We inline this at startup so the SSR first paint is styled.
|
|
869
|
-
let css = '';
|
|
870
|
-
for (const url of cssUrls) {
|
|
871
|
-
try {
|
|
872
|
-
const result = await server.transformRequest(url);
|
|
873
|
-
const m = result?.code
|
|
874
|
-
? /const __vite__css = ("(?:[^"\\]|\\.)*")/.exec(result.code)
|
|
875
|
-
: null;
|
|
876
|
-
if (m) css += `${JSON.parse(m[1]) as string}\n`;
|
|
877
|
-
} catch {
|
|
878
|
-
// skip a stylesheet whose CSS can't be extracted; inline what we can
|
|
879
|
-
}
|
|
880
|
-
}
|
|
881
|
-
return css;
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
/** The host to show in a clickable URL: a wildcard bind (`0.0.0.0`/`::`/empty) is
|
|
885
|
-
* not universally openable, so present `localhost` for those; otherwise the host. */
|
|
886
|
-
function displayHost(host: string): string {
|
|
887
|
-
return host === '0.0.0.0' || host === '::' || host === '' ? 'localhost' : host;
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
/** Whether the bind host exposes the server beyond loopback (worth flagging to the user). */
|
|
891
|
-
function isExposedHost(host: string): boolean {
|
|
892
|
-
return host !== '127.0.0.1' && host !== 'localhost' && host !== '::1';
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
export async function dev(opts: ToilCommandOptions = {}): Promise<ViteDevServer> {
|
|
896
|
-
const cfg = await loadConfig(opts);
|
|
897
|
-
// Server first: build it (regenerating shared/server.ts) before the client dev server starts.
|
|
898
|
-
const hasServer = fs.existsSync(path.join(cfg.root, 'toilconfig.json'));
|
|
899
|
-
if (hasServer) process.stdout.write(pc.dim(' building the server (toilscript)…') + '\n');
|
|
900
|
-
// Compile emails/*.tsx -> generated server module BEFORE toilscript builds it in.
|
|
901
|
-
const authOnDev = serverAuthEnabled(cfg.root, cfg.auth);
|
|
902
|
-
await renderEmails(cfg, authOnDev);
|
|
903
|
-
// Built-in auth: (re)generate `.toil/authlib/_auth_emails.ts` (defaults + any emails/auth-*.tsx
|
|
904
|
-
// overrides) so the injected AuthController's `AuthEmail.*` senders compile in via --lib.
|
|
905
|
-
await renderAuthEmails(cfg, authOnDev);
|
|
906
|
-
// Generate the client codegen first so the SSR slots pre-pass can load the route graph, then
|
|
907
|
-
// emit the server-importable `<server>/_ssr/<name>.slots.ts` BEFORE the server build so its
|
|
908
|
-
// `render` can import them. Dev reuses the prior build's shell (or the template) for the HASH;
|
|
909
|
-
// `dispatchRender` checks coherence against the same `.slots`, so a hash drift surfaces as the
|
|
910
|
-
// documented fail-safe 500 until the next full `build`. A no-op without an `ssr = true` route.
|
|
911
|
-
generate(cfg);
|
|
912
|
-
if (hasServer) await extractServerSlots(cfg);
|
|
913
|
-
await buildServer(cfg.root, cfg.auth);
|
|
914
|
-
if (hasServer) process.stdout.write(pc.green(' ✓ ') + pc.dim('server built') + '\n');
|
|
915
|
-
|
|
916
|
-
if (!hasServer) {
|
|
917
|
-
const server = await createServer(await createViteConfig(cfg));
|
|
918
|
-
await server.listen();
|
|
919
|
-
server.printUrls();
|
|
920
|
-
printEmailsUrl(cfg, server.resolvedUrls?.local?.[0]);
|
|
921
|
-
installDevShutdown(() => server.close());
|
|
922
|
-
return server;
|
|
923
|
-
}
|
|
924
|
-
|
|
925
|
-
// Vite moves to an internal loopback port; the WASM dev server takes the public one.
|
|
926
|
-
const vitePort = await freeLoopbackPort();
|
|
927
|
-
const viteConfig = mergeConfig(await createViteConfig(cfg), {
|
|
928
|
-
server: { port: vitePort, host: '127.0.0.1', strictPort: true },
|
|
929
|
-
});
|
|
930
|
-
const server = await createServer(viteConfig);
|
|
931
|
-
await server.listen();
|
|
932
|
-
|
|
933
|
-
// Edge SSR in dev: render each `ssr = true` route against the LIVE (Vite-
|
|
934
|
-
// transformed) dev shell into a template-with-holes, so the dev server can
|
|
935
|
-
// splice the guest `render` values into it and serve real server-rendered
|
|
936
|
-
// HTML (the prod-edge path), which then hydrates in place. Extracted once at
|
|
937
|
-
// startup; a route's MARKUP change needs a dev restart to re-extract, but its
|
|
938
|
-
// per-request VALUES are always live via `render`. Best-effort: on failure the
|
|
939
|
-
// routes simply client-render as before.
|
|
940
|
-
let ssrTemplates: DevSsrTemplate[] = [];
|
|
941
|
-
try {
|
|
942
|
-
const rawIndex = fs.readFileSync(path.join(cfg.toilDir, 'index.html'), 'utf8');
|
|
943
|
-
let devShell = await server.transformIndexHtml('/', rawIndex);
|
|
944
|
-
// In dev, Vite injects JS-imported CSS at RUNTIME (after the entry script runs), so a
|
|
945
|
-
// server-rendered document would paint unstyled until then (a FOUC). INLINE the entry's CSS
|
|
946
|
-
// into the SSR shell so the first paint is styled with no extra request (a `<link>` would
|
|
947
|
-
// render-block the paint while Vite cold-serves the CSS, a visible blank beat). Prod bakes the
|
|
948
|
-
// real `<link>` into the built shell; the client drops this <style> after hydration so Vite's
|
|
949
|
-
// HMR-managed styles take over (see routing/mount.tsx).
|
|
950
|
-
const devCss = await collectDevCss(server);
|
|
951
|
-
if (devCss.trim().length > 0) {
|
|
952
|
-
const tag = `<style data-toil-dev-ssr>${devCss}</style>`;
|
|
953
|
-
devShell = devShell.includes('</head>')
|
|
954
|
-
? devShell.replace('</head>', `${tag}</head>`)
|
|
955
|
-
: tag + devShell;
|
|
956
|
-
}
|
|
957
|
-
ssrTemplates = await extractDevSsrTemplates(cfg, devShell);
|
|
958
|
-
if (ssrTemplates.length > 0) {
|
|
959
|
-
process.stdout.write(
|
|
960
|
-
pc.green(' ✓ ') +
|
|
961
|
-
pc.dim(`edge SSR: ${String(ssrTemplates.length)} route(s) server-rendered`) +
|
|
962
|
-
'\n',
|
|
963
|
-
);
|
|
964
|
-
}
|
|
965
|
-
} catch (e) {
|
|
966
|
-
process.stdout.write(
|
|
967
|
-
pc.yellow(' ! ') + pc.dim(`SSR dev extraction skipped: ${String(e)}`) + '\n',
|
|
968
|
-
);
|
|
969
|
-
}
|
|
970
|
-
|
|
971
|
-
const { startDevServer } = await import('toiljs/devserver');
|
|
972
|
-
const front = await startDevServer({
|
|
973
|
-
root: cfg.root,
|
|
974
|
-
port: cfg.port,
|
|
975
|
-
host: cfg.host,
|
|
976
|
-
wasmFile: serverWasmFile(cfg.root),
|
|
977
|
-
// The daemon (cold) emulator drives `release-cold.wasm` per `nodeMode`; absent for a
|
|
978
|
-
// project with no `@daemon` (the cold artifact never gets built, so the host stays idle).
|
|
979
|
-
coldWasmFile: serverArtifacts(cfg.root).cold,
|
|
980
|
-
// The stream router serves `@stream`-route WebSocket upgrades from `release-stream.wasm` per
|
|
981
|
-
// `nodeMode`; the path points at the (maybe-not-yet-built) stream artifact, mtime-reloaded so
|
|
982
|
-
// a `@stream` build activates it, and harmless for a project with no `@stream` (no routes).
|
|
983
|
-
streamWasmFile: serverArtifacts(cfg.root).stream,
|
|
984
|
-
nodeMode: cfg.nodeMode,
|
|
985
|
-
daemon: cfg.daemon,
|
|
986
|
-
vite: { host: '127.0.0.1', port: vitePort },
|
|
987
|
-
email: cfg.email ?? undefined,
|
|
988
|
-
ssrTemplates,
|
|
989
|
-
});
|
|
990
|
-
server.httpServer?.once('close', () => {
|
|
991
|
-
void front.close();
|
|
992
|
-
});
|
|
993
|
-
const shownHost = displayHost(front.host);
|
|
994
|
-
process.stdout.write(
|
|
995
|
-
'\n ' +
|
|
996
|
-
pc.green('➜') +
|
|
997
|
-
' ' +
|
|
998
|
-
pc.bold('Local') +
|
|
999
|
-
': ' +
|
|
1000
|
-
pc.cyan(`http://${shownHost}:${pc.bold(String(front.port))}/`) +
|
|
1001
|
-
pc.dim(' (wasm server + vite)') +
|
|
1002
|
-
(isExposedHost(front.host)
|
|
1003
|
-
? pc.yellow(`\n ⚠ exposed on ${front.host}:${String(front.port)} (not just loopback)`)
|
|
1004
|
-
: '') +
|
|
1005
|
-
'\n',
|
|
1006
|
-
);
|
|
1007
|
-
printEmailsUrl(cfg, `http://${shownHost}:${String(front.port)}/`);
|
|
1008
|
-
|
|
1009
|
-
// Rebuild the server on server-file changes; Vite HMRs the regenerated shared/server.ts
|
|
1010
|
-
// and the dev server hot-swaps the recompiled wasm module.
|
|
1011
|
-
watchServer(cfg, server.watcher);
|
|
1012
|
-
installDevShutdown(async () => {
|
|
1013
|
-
await front.close();
|
|
1014
|
-
await server.close();
|
|
1015
|
-
});
|
|
1016
|
-
return server;
|
|
1017
|
-
}
|
|
1018
|
-
|
|
1019
|
-
/** Produces an optimized production SPA bundle in the configured `outDir`. With `serverOnly`,
|
|
1020
|
-
* builds just the server (regenerates `shared/server.ts` + the wasm) and skips the client. */
|
|
1021
|
-
export async function build(opts: ToilCommandOptions = {}): Promise<void> {
|
|
1022
|
-
const cfg = await loadConfig(opts);
|
|
1023
|
-
// The server is always built first so the client's generated `shared/server.ts` is current.
|
|
1024
|
-
// toilscript is quiet on success, so announce the step explicitly (otherwise it looks skipped).
|
|
1025
|
-
// For `serverOnly` the CLI narrates the step, so stay quiet here to avoid doubling up.
|
|
1026
|
-
const hasServer = fs.existsSync(path.join(cfg.root, 'toilconfig.json'));
|
|
1027
|
-
if (hasServer && !opts.serverOnly)
|
|
1028
|
-
process.stdout.write(pc.dim(' building the server (toilscript)…') + '\n');
|
|
1029
|
-
// Compile emails/*.tsx -> generated server module BEFORE toilscript builds it in.
|
|
1030
|
-
const authOnBuild = serverAuthEnabled(cfg.root, cfg.auth);
|
|
1031
|
-
await renderEmails(cfg, authOnBuild);
|
|
1032
|
-
// Built-in auth: (re)generate `.toil/authlib/_auth_emails.ts` (defaults + any emails/auth-*.tsx
|
|
1033
|
-
// overrides) so the injected AuthController's `AuthEmail.*` senders compile in via --lib.
|
|
1034
|
-
await renderAuthEmails(cfg, authOnBuild);
|
|
1035
|
-
// Generate the client codegen (`.toil/globals.ts`, `.toil/index.html`, …) NOW — before the
|
|
1036
|
-
// server build — so the SSR slots pre-pass below can load the route/layout module graph and
|
|
1037
|
-
// render the opted-in routes.
|
|
1038
|
-
generate(cfg);
|
|
1039
|
-
// SSR slots PRE-PASS: emit the server-importable `<server>/_ssr/<name>.slots.ts` (the `Slot`
|
|
1040
|
-
// enum + `HASH`) the guest `render` imports, so toilscript can compile it. This is what makes a
|
|
1041
|
-
// CLEAN build work with zero hand-maintained slots: the modules are generated here, before the
|
|
1042
|
-
// server compiles. (The `HASH` is finalized by the post-Vite `extractTemplates` below, which
|
|
1043
|
-
// recompiles the server only if it rotated.) A no-op for a project with no `ssr = true` route.
|
|
1044
|
-
const priorServerSlots = hasServer ? await extractServerSlots(cfg) : new Map<string, string>();
|
|
1045
|
-
await buildServer(cfg.root, cfg.auth);
|
|
1046
|
-
if (opts.serverOnly) return;
|
|
1047
|
-
if (hasServer)
|
|
1048
|
-
process.stdout.write(
|
|
1049
|
-
pc.green(' ✓ ') + pc.dim('server built; building the client (vite)…') + '\n',
|
|
1050
|
-
);
|
|
1051
|
-
// Keep dev-only tooling (the dev toolbar + error overlay mounted in `mount`) OUT of the production
|
|
1052
|
-
// bundle. It lives behind `import.meta.env.DEV`, which Vite derives from NODE_ENV - and with NODE_ENV
|
|
1053
|
-
// unset a `vite build` here still resolves to a DEV build (even with mode:'production'), leaving that
|
|
1054
|
-
// branch live so the devtools ship in `build`. Force NODE_ENV=production for the client build so the
|
|
1055
|
-
// branch is dead-code-eliminated, then restore NODE_ENV so a later in-process `dev()` (which must
|
|
1056
|
-
// stay a development build, devtools on) is unaffected. `createViteConfig` is shared with `dev`.
|
|
1057
|
-
const prevNodeEnv = process.env.NODE_ENV;
|
|
1058
|
-
process.env.NODE_ENV = 'production';
|
|
1059
|
-
try {
|
|
1060
|
-
await viteBuild(mergeConfig(await createViteConfig(cfg), { mode: 'production' }));
|
|
1061
|
-
} finally {
|
|
1062
|
-
if (prevNodeEnv === undefined) delete process.env.NODE_ENV;
|
|
1063
|
-
else process.env.NODE_ENV = prevNodeEnv;
|
|
1064
|
-
}
|
|
1065
|
-
// SSG: bake per-URL HTML + sitemap for dynamic routes that opt in via `generateStaticParams`.
|
|
1066
|
-
await prerenderStaticParams(cfg);
|
|
1067
|
-
// Edge SSR: render `export const ssr = true` routes to template-with-holes
|
|
1068
|
-
// (`_ssr/*.tmpl|slots` + the guest `Slot` module), copied into the edge host
|
|
1069
|
-
// bundle. This also rewrites the server-importable slots module against the REAL built shell
|
|
1070
|
-
// (authoritative `HASH`). No-op when no route opts in.
|
|
1071
|
-
const ssr = await extractTemplates(cfg, 'edge', priorServerSlots);
|
|
1072
|
-
// If the authoritative `HASH` (or `Slot` ids) rotated since the pre-pass the server was
|
|
1073
|
-
// compiled against, recompile the server ONCE so the guest bakes the deployed hash; otherwise
|
|
1074
|
-
// the host rejects the response as a deploy skew. The common case (an unchanged rebuild) reuses
|
|
1075
|
-
// the prior shell in the pre-pass, so the hashes already match and this is skipped.
|
|
1076
|
-
if (ssr.serverSlotsChanged) {
|
|
1077
|
-
process.stdout.write(
|
|
1078
|
-
pc.dim(' SSR template changed; recompiling the server with the new hash…') + '\n',
|
|
1079
|
-
);
|
|
1080
|
-
await buildServer(cfg.root, cfg.auth);
|
|
1081
|
-
}
|
|
1082
|
-
}
|
|
1083
|
-
|
|
1084
|
-
/**
|
|
1085
|
-
* Self-hosts the built app over the high-performance hyper-express backend (uWebSockets.js).
|
|
1086
|
-
* Server projects use the built wasm + SSR templates before the SPA fallback; client-only projects
|
|
1087
|
-
* use the static backend. Requires a prior `build`. Returns the running backend.
|
|
1088
|
-
*/
|
|
1089
|
-
export async function start(opts: ToilCommandOptions = {}): Promise<RunningBackend> {
|
|
1090
|
-
const cfg = await loadConfig(opts);
|
|
1091
|
-
const outDir = path.resolve(cfg.root, cfg.outDir);
|
|
1092
|
-
if (!fs.existsSync(path.join(outDir, 'index.html'))) {
|
|
1093
|
-
throw new Error(`No build found in ${outDir}. Run \`toiljs build\` first.`);
|
|
1094
|
-
}
|
|
1095
|
-
const wasmFile = serverWasmFile(cfg.root);
|
|
1096
|
-
const { startBuiltServer } = await import('toiljs/devserver');
|
|
1097
|
-
const artifacts = serverArtifacts(cfg.root);
|
|
1098
|
-
return startBuiltServer({
|
|
1099
|
-
root: cfg.root,
|
|
1100
|
-
staticRoot: outDir,
|
|
1101
|
-
wasmFile: fs.existsSync(wasmFile) ? wasmFile : undefined,
|
|
1102
|
-
coldWasmFile: artifacts.cold,
|
|
1103
|
-
nodeMode: cfg.nodeMode,
|
|
1104
|
-
daemon: cfg.daemon,
|
|
1105
|
-
threads: opts.threads ?? cfg.threads,
|
|
1106
|
-
port: cfg.port,
|
|
1107
|
-
// `cfg.host` already folds in the `--host` flag, `HOST` env, and `client.host`
|
|
1108
|
-
// config (CLI > env > config > 127.0.0.1); see loadConfig.
|
|
1109
|
-
host: cfg.host,
|
|
1110
|
-
email: cfg.email ?? undefined,
|
|
1111
|
-
});
|
|
1112
|
-
}
|
|
1113
|
-
|
|
1114
|
-
export { defineConfig, loadConfig, AiProvider } from './config.js';
|
|
1115
|
-
export { scanRoutes } from './routes.js';
|
|
1116
|
-
export type { ScannedRoute } from './routes.js';
|
|
1117
|
-
export { TOIL_ENV_DTS, TOIL_SERVER_ENV_DTS } from './generate.js';
|
|
1118
|
-
export { AI_HELPERS, AI_HELPER_IDS, aiHelperFiles, TOIL_DOCS } from './docs.js';
|
|
1119
|
-
export type { AiHelper } from './docs.js';
|
|
1120
|
-
export type {
|
|
1121
|
-
ToilConfig,
|
|
1122
|
-
ResolvedToilConfig,
|
|
1123
|
-
ClientConfig,
|
|
1124
|
-
ServerConfig,
|
|
1125
|
-
DevtoolsConfig,
|
|
1126
|
-
DevtoolsAiConfig,
|
|
1127
|
-
} from './config.js';
|
|
1128
|
-
export type { RunningBackend, BackendOptions } from 'toiljs/backend';
|
|
1129
|
-
|
|
1130
|
-
// --- @stream client-surface emission ---------------------------------------------------------------
|
|
1131
|
-
// The stream compile pass runs with `withRpc:false`, so toilscript never emits `Server.Stream` into
|
|
1132
|
-
// `shared/server.ts`. We append it after the request pass by reading the compiled stream artifact's
|
|
1133
|
-
// `toilstream.catalog`. Self-contained (the compiler tsconfig does not include the devserver walker).
|
|
1134
|
-
|
|
1135
|
-
/** A LEB128 unsigned int from `buf` at `pos`; `[value, nextPos]`. Throws on overrun. */
|
|
1136
|
-
function lebU(buf: Buffer, pos: number): [number, number] {
|
|
1137
|
-
let result = 0;
|
|
1138
|
-
let shift = 0;
|
|
1139
|
-
let p = pos;
|
|
1140
|
-
for (;;) {
|
|
1141
|
-
if (p >= buf.length) throw new RangeError('leb128 past end');
|
|
1142
|
-
const b = buf[p++] as number;
|
|
1143
|
-
result |= (b & 0x7f) << shift;
|
|
1144
|
-
if ((b & 0x80) === 0) break;
|
|
1145
|
-
shift += 7;
|
|
1146
|
-
if (shift > 35) throw new RangeError('leb128 too long');
|
|
1147
|
-
}
|
|
1148
|
-
return [result >>> 0, p];
|
|
1149
|
-
}
|
|
1150
|
-
|
|
1151
|
-
/** The bytes of the named wasm custom section, or `null` if absent/truncated. */
|
|
1152
|
-
function customSectionBytes(wasm: Buffer, want: string): Buffer | null {
|
|
1153
|
-
if (wasm.length < 8 || wasm[0] !== 0x00 || wasm[1] !== 0x61 || wasm[2] !== 0x73 || wasm[3] !== 0x6d)
|
|
1154
|
-
return null;
|
|
1155
|
-
let pos = 8;
|
|
1156
|
-
try {
|
|
1157
|
-
while (pos < wasm.length) {
|
|
1158
|
-
const id = wasm[pos++] as number;
|
|
1159
|
-
let size: number;
|
|
1160
|
-
[size, pos] = lebU(wasm, pos);
|
|
1161
|
-
const end = pos + size;
|
|
1162
|
-
if (end > wasm.length || end < pos) return null;
|
|
1163
|
-
if (id === 0) {
|
|
1164
|
-
const [nameLen, namePos] = lebU(wasm, pos);
|
|
1165
|
-
if (
|
|
1166
|
-
namePos + nameLen <= end &&
|
|
1167
|
-
wasm.toString('latin1', namePos, namePos + nameLen) === want
|
|
1168
|
-
)
|
|
1169
|
-
return wasm.subarray(namePos + nameLen, end);
|
|
1170
|
-
}
|
|
1171
|
-
pos = end;
|
|
1172
|
-
}
|
|
1173
|
-
} catch {
|
|
1174
|
-
return null;
|
|
1175
|
-
}
|
|
1176
|
-
return null;
|
|
1177
|
-
}
|
|
1178
|
-
|
|
1179
|
-
/** One `@stream` class from `toilstream.catalog`: the client key (class name) + its mount route. */
|
|
1180
|
-
interface CatalogStream {
|
|
1181
|
-
name: string;
|
|
1182
|
-
route: string;
|
|
1183
|
-
}
|
|
1184
|
-
|
|
1185
|
-
/** Parse `toilstream.catalog` (doc 08 3.1; all little-endian) into `{ name, route }[]`, bounds-checked:
|
|
1186
|
-
* a short read mid-record yields the cleanly decoded prefix. */
|
|
1187
|
-
function readStreamCatalog(wasm: Buffer): CatalogStream[] {
|
|
1188
|
-
const sec = customSectionBytes(wasm, 'toilstream.catalog');
|
|
1189
|
-
if (sec === null) return [];
|
|
1190
|
-
const out: CatalogStream[] = [];
|
|
1191
|
-
let o = 0;
|
|
1192
|
-
const need = (n: number): boolean => o + n <= sec.length;
|
|
1193
|
-
const u8 = (): number => {
|
|
1194
|
-
const v = sec.readUInt8(o);
|
|
1195
|
-
o += 1;
|
|
1196
|
-
return v;
|
|
1197
|
-
};
|
|
1198
|
-
const u16 = (): number => {
|
|
1199
|
-
const v = sec.readUInt16LE(o);
|
|
1200
|
-
o += 2;
|
|
1201
|
-
return v;
|
|
1202
|
-
};
|
|
1203
|
-
const u32 = (): number => {
|
|
1204
|
-
const v = sec.readUInt32LE(o);
|
|
1205
|
-
o += 4;
|
|
1206
|
-
return v;
|
|
1207
|
-
};
|
|
1208
|
-
const str = (): string => {
|
|
1209
|
-
const len = u32();
|
|
1210
|
-
if (!need(len)) {
|
|
1211
|
-
o = sec.length + 1; // overrun: stop the loop on the next bounds check
|
|
1212
|
-
return '';
|
|
1213
|
-
}
|
|
1214
|
-
const s = sec.toString('utf8', o, o + len);
|
|
1215
|
-
o += len;
|
|
1216
|
-
return s;
|
|
1217
|
-
};
|
|
1218
|
-
try {
|
|
1219
|
-
if (!need(4)) return out;
|
|
1220
|
-
u16(); // format_version
|
|
1221
|
-
const n = u16();
|
|
1222
|
-
for (let i = 0; i < n; i++) {
|
|
1223
|
-
if (!need(8)) break;
|
|
1224
|
-
const name = str();
|
|
1225
|
-
const route = str();
|
|
1226
|
-
if (o > sec.length || !need(21)) break; // the per-record tail is 3*u8 + 4*u32 + u16 = 21
|
|
1227
|
-
u8(); // hook_presence_bitmask
|
|
1228
|
-
u8(); // declared_scope
|
|
1229
|
-
u8(); // message_mode
|
|
1230
|
-
u32(); // max_frame_bytes
|
|
1231
|
-
u32(); // ingress_ring_bytes
|
|
1232
|
-
u32(); // message_value_data_id
|
|
1233
|
-
u32(); // message_schema_version
|
|
1234
|
-
u16(); // stream_index
|
|
1235
|
-
if (name.length > 0 && route.length > 0) out.push({ name, route });
|
|
1236
|
-
}
|
|
1237
|
-
} catch {
|
|
1238
|
-
/* truncated section: return the decoded prefix */
|
|
1239
|
-
}
|
|
1240
|
-
return out;
|
|
1241
|
-
}
|
|
1242
|
-
|
|
1243
|
-
/** One `@stream` class from a source scan: its client key (the class name) and mount route. */
|
|
1244
|
-
interface SourceStream {
|
|
1245
|
-
className: string;
|
|
1246
|
-
route: string;
|
|
1247
|
-
}
|
|
1248
|
-
|
|
1249
|
-
/** Scan the `@stream` tier source files for their classes. The catalog carries only the declared route
|
|
1250
|
-
* name; the typed client wants the CLASS name (`Server.Stream.Echo`), which only the source has.
|
|
1251
|
-
* Best-effort regex mirroring toilscript's streamRoute; a class the scan misses falls back to its
|
|
1252
|
-
* catalog declared name. */
|
|
1253
|
-
function scanStreamSource(root: string, files: string[]): SourceStream[] {
|
|
1254
|
-
const out: SourceStream[] = [];
|
|
1255
|
-
const re =
|
|
1256
|
-
/@stream\b\s*(\([^)]*\))?\s*(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/g;
|
|
1257
|
-
for (const rel of files) {
|
|
1258
|
-
let src: string;
|
|
1259
|
-
try {
|
|
1260
|
-
src = fs.readFileSync(path.isAbsolute(rel) ? rel : path.join(root, rel), 'utf8');
|
|
1261
|
-
} catch {
|
|
1262
|
-
continue;
|
|
1263
|
-
}
|
|
1264
|
-
let m: RegExpExecArray | null;
|
|
1265
|
-
while ((m = re.exec(src)) !== null) {
|
|
1266
|
-
const className = m[2];
|
|
1267
|
-
if (className === undefined) continue;
|
|
1268
|
-
const args = m[1] ?? '';
|
|
1269
|
-
// The declared name: `@stream('x')` / `@stream({ name: 'x' })`, else the class name.
|
|
1270
|
-
let declared = className;
|
|
1271
|
-
const strArg = /^\(\s*['"]([^'"]+)['"]/.exec(args);
|
|
1272
|
-
const nameProp = /\bname\s*:\s*['"]([^'"]+)['"]/.exec(args);
|
|
1273
|
-
if (strArg?.[1] !== undefined) declared = strArg[1];
|
|
1274
|
-
else if (nameProp?.[1] !== undefined) declared = nameProp[1];
|
|
1275
|
-
out.push({ className, route: '/' + declared });
|
|
1276
|
-
}
|
|
1277
|
-
}
|
|
1278
|
-
return out;
|
|
1279
|
-
}
|
|
1280
|
-
|
|
1281
|
-
/** Append the typed `Server.Stream` client surface to `shared/server.ts`. The compiled stream catalog
|
|
1282
|
-
* is authoritative for WHICH streams exist (their routes); a source scan supplies the class name (the
|
|
1283
|
-
* client key, `Server.Stream.Echo`). Emits the `__toilStream` runtime attach plus - unless
|
|
1284
|
-
* shared/server.ts already declares `Server` (a @rest surface toilscript owns) - the
|
|
1285
|
-
* `declare global { const Server }` ambient type. The origin stays runtime-resolved. Idempotent. */
|
|
1286
|
-
function emitStreamClientSurface(
|
|
1287
|
-
root: string,
|
|
1288
|
-
streamWasmPath: string | undefined,
|
|
1289
|
-
streamFiles: string[],
|
|
1290
|
-
): void {
|
|
1291
|
-
if (streamWasmPath === undefined) return;
|
|
1292
|
-
let wasm: Buffer;
|
|
1293
|
-
try {
|
|
1294
|
-
const abs = path.isAbsolute(streamWasmPath)
|
|
1295
|
-
? streamWasmPath
|
|
1296
|
-
: path.join(root, streamWasmPath);
|
|
1297
|
-
wasm = fs.readFileSync(abs);
|
|
1298
|
-
} catch {
|
|
1299
|
-
return; // no stream artifact: nothing to wire
|
|
1300
|
-
}
|
|
1301
|
-
const catalog = readStreamCatalog(wasm);
|
|
1302
|
-
if (catalog.length === 0) return;
|
|
1303
|
-
|
|
1304
|
-
const classByRoute = new Map(scanStreamSource(root, streamFiles).map((s) => [s.route, s.className]));
|
|
1305
|
-
const streams = catalog.map((c) => ({ key: classByRoute.get(c.route) ?? c.name, route: c.route }));
|
|
1306
|
-
|
|
1307
|
-
const rpcModule = path.join(root, 'shared', 'server.ts');
|
|
1308
|
-
let existing = '';
|
|
1309
|
-
try {
|
|
1310
|
-
existing = fs.readFileSync(rpcModule, 'utf8');
|
|
1311
|
-
} catch {
|
|
1312
|
-
/* absent (a stream-only project, or no @rest surface): create it */
|
|
1313
|
-
}
|
|
1314
|
-
if (existing.includes('__toilStream')) {
|
|
1315
|
-
// toilscript already emitted the Server.Stream surface + ambient type (via --rpcSurfaceFiles),
|
|
1316
|
-
// but imports only toiljs/io, so it never evaluates the client proxy. Prepend a bare side-effect
|
|
1317
|
-
// import of toiljs/client so rpc.ts attaches `globalThis.Server`. (globalThis.Server is also set
|
|
1318
|
-
// unconditionally by .toil/globals.ts; this is belt-and-suspenders, and a bare side-effect import
|
|
1319
|
-
// is never tree-shaken, even under a future `sideEffects: false`.) Skip if it is already imported.
|
|
1320
|
-
if (!existing.includes('toiljs/client')) {
|
|
1321
|
-
fs.mkdirSync(path.dirname(rpcModule), { recursive: true });
|
|
1322
|
-
fs.writeFileSync(rpcModule, 'import "toiljs/client";\n' + existing);
|
|
1323
|
-
}
|
|
1324
|
-
return;
|
|
1325
|
-
}
|
|
1326
|
-
|
|
1327
|
-
const routes = streams
|
|
1328
|
-
.map((s) => ` ${JSON.stringify(s.key)}: ${JSON.stringify(s.route)},`)
|
|
1329
|
-
.join('\n');
|
|
1330
|
-
const attach =
|
|
1331
|
-
'if (typeof globalThis !== "undefined") {\n' +
|
|
1332
|
-
` (globalThis as Record<string, unknown>).__toilStream = __mkStream({\n${routes}\n });\n` +
|
|
1333
|
-
'}\n';
|
|
1334
|
-
|
|
1335
|
-
// The ambient `Server.Stream` type - only when toilscript has not already declared `Server` (a
|
|
1336
|
-
// @rest surface). For a @rest project, teaching toilscript the @stream surface is the follow-up;
|
|
1337
|
-
// the runtime attach above works regardless of the type.
|
|
1338
|
-
// Strip comments first so a commented-out `declare global { const Server }` does not suppress the
|
|
1339
|
-
// real type emit.
|
|
1340
|
-
const uncommented = existing.replace(/\/\/[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, '');
|
|
1341
|
-
const declareType = !/declare global[\s\S]*?const Server\b/.test(uncommented);
|
|
1342
|
-
const typeBlock = declareType
|
|
1343
|
-
? 'declare global {\n' +
|
|
1344
|
-
' /** The client-callable server surface (generated from the @stream catalog). */\n' +
|
|
1345
|
-
' const Server: {\n' +
|
|
1346
|
-
' readonly Stream: {\n' +
|
|
1347
|
-
streams
|
|
1348
|
-
.map((s) => ` readonly ${s.key}: import('toiljs/client').StreamConnectable;`)
|
|
1349
|
-
.join('\n') +
|
|
1350
|
-
'\n };\n };\n}\n\nexport {};\n'
|
|
1351
|
-
: '';
|
|
1352
|
-
|
|
1353
|
-
const out =
|
|
1354
|
-
'import { makeStreamClient as __mkStream } from "toiljs/client";\n' +
|
|
1355
|
-
existing +
|
|
1356
|
-
'\n// --- @stream client surface (auto-generated from toilstream.catalog) ---\n' +
|
|
1357
|
-
attach +
|
|
1358
|
-
(typeBlock.length > 0 ? '\n' + typeBlock : '');
|
|
1359
|
-
|
|
1360
|
-
fs.mkdirSync(path.dirname(rpcModule), { recursive: true });
|
|
1361
|
-
fs.writeFileSync(rpcModule, out);
|
|
1362
|
-
}
|