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.
Files changed (326) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +16 -7
  3. package/TYPESCRIPT_LAW.md +1946 -2073
  4. package/build/backend/.tsbuildinfo +1 -1
  5. package/build/cli/.tsbuildinfo +1 -1
  6. package/build/cli/index.js +2375 -258
  7. package/build/client/.tsbuildinfo +1 -1
  8. package/build/client/dev/devtools.js +1 -1
  9. package/build/client/dev/error-overlay.js +1 -1
  10. package/build/client/routing/mount.js +1 -1
  11. package/build/compiler/.tsbuildinfo +1 -1
  12. package/build/compiler/auth-emails.js +7 -3
  13. package/build/compiler/emails.js +1 -1
  14. package/build/compiler/index.d.ts +1 -0
  15. package/build/compiler/index.js +25 -4
  16. package/build/compiler/pages.js +2 -3
  17. package/build/compiler/prerender.d.ts +3 -8
  18. package/build/compiler/prerender.js +88 -127
  19. package/build/compiler/ssg.js +3 -4
  20. package/build/compiler/template-build.js +3 -4
  21. package/build/compiler/toil-docs.generated.js +6 -5
  22. package/build/compiler/vite.js +3 -6
  23. package/build/devserver/.tsbuildinfo +1 -1
  24. package/build/devserver/db/database.js +7 -3
  25. package/build/devserver/db/types.js +1 -1
  26. package/build/devserver/production.js +4 -3
  27. package/build/devserver/runtime/crypto.d.ts +1 -1
  28. package/build/io/.tsbuildinfo +1 -1
  29. package/build/logger/.tsbuildinfo +1 -1
  30. package/build/shared/.tsbuildinfo +1 -1
  31. package/examples/basic/client/routes/analytics.tsx +7 -5
  32. package/examples/basic/server/tsconfig.json +1 -3
  33. package/package.json +45 -44
  34. package/presets/no-uint8array-tostring.js +120 -153
  35. package/presets/oxlint.d.ts +5 -0
  36. package/presets/oxlint.js +224 -0
  37. package/server/runtime/tsconfig.json +1 -3
  38. package/tsconfig.json +2 -11
  39. package/.github/ISSUE_TEMPLATE/bug_report.md +0 -38
  40. package/.github/ISSUE_TEMPLATE/bug_report.yml +0 -90
  41. package/.github/ISSUE_TEMPLATE/config.yml +0 -8
  42. package/.github/ISSUE_TEMPLATE/feature_request.md +0 -20
  43. package/.github/PULL_REQUEST_TEMPLATE.md +0 -43
  44. package/.github/changelog-config.json +0 -45
  45. package/.github/dependabot.yml +0 -27
  46. package/.github/workflows/ci.yml +0 -222
  47. package/.vscode/settings.json +0 -10
  48. package/docs/README.md +0 -107
  49. package/docs/auth/README.md +0 -104
  50. package/docs/auth/configuration.md +0 -105
  51. package/docs/auth/emails.md +0 -92
  52. package/docs/auth/extending.md +0 -202
  53. package/docs/auth/how-it-works.md +0 -138
  54. package/docs/auth/usage.md +0 -188
  55. package/docs/backend/README.md +0 -143
  56. package/docs/backend/data.md +0 -351
  57. package/docs/backend/rest.md +0 -402
  58. package/docs/backend/rpc.md +0 -226
  59. package/docs/background/README.md +0 -114
  60. package/docs/background/daemons.md +0 -276
  61. package/docs/background/derive.md +0 -179
  62. package/docs/cli/README.md +0 -379
  63. package/docs/concepts/ai-guide.md +0 -132
  64. package/docs/concepts/config.md +0 -416
  65. package/docs/concepts/decorators.md +0 -127
  66. package/docs/concepts/security.md +0 -108
  67. package/docs/concepts/tiers.md +0 -166
  68. package/docs/concepts/types.md +0 -216
  69. package/docs/database/README.md +0 -143
  70. package/docs/database/capacity.md +0 -350
  71. package/docs/database/counters.md +0 -174
  72. package/docs/database/documents.md +0 -277
  73. package/docs/database/events.md +0 -307
  74. package/docs/database/membership.md +0 -246
  75. package/docs/database/setup.md +0 -155
  76. package/docs/database/unique.md +0 -216
  77. package/docs/database/views.md +0 -246
  78. package/docs/frontend/README.md +0 -104
  79. package/docs/frontend/components.md +0 -286
  80. package/docs/frontend/data-fetching.md +0 -260
  81. package/docs/frontend/images.md +0 -148
  82. package/docs/frontend/metadata.md +0 -344
  83. package/docs/frontend/navigation.md +0 -296
  84. package/docs/frontend/rendering.md +0 -236
  85. package/docs/frontend/routing.md +0 -346
  86. package/docs/frontend/scripts.md +0 -118
  87. package/docs/frontend/search.md +0 -191
  88. package/docs/frontend/styling.md +0 -147
  89. package/docs/frontend/toil-global.md +0 -216
  90. package/docs/getting-started/README.md +0 -81
  91. package/docs/getting-started/create-project.md +0 -131
  92. package/docs/getting-started/deploy.md +0 -101
  93. package/docs/getting-started/first-app.md +0 -215
  94. package/docs/getting-started/installation.md +0 -124
  95. package/docs/getting-started/migrating.md +0 -125
  96. package/docs/getting-started/project-structure.md +0 -163
  97. package/docs/introduction/README.md +0 -66
  98. package/docs/introduction/design-principles.md +0 -81
  99. package/docs/introduction/distributed.md +0 -91
  100. package/docs/introduction/how-it-works.md +0 -121
  101. package/docs/introduction/hyperscale.md +0 -70
  102. package/docs/introduction/modern-stack.md +0 -85
  103. package/docs/introduction/vs-other-frameworks.md +0 -64
  104. package/docs/introduction/why-toil.md +0 -99
  105. package/docs/llms.txt +0 -95
  106. package/docs/realtime/README.md +0 -102
  107. package/docs/realtime/channels.md +0 -211
  108. package/docs/realtime/streams.md +0 -369
  109. package/docs/services/README.md +0 -60
  110. package/docs/services/analytics.md +0 -279
  111. package/docs/services/caching.md +0 -175
  112. package/docs/services/cookies.md +0 -235
  113. package/docs/services/crypto.md +0 -209
  114. package/docs/services/email.md +0 -289
  115. package/docs/services/environment.md +0 -141
  116. package/docs/services/ratelimit.md +0 -174
  117. package/docs/services/time.md +0 -85
  118. package/eslint.config.js +0 -57
  119. package/examples/basic/client/routes/docs/[...slug].tsx +0 -12
  120. package/presets/eslint.js +0 -88
  121. package/src/backend/index.ts +0 -204
  122. package/src/cli/configure.ts +0 -363
  123. package/src/cli/create.ts +0 -924
  124. package/src/cli/db.ts +0 -158
  125. package/src/cli/diagnostics.ts +0 -665
  126. package/src/cli/doctor.ts +0 -982
  127. package/src/cli/features.ts +0 -170
  128. package/src/cli/index.ts +0 -326
  129. package/src/cli/notify.ts +0 -120
  130. package/src/cli/proc.ts +0 -56
  131. package/src/cli/ui.ts +0 -169
  132. package/src/cli/update.ts +0 -248
  133. package/src/cli/updates.ts +0 -92
  134. package/src/cli/validate.ts +0 -31
  135. package/src/cli/version-check.ts +0 -125
  136. package/src/client/auth.ts +0 -869
  137. package/src/client/channel/channel.ts +0 -146
  138. package/src/client/components/Form.tsx +0 -65
  139. package/src/client/components/Image.tsx +0 -160
  140. package/src/client/components/Script.tsx +0 -113
  141. package/src/client/components/Slot.tsx +0 -21
  142. package/src/client/dev/devtools.tsx +0 -1089
  143. package/src/client/dev/error-overlay.tsx +0 -225
  144. package/src/client/errors.ts +0 -11
  145. package/src/client/head/head.ts +0 -182
  146. package/src/client/head/metadata.ts +0 -110
  147. package/src/client/index.ts +0 -130
  148. package/src/client/navigation/Link.tsx +0 -108
  149. package/src/client/navigation/NavLink.tsx +0 -86
  150. package/src/client/navigation/navigation.ts +0 -235
  151. package/src/client/navigation/prefetch.ts +0 -169
  152. package/src/client/navigation/scroll.ts +0 -53
  153. package/src/client/routing/Router.tsx +0 -216
  154. package/src/client/routing/action.ts +0 -122
  155. package/src/client/routing/error-boundary.tsx +0 -43
  156. package/src/client/routing/hooks.ts +0 -140
  157. package/src/client/routing/lazy.ts +0 -77
  158. package/src/client/routing/loader.ts +0 -381
  159. package/src/client/routing/match.ts +0 -47
  160. package/src/client/routing/mount.tsx +0 -90
  161. package/src/client/routing/params-context.ts +0 -10
  162. package/src/client/routing/slot-context.ts +0 -7
  163. package/src/client/rpc.ts +0 -97
  164. package/src/client/search/search.ts +0 -189
  165. package/src/client/search/use-page-search.ts +0 -73
  166. package/src/client/ssr/markers.tsx +0 -172
  167. package/src/client/stream/client.ts +0 -237
  168. package/src/client/types.ts +0 -73
  169. package/src/compiler/auth-emails.ts +0 -312
  170. package/src/compiler/config.ts +0 -369
  171. package/src/compiler/docs.ts +0 -127
  172. package/src/compiler/email-preview.ts +0 -314
  173. package/src/compiler/emails.ts +0 -404
  174. package/src/compiler/fonts.ts +0 -89
  175. package/src/compiler/generate.ts +0 -471
  176. package/src/compiler/image-blur.ts +0 -42
  177. package/src/compiler/image-report.ts +0 -88
  178. package/src/compiler/index.ts +0 -1362
  179. package/src/compiler/pages.ts +0 -49
  180. package/src/compiler/plugin.ts +0 -391
  181. package/src/compiler/prerender.ts +0 -263
  182. package/src/compiler/routes.ts +0 -170
  183. package/src/compiler/seo.ts +0 -415
  184. package/src/compiler/sri.ts +0 -144
  185. package/src/compiler/ssg.ts +0 -177
  186. package/src/compiler/ssr-codegen.ts +0 -85
  187. package/src/compiler/template-build.ts +0 -676
  188. package/src/compiler/template.ts +0 -262
  189. package/src/compiler/toil-docs.generated.ts +0 -77
  190. package/src/compiler/vite.ts +0 -245
  191. package/src/devserver/analytics/index.ts +0 -317
  192. package/src/devserver/config/dotenv.ts +0 -102
  193. package/src/devserver/config/env.ts +0 -30
  194. package/src/devserver/config/ratelimit.ts +0 -123
  195. package/src/devserver/daemon/catalog.ts +0 -120
  196. package/src/devserver/daemon/cron.ts +0 -87
  197. package/src/devserver/daemon/host.ts +0 -134
  198. package/src/devserver/daemon/index.ts +0 -369
  199. package/src/devserver/daemon/runtime.ts +0 -48
  200. package/src/devserver/db/catalog.ts +0 -109
  201. package/src/devserver/db/database.ts +0 -1834
  202. package/src/devserver/db/derives.ts +0 -121
  203. package/src/devserver/db/index.ts +0 -20
  204. package/src/devserver/db/routeKinds.ts +0 -191
  205. package/src/devserver/db/types.ts +0 -168
  206. package/src/devserver/email/caps.ts +0 -0
  207. package/src/devserver/email/config.ts +0 -141
  208. package/src/devserver/email/console.ts +0 -348
  209. package/src/devserver/email/index.ts +0 -155
  210. package/src/devserver/email/providers.ts +0 -131
  211. package/src/devserver/email/status.ts +0 -23
  212. package/src/devserver/email/validate.ts +0 -37
  213. package/src/devserver/email/wire.ts +0 -55
  214. package/src/devserver/http/cache.ts +0 -0
  215. package/src/devserver/http/envelope.ts +0 -157
  216. package/src/devserver/http/proxy.ts +0 -172
  217. package/src/devserver/http/runtime.ts +0 -387
  218. package/src/devserver/index.ts +0 -43
  219. package/src/devserver/production-ipc.ts +0 -63
  220. package/src/devserver/production-worker.ts +0 -83
  221. package/src/devserver/production.ts +0 -718
  222. package/src/devserver/runtime/crypto.ts +0 -587
  223. package/src/devserver/runtime/host.ts +0 -427
  224. package/src/devserver/runtime/module.ts +0 -442
  225. package/src/devserver/server.ts +0 -251
  226. package/src/devserver/ssr.ts +0 -208
  227. package/src/devserver/stream/catalog.ts +0 -106
  228. package/src/devserver/stream/host.ts +0 -42
  229. package/src/devserver/stream/index.ts +0 -301
  230. package/src/devserver/stream/manager.ts +0 -167
  231. package/src/devserver/stream/router.ts +0 -101
  232. package/src/devserver/stream/wire.ts +0 -58
  233. package/src/devserver/stream/ws.ts +0 -78
  234. package/src/devserver/wasm/sections.ts +0 -59
  235. package/src/devserver/wasm/surface.ts +0 -115
  236. package/src/io/FastMap.ts +0 -151
  237. package/src/io/FastSet.ts +0 -113
  238. package/src/io/codec.ts +0 -362
  239. package/src/io/index.ts +0 -10
  240. package/src/io/lengths.ts +0 -14
  241. package/src/io/types.ts +0 -19
  242. package/src/logger/index.ts +0 -22
  243. package/src/shared/index.ts +0 -46
  244. package/test/analytics-dev.test.ts +0 -113
  245. package/test/assembly/aspect-shim.ts +0 -33
  246. package/test/assembly/cookie.spec.ts +0 -303
  247. package/test/assembly/example.spec.ts +0 -22
  248. package/test/assembly/ssr.spec.ts +0 -95
  249. package/test/assembly.test.ts +0 -59
  250. package/test/auth-emails.test.ts +0 -118
  251. package/test/built-ssr.test.ts +0 -148
  252. package/test/channel.test.ts +0 -21
  253. package/test/config-host-port.test.ts +0 -59
  254. package/test/configure.test.ts +0 -94
  255. package/test/daemon-build.test.ts +0 -278
  256. package/test/daemon-catalog.test.ts +0 -349
  257. package/test/daemon-emulation.test.ts +0 -256
  258. package/test/db.test.ts +0 -0
  259. package/test/devserver-database.test.ts +0 -1102
  260. package/test/devserver-email.test.ts +0 -306
  261. package/test/devserver-pqauth.test.ts +0 -93
  262. package/test/devserver-secrets.test.ts +0 -63
  263. package/test/devserver.test.ts +0 -259
  264. package/test/doctor.test.ts +0 -298
  265. package/test/dom/Image.test.tsx +0 -144
  266. package/test/dom/Link.test.tsx +0 -67
  267. package/test/dom/NavLink.test.tsx +0 -37
  268. package/test/dom/Script.test.tsx +0 -48
  269. package/test/dom/action.test.tsx +0 -146
  270. package/test/dom/error-overlay.test.tsx +0 -44
  271. package/test/dom/loader.test.tsx +0 -121
  272. package/test/dom/navigation.test.ts +0 -59
  273. package/test/dom/revalidate.test.tsx +0 -38
  274. package/test/dom/route-head.test.tsx +0 -56
  275. package/test/dom/router-loading.test.tsx +0 -44
  276. package/test/dom/scroll.test.ts +0 -56
  277. package/test/dom/slot.test.tsx +0 -131
  278. package/test/dom/use-metadata.test.tsx +0 -58
  279. package/test/dom/view-transitions.test.tsx +0 -53
  280. package/test/dynamic-routing.test.ts +0 -132
  281. package/test/email-console.test.ts +0 -86
  282. package/test/email-preview.test.ts +0 -73
  283. package/test/emails.test.ts +0 -58
  284. package/test/errors.test.ts +0 -21
  285. package/test/example-guestbook.test.ts +0 -123
  286. package/test/features.test.ts +0 -149
  287. package/test/fixtures/bignum-wire/spec.ts +0 -25
  288. package/test/fixtures/daemon-app.ts +0 -65
  289. package/test/fixtures/stream-echo.ts +0 -26
  290. package/test/fixtures/stream-gate.ts +0 -28
  291. package/test/fixtures/stream-trap.ts +0 -18
  292. package/test/fixtures/stream-typed.ts +0 -41
  293. package/test/fonts.test.ts +0 -28
  294. package/test/global-setup.ts +0 -17
  295. package/test/head.test.ts +0 -42
  296. package/test/image-blur.test.ts +0 -50
  297. package/test/io.test.ts +0 -117
  298. package/test/metadata.test.ts +0 -40
  299. package/test/navlink.test.ts +0 -28
  300. package/test/pages.test.ts +0 -105
  301. package/test/placeholder.test.ts +0 -9
  302. package/test/pqauth-e2e.test.ts +0 -216
  303. package/test/pqauth-email.test.ts +0 -801
  304. package/test/prerender.test.ts +0 -116
  305. package/test/prettier-plugin.test.ts +0 -46
  306. package/test/routes.test.ts +0 -76
  307. package/test/rpc-bignum-wire.test.ts +0 -164
  308. package/test/rpc-dispatch.test.ts +0 -132
  309. package/test/rpc-kinds.test.ts +0 -18
  310. package/test/rpc.test.ts +0 -66
  311. package/test/search.test.ts +0 -114
  312. package/test/seo.test.ts +0 -175
  313. package/test/slot-layouts.test.ts +0 -69
  314. package/test/sri.test.ts +0 -129
  315. package/test/ssg.test.ts +0 -36
  316. package/test/ssr-hydration.test.tsx +0 -122
  317. package/test/ssr-render.test.ts +0 -197
  318. package/test/ssr-template.test.tsx +0 -482
  319. package/test/stream-emulation.test.ts +0 -479
  320. package/test/ui.test.ts +0 -40
  321. package/test/update.test.ts +0 -58
  322. package/test/validate.test.ts +0 -42
  323. package/test/version-check.test.ts +0 -130
  324. package/tests/data-parity/generated-parity.ts +0 -99
  325. package/tests/data-parity/parity.ts +0 -80
  326. package/tests/data-parity/spec.ts +0 -46
@@ -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
- }