toiljs 0.0.16 → 0.0.20

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 (100) hide show
  1. package/CHANGELOG.md +128 -0
  2. package/README.md +313 -128
  3. package/as-pect.config.js +1 -1
  4. package/build/backend/.tsbuildinfo +1 -1
  5. package/build/backend/index.d.ts +1 -0
  6. package/build/backend/index.js +20 -1
  7. package/build/cli/.tsbuildinfo +1 -1
  8. package/build/cli/index.js +1320 -697
  9. package/build/client/.tsbuildinfo +1 -1
  10. package/build/client/dev/devtools.js +42 -5
  11. package/build/client/errors.d.ts +1 -0
  12. package/build/client/errors.js +3 -0
  13. package/build/client/index.d.ts +2 -0
  14. package/build/client/index.js +2 -0
  15. package/build/client/rpc.d.ts +1 -0
  16. package/build/client/rpc.js +37 -0
  17. package/build/compiler/.tsbuildinfo +1 -1
  18. package/build/compiler/config.js +3 -1
  19. package/build/compiler/docs.js +69 -7
  20. package/build/compiler/generate.js +5 -4
  21. package/build/compiler/index.d.ts +1 -0
  22. package/build/compiler/index.js +30 -1
  23. package/build/compiler/plugin.js +80 -8
  24. package/build/compiler/seo.js +15 -1
  25. package/build/compiler/ssg.js +7 -1
  26. package/build/compiler/vite.js +25 -0
  27. package/build/io/.tsbuildinfo +1 -1
  28. package/build/io/codec.d.ts +54 -0
  29. package/build/io/codec.js +143 -0
  30. package/build/io/index.d.ts +1 -2
  31. package/build/io/index.js +1 -2
  32. package/eslint.config.js +1 -1
  33. package/examples/basic/client/routes/features/index.tsx +1 -1
  34. package/examples/basic/client/routes/io.tsx +6 -7
  35. package/examples/basic/client/routes/rest.tsx +84 -0
  36. package/examples/basic/client/routes/rpc.tsx +43 -0
  37. package/package.json +19 -7
  38. package/presets/prettier-plugin.js +51 -0
  39. package/presets/prettier.json +1 -0
  40. package/server/runtime/README.md +97 -0
  41. package/server/runtime/abort/abort.ts +27 -0
  42. package/server/runtime/env/Server.ts +61 -0
  43. package/server/runtime/envelope.ts +191 -0
  44. package/server/runtime/exports/index.ts +52 -0
  45. package/server/runtime/handlers/ToilHandler.ts +34 -0
  46. package/server/runtime/index.ts +26 -0
  47. package/server/runtime/lang/Potential.ts +5 -0
  48. package/server/runtime/memory.ts +81 -0
  49. package/server/runtime/request.ts +55 -0
  50. package/server/runtime/response.ts +86 -0
  51. package/server/runtime/rest/Rest.ts +39 -0
  52. package/server/runtime/rest/RestHandler.ts +20 -0
  53. package/server/runtime/rest/RouteContext.ts +82 -0
  54. package/server/runtime/rest/match.ts +48 -0
  55. package/server/runtime/tsconfig.json +7 -0
  56. package/src/backend/index.ts +45 -3
  57. package/src/cli/create.ts +16 -6
  58. package/src/cli/diagnostics.ts +81 -0
  59. package/src/cli/doctor.ts +384 -7
  60. package/src/cli/index.ts +11 -2
  61. package/src/client/dev/devtools.tsx +49 -4
  62. package/src/client/errors.ts +11 -0
  63. package/src/client/index.ts +2 -0
  64. package/src/client/rpc.ts +64 -0
  65. package/src/compiler/config.ts +3 -1
  66. package/src/compiler/docs.ts +69 -7
  67. package/src/compiler/generate.ts +6 -5
  68. package/src/compiler/index.ts +50 -1
  69. package/src/compiler/plugin.ts +99 -11
  70. package/src/compiler/seo.ts +23 -3
  71. package/src/compiler/ssg.ts +10 -1
  72. package/src/compiler/vite.ts +34 -0
  73. package/src/io/FastMap.ts +24 -0
  74. package/src/io/FastSet.ts +15 -1
  75. package/src/io/codec.ts +217 -0
  76. package/src/io/index.ts +1 -2
  77. package/src/io/types.ts +2 -1
  78. package/test/assembly/example.spec.ts +14 -4
  79. package/test/doctor.test.ts +65 -0
  80. package/test/errors.test.ts +21 -0
  81. package/test/io.test.ts +65 -41
  82. package/test/prettier-plugin.test.ts +46 -0
  83. package/test/rpc.test.ts +50 -0
  84. package/tests/data-parity/generated-parity.ts +99 -0
  85. package/tests/data-parity/parity.ts +80 -0
  86. package/tests/data-parity/spec.ts +46 -0
  87. package/tsconfig.json +1 -1
  88. package/tsconfig.server.json +1 -1
  89. package/build/io/BinaryReader.d.ts +0 -44
  90. package/build/io/BinaryReader.js +0 -244
  91. package/build/io/BinaryWriter.d.ts +0 -44
  92. package/build/io/BinaryWriter.js +0 -297
  93. package/build/server/release.wasm +0 -0
  94. package/build/server/release.wat +0 -9
  95. package/src/io/BinaryReader.ts +0 -340
  96. package/src/io/BinaryWriter.ts +0 -385
  97. package/src/server/index.ts +0 -10
  98. package/src/server/main.ts +0 -13
  99. package/src/server/tsconfig.json +0 -4
  100. package/toilconfig.json +0 -30
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Extracts a human-readable message from an unknown thrown value. Handy in `catch`
3
+ * blocks where the caught value is typed `unknown`. Exposed as a global `parseError`
4
+ * (no import) alongside the other toiljs globals.
5
+ *
6
+ * @param err - the caught value (an `Error`, a string, or anything else).
7
+ * @returns the `Error.message` when `err` is an `Error`, otherwise `String(err)`.
8
+ */
9
+ export function parseError(err: unknown): string {
10
+ return err instanceof Error ? err.message : String(err);
11
+ }
@@ -87,3 +87,5 @@ export { Form } from './components/Form.js';
87
87
  export type { FormProps } from './components/Form.js';
88
88
  export { Slot } from './components/Slot.js';
89
89
  export type { SlotProps } from './components/Slot.js';
90
+ export { Server } from './rpc.js';
91
+ export { parseError } from './errors.js';
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The client-callable server surface (`Server`). Its TYPED shape is generated by
3
+ * the toilscript server build into the project's `shared/server.ts`
4
+ * (`declare global { const Server }`); this module is the runtime behind it.
5
+ *
6
+ * Two surfaces live under `Server`:
7
+ * - `Server.REST.<controller>.<route>(args)` is a WORKING fetch client. The
8
+ * generated `shared/server.ts` attaches it to `globalThis.__toilRest` when
9
+ * imported; the proxy below surfaces it under `Server.REST`.
10
+ * - `Server.<service>.<method>()` (RPC) is not wired yet, so any call throws.
11
+ * The pipeline (tags -> generated types -> proxy) is in place; only the
12
+ * network dispatch is a TODO. Build the server (`npm run build:server`) to
13
+ * (re)generate the typed surface.
14
+ */
15
+
16
+ /** A recursive proxy that throws on call, used when the REST client hasn't loaded. */
17
+ function restMissingStub(path: string): unknown {
18
+ const call = (): never => {
19
+ throw new Error(
20
+ `toiljs REST: ${path}() is unavailable. The generated REST client has not loaded - ` +
21
+ `import a type from your 'shared/server' (so the client attaches), or run 'npm run build:server'.`,
22
+ );
23
+ };
24
+ return new Proxy(call, {
25
+ get(_target, prop) {
26
+ if (typeof prop === 'symbol' || prop === 'then') return undefined;
27
+ return restMissingStub(`${path}.${String(prop)}`);
28
+ },
29
+ apply() {
30
+ return call();
31
+ },
32
+ });
33
+ }
34
+
35
+ /** Builds a recursive proxy that throws on call, supporting `Server.svc.method()`. */
36
+ function rpcStub(path: string): unknown {
37
+ const call = (): never => {
38
+ throw new Error(
39
+ `toiljs RPC: ${path}() is not available yet. The client<->server transport ` +
40
+ `is not wired; this is a generated stub. Remote calls will work once transport lands.`,
41
+ );
42
+ };
43
+ return new Proxy(call, {
44
+ get(_target, prop) {
45
+ // Not thenable, and ignore symbol probes (e.g. from awaiting/inspection).
46
+ if (typeof prop === 'symbol' || prop === 'then') return undefined;
47
+ // `Server.REST` surfaces the generated fetch client attached by shared/server.ts.
48
+ if (path === 'Server' && prop === 'REST') {
49
+ const rest = (globalThis as { __toilRest?: unknown }).__toilRest;
50
+ return rest !== undefined ? rest : restMissingStub('Server.REST');
51
+ }
52
+ return rpcStub(`${path}.${prop}`);
53
+ },
54
+ apply() {
55
+ return call();
56
+ },
57
+ });
58
+ }
59
+
60
+ /**
61
+ * Runtime value for the global `Server`. Typed as `unknown` here; the real types
62
+ * come from the generated `shared/server.ts`. toiljs assigns this to `globalThis`.
63
+ */
64
+ export const Server: unknown = rpcStub('Server');
@@ -211,7 +211,9 @@ export async function loadConfig(
211
211
  transitions: client.transitions ?? false,
212
212
  devtools: client.devtools !== false,
213
213
  devtoolsAi:
214
- typeof client.devtools === 'object' && client.devtools.ai ? client.devtools.ai : null,
214
+ client.devtools != null && typeof client.devtools === 'object'
215
+ ? (client.devtools.ai ?? null)
216
+ : null,
215
217
  seo: client.seo ?? null,
216
218
  runtimePath: resolveRuntimePath(),
217
219
  vite: client.vite ?? {},
@@ -85,14 +85,17 @@ export const TOIL_DOCS: Record<string, string> = {
85
85
  '- `client/`, the app: `routes/` (file-based), `layout.tsx`, `components/`, `styles/`,',
86
86
  ' `public/`, and `toil.tsx` (the entry that calls `Toil.mount`).',
87
87
  '- `server/`, the toilscript → WASM target (`@main` entry), compiled by `toilscript`.',
88
+ ' `@data`/`@remote`/`@service` here generate the typed client `Server` API (see `server.md`).',
88
89
  '- `toil.config.ts`, configuration via `defineConfig` (`toiljs.config.ts` also works).',
89
90
  '- Generated, gitignored, do not edit: `.toil/` (working dir), `toil-env.d.ts` (ambient',
90
- ' globals), `toil-routes.d.ts` (typed routes).',
91
+ ' globals), `toil-routes.d.ts` (typed routes), `shared/server.ts` (the typed RPC module,',
92
+ ' emitted by the server build; import `@data` classes from `shared/server`).',
91
93
  '',
92
94
  '## Key ideas',
93
95
  '',
94
96
  '- `Toil` is a native global (no import): `Toil.Link`, `Toil.useRouter`, `Toil.useLoaderData`,',
95
- ' etc. The IO classes (`BinaryWriter`, `BinaryReader`, `FastMap`, `FastSet`) are globals too.',
97
+ ' etc. The IO classes (`FastMap`, `FastSet`, `DataWriter`, `DataReader`), `parseError`, and the',
98
+ ' generated `Server` RPC surface are globals too.',
96
99
  '- Scripts: `npm run dev` (HMR), `npm run build` (→ `build/client` + `build/server`),',
97
100
  ' `npm start` (self-host the build).',
98
101
  '',
@@ -161,7 +164,14 @@ export const TOIL_DOCS: Record<string, string> = {
161
164
  '- Data: `useLoaderData` (see `routing.md`)',
162
165
  '- Head: `useHead`, `useTitle`, `<Head>`, set the `<title>` / meta per route',
163
166
  '- Realtime: `useChannel`, `connectChannel` (WebSocket to the backend at `/_toil`)',
164
- '- IO globals (no `Toil.` prefix): `BinaryWriter`, `BinaryReader`, `FastMap`, `FastSet`',
167
+ '- IO globals (no `Toil.` prefix): `FastMap`, `FastSet`, `DataWriter`, `DataReader`',
168
+ '- `parseError(err)` global: message from an unknown caught value (handy in `catch`)',
169
+ '- `Server` global: the typed RPC surface generated from the server (see `server.md`)',
170
+ '- `Server.REST.<controller>.<route>(args)`: a working, typed `fetch` client for your',
171
+ ' `@rest` controllers, e.g. `await Server.REST.todos.getTodo({ params: { id } })` or',
172
+ ' `await Server.REST.todos.add({ body: new AddTodo("milk") })`. `args` is',
173
+ ' `{ params?, body?, query?, headers? }`; returns are typed (`@data` classes are parsed for',
174
+ ' you). The REST client attaches when you import from `shared/server`.',
165
175
  '',
166
176
  '## Head example',
167
177
  '',
@@ -202,19 +212,71 @@ export const TOIL_DOCS: Record<string, string> = {
202
212
  '- `server/index.ts`, your functions.',
203
213
  '- `server/tsconfig.json`, extends `toilscript/std/assembly.json` (AssemblyScript/toilscript',
204
214
  ' globals like `i32`, not the DOM), so editors resolve server types correctly.',
205
- '- `npm run build:server` (or `npm run build`) emits `build/server/release.wasm`.',
215
+ '- `npm run build:server` (or `npm run build`) emits `build/server/release.wasm` and',
216
+ ' regenerates `shared/server.ts` (the typed client RPC module).',
217
+ '',
218
+ '## Typed RPC (`@data` / `@remote` / `@service`)',
219
+ '',
220
+ 'Tag server code and the build generates a typed client `Server` surface:',
221
+ '',
222
+ '- `@data class X {}`, a serializable struct. Generates a client class with the same fields',
223
+ ' plus `encode`/`decode`; construct it on the client: `import { X } from "shared/server"`.',
224
+ '- `@remote function f(a: T): R`, a client-callable endpoint, becomes `Server.f(a)`.',
225
+ '- `@service class S { @remote m(...) {} }`, namespaces methods: `Server.s.m(...)`.',
226
+ '',
227
+ 'On the client, `Server` is a global (no import) and fully typed; every call is async',
228
+ '(`Promise<R>`). Inputs/outputs are scalars, arrays, or `@data` classes, both directions.',
229
+ '',
230
+ 'Note: the client↔server transport is not wired yet, so calling a `Server` method throws',
231
+ 'until it lands; the typed surface + codec are generated and ready.',
232
+ '',
233
+ '## HTTP REST (`@rest` / `@route`)',
234
+ '',
235
+ 'Tag a class `@rest` and its methods with a verb to expose a real HTTP API. Unlike RPC,',
236
+ 'the generated client is working `fetch` code (it is just HTTP).',
237
+ '',
238
+ '- `@rest("api") class Todos {}`, mounts the controller at `/api` (bare `@rest` → `/`).',
239
+ '- `@get("/todos/:id")` / `@post` / `@put` / `@del` / `@patch` / `@head` / `@options`, verb',
240
+ ' shortcuts; or `@route({ method: Methods.GET, path: "/todos", stream: DataStream.JSON })`.',
241
+ '- A method takes an optional `@data` body + an optional `ctx: RouteContext` (path params via',
242
+ ' `ctx.param("id")`, `ctx.query(...)`, `ctx.header(...)`). It returns either a `@data` type,',
243
+ ' which the compiler encodes per `stream` (`DataStream.JSON` default, or `DataStream.Binary`,',
244
+ ' lossless for large `u64`/bignum), or a `Response` for full control - custom status and',
245
+ ' headers, e.g. `Response.json(value.toJSON().toString()).setHeader("cache-control", "no-store")`',
246
+ ' or `Response.notFound()`. (The editor sees the compiler-injected `@data` `toJSON`/`encode`',
247
+ ' members via the toilscript plugin, so serializing into a `Response` is editor-clean.)',
248
+ '',
249
+ 'Each `@rest` class self-registers; dispatch them from your handler - it composes, it never',
250
+ 'takes over `handle()`:',
251
+ '',
252
+ '```ts',
253
+ 'import { ToilHandler, Request, Response, Rest } from "toiljs/server/runtime";',
254
+ 'export class App extends ToilHandler {',
255
+ ' public handle(req: Request): Response {',
256
+ ' const hit = Rest.dispatch(req); // try every @rest controller',
257
+ ' if (hit != null) return hit;',
258
+ ' return Response.notFound(); // your own logic / static fallback',
259
+ ' }',
260
+ '}',
261
+ '```',
206
262
  '',
207
- 'Note: the client↔server bridge (calling WASM exports from the client) is not wired yet.',
263
+ 'For a REST-only project, `Server.handler = () => new RestHandler()` does the same with no',
264
+ 'boilerplate. On the client: `Server.REST.todos.getTodo({ params: { id } })` (see client.md).',
208
265
  ]),
209
266
  'cli.md': doc([
210
267
  '# CLI',
211
268
  '',
212
269
  '- `toiljs create [name]`, scaffold a project. Flags: `--template app|minimal`,',
213
270
  ' `--style css|sass|less|stylus`, `--tailwind`, `--no-ai`, `-y`/`--yes`.',
214
- '- `toiljs dev`, dev server with HMR (`--port`, `--root`).',
215
- '- `toiljs build`, production build `build/client` (chain `toilscript` for the server).',
271
+ '- `toiljs dev`, dev server with HMR (`--port`, `--root`). Builds the server first when the',
272
+ ' project has a `toilconfig.json`, so the generated `shared/server.ts` is current.',
273
+ '- `toiljs build`, production build. When a `toilconfig.json` is present it builds the server',
274
+ ' (toilscript, regenerating `shared/server.ts`) first, then the client → `build/client`.',
216
275
  '- `toiljs start`, self-host the built app (hyper-express) with a `/_toil` WebSocket channel.',
217
276
  '- `toiljs configure`, toggle styling features on an existing project (see `styling.md`).',
277
+ '- `toiljs doctor`, diagnose project setup (`--json` for CI). `--fix` auto-wires the typed-RPC',
278
+ ' setup (build scripts, tsconfig `shared` + alias, `.gitignore`, toilscript version, and the',
279
+ ' toilscript prettier plugin) so an existing project upgrades in one command.',
218
280
  ]),
219
281
  };
220
282
 
@@ -8,7 +8,7 @@ import { scanRoutes, type ScannedRoute } from './routes.js';
8
8
  import { llmsTxt, robotsTxt, sitemapXml } from './seo.js';
9
9
 
10
10
  /**
11
- * Contents of the root `toil-env.d.ts`: ambient global types so `new BinaryWriter()` etc. resolve
11
+ * Contents of the root `toil-env.d.ts`: ambient global types so `new DataWriter()` etc. resolve
12
12
  * in the IDE without an import. Script-mode declaration (no top-level import/export → the
13
13
  * `declare const`s are truly global, and it's not a module that could confuse ESLint's project
14
14
  * service); the inline `import('toiljs/io')` type only needs the normal `toiljs/io` export.
@@ -55,10 +55,11 @@ export const TOIL_ENV_DTS =
55
55
  ` type PageMeta = import('toiljs/client').PageMeta;\n` +
56
56
  ` type SearchHints = import('toiljs/client').SearchHints;\n` +
57
57
  `}\n` +
58
- `declare const BinaryWriter: typeof import('toiljs/io').BinaryWriter;\n` +
59
- `declare const BinaryReader: typeof import('toiljs/io').BinaryReader;\n` +
60
58
  `declare const FastMap: typeof import('toiljs/io').FastMap;\n` +
61
59
  `declare const FastSet: typeof import('toiljs/io').FastSet;\n` +
60
+ `declare const DataWriter: typeof import('toiljs/io').DataWriter;\n` +
61
+ `declare const DataReader: typeof import('toiljs/io').DataReader;\n` +
62
+ `declare const parseError: typeof import('toiljs/client').parseError;\n` +
62
63
  `\n` +
63
64
  `${STYLE_MODULES}\n` +
64
65
  `\n` +
@@ -290,9 +291,9 @@ export function generate(cfg: ResolvedToilConfig): ScannedRoute[] {
290
291
  `// @ts-nocheck\n` +
291
292
  `// AUTO-GENERATED by toil, do not edit.\n` +
292
293
  `import * as Toil from 'toiljs/client';\n` +
293
- `import { BinaryWriter, BinaryReader, FastMap, FastSet } from 'toiljs/io';\n` +
294
+ `import { FastMap, FastSet, DataWriter, DataReader } from 'toiljs/io';\n` +
294
295
  `import { pages } from './routes';\n\n` +
295
- `Object.assign(globalThis, { Toil, BinaryWriter, BinaryReader, FastMap, FastSet });\n` +
296
+ `Object.assign(globalThis, { Toil, FastMap, FastSet, DataWriter, DataReader, Server: Toil.Server, parseError: Toil.parseError });\n` +
296
297
  `Toil.setViewTransitions(${String(cfg.viewTransitions)});\n` +
297
298
  `Toil.setTransitions(${String(cfg.transitions)});\n` +
298
299
  `Toil.registerPages(pages);\n`;
@@ -1,4 +1,6 @@
1
+ import { spawn } from 'node:child_process';
1
2
  import fs from 'node:fs';
3
+ import { createRequire } from 'node:module';
2
4
  import path from 'node:path';
3
5
 
4
6
  import { build as viteBuild, createServer, type ViteDevServer } from 'vite';
@@ -9,14 +11,60 @@ import { generate } from './generate.js';
9
11
  import { prerenderStaticParams } from './ssg.js';
10
12
  import { createViteConfig } from './vite.js';
11
13
 
14
+ /**
15
+ * Builds the toilscript server target (which also regenerates `shared/server.ts` via
16
+ * `--rpcModule`) when the project has one, signalled by a `toilconfig.json` at the root. This
17
+ * runs before the client build/dev so the generated `@data` + `Server` module the client
18
+ * imports is always current; without it a stale or missing `shared/server.ts` breaks the
19
+ * client build. A no-op for client-only projects. Runs the locally installed `toilscript`
20
+ * (resolved + invoked via Node, so no `.bin` shim / PATH assumptions).
21
+ */
22
+ async function buildServer(root: string): Promise<void> {
23
+ if (!fs.existsSync(path.join(root, 'toilconfig.json'))) return;
24
+
25
+ const require = createRequire(path.join(root, 'package.json'));
26
+ let binJs: string;
27
+ try {
28
+ const pkgPath = require.resolve('toilscript/package.json');
29
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) as {
30
+ bin?: string | Record<string, string>;
31
+ };
32
+ const binRel = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.toilscript;
33
+ if (!binRel) throw new Error('toilscript declares no bin');
34
+ binJs = path.join(path.dirname(pkgPath), binRel);
35
+ } catch {
36
+ throw new Error(
37
+ "toiljs: this project has a server target (toilconfig.json) but 'toilscript' is not " +
38
+ 'installed. Run `npm i -D toilscript`, or remove toilconfig.json for a client-only build.',
39
+ );
40
+ }
41
+
42
+ await new Promise<void>((resolve, reject) => {
43
+ const child = spawn(
44
+ process.execPath,
45
+ [binJs, '--target', 'release', '--rpcModule', 'shared/server.ts'],
46
+ { cwd: root, stdio: 'inherit' },
47
+ );
48
+ child.on('error', reject);
49
+ child.on('close', (code) =>
50
+ code === 0
51
+ ? resolve()
52
+ : reject(new Error(`toilscript server build failed (exit ${String(code)})`)),
53
+ );
54
+ });
55
+ }
56
+
12
57
  export interface ToilCommandOptions {
13
58
  readonly root?: string;
14
59
  readonly port?: number;
60
+ /** Bind host for `start`. Defaults to loopback (`127.0.0.1`); pass `0.0.0.0` to expose. */
61
+ readonly host?: string;
15
62
  }
16
63
 
17
64
  /** Starts the Vite dev server (HMR + transforms) for the client app. Returns the running server. */
18
65
  export async function dev(opts: ToilCommandOptions = {}): Promise<ViteDevServer> {
19
66
  const cfg = await loadConfig(opts);
67
+ await buildServer(cfg.root);
20
68
  generate(cfg);
21
69
  const server = await createServer(await createViteConfig(cfg));
22
70
  await server.listen();
@@ -27,6 +75,7 @@ export async function dev(opts: ToilCommandOptions = {}): Promise<ViteDevServer>
27
75
  /** Produces an optimized production SPA bundle in the configured `outDir`. */
28
76
  export async function build(opts: ToilCommandOptions = {}): Promise<void> {
29
77
  const cfg = await loadConfig(opts);
78
+ await buildServer(cfg.root);
30
79
  generate(cfg);
31
80
  await viteBuild(await createViteConfig(cfg));
32
81
  // SSG: bake per-URL HTML + sitemap for dynamic routes that opt in via `generateStaticParams`.
@@ -44,7 +93,7 @@ export async function start(opts: ToilCommandOptions = {}): Promise<RunningBacke
44
93
  if (!fs.existsSync(path.join(outDir, 'index.html'))) {
45
94
  throw new Error(`No build found in ${outDir}. Run \`toiljs build\` first.`);
46
95
  }
47
- return startBackend({ root: outDir, port: cfg.port });
96
+ return startBackend({ root: outDir, port: cfg.port, host: opts.host });
48
97
  }
49
98
 
50
99
  export { defineConfig, loadConfig, AiProvider } from './config.js';
@@ -1,5 +1,6 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
+ import { type IncomingMessage } from 'node:http';
3
4
  import { createRequire } from 'node:module';
4
5
  import path from 'node:path';
5
6
  import { fileURLToPath } from 'node:url';
@@ -102,6 +103,37 @@ function devInfo(cfg: ResolvedToilConfig, port: number): Record<string, unknown>
102
103
  };
103
104
  }
104
105
 
106
+ /**
107
+ * Resolves a request's `file` param to an absolute path that is genuinely inside the project root,
108
+ * or null. Guards against `..` traversal, sibling-prefix escapes (`<root>-evil/secret` passes a bare
109
+ * `startsWith(root)`), and symlinks inside the project that point outside it (realpath re-check).
110
+ */
111
+ function safeProjectPath(cfg: ResolvedToilConfig, file: string | null): string | null {
112
+ if (!file) return null;
113
+ const root = cfg.root;
114
+ const inside = (p: string): boolean => p === root || p.startsWith(root + path.sep);
115
+ const abs = path.resolve(file);
116
+ if (!inside(abs) || !fs.existsSync(abs)) return null;
117
+ try {
118
+ const real = fs.realpathSync(abs);
119
+ return inside(real) ? real : null;
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * True for requests that must NOT reach the dev endpoints (they open files, read source, and spend
127
+ * AI credits). Uses an allowlist on the browser-set `Sec-Fetch-Site`: only `same-origin` (the
128
+ * toolbar), `none` (user-initiated, e.g. the address bar), or an absent header (non-browser tooling
129
+ * like curl) are allowed. Everything else, `cross-site`, `same-site`, or any unexpected value, is
130
+ * rejected. This blocks CSRF (a malicious site's fetch/img) without breaking local dev tooling.
131
+ */
132
+ function isCrossSiteRequest(headers: IncomingMessage['headers']): boolean {
133
+ const site = headers['sec-fetch-site'];
134
+ return site !== undefined && site !== 'same-origin' && site !== 'none';
135
+ }
136
+
105
137
  /** Opens `file` in the user's editor (best-effort): `$EDITOR file`, else `code -g file`. */
106
138
  function openInEditor(file: string): void {
107
139
  try {
@@ -150,7 +182,12 @@ export function toilPlugin(cfg: ResolvedToilConfig): Plugin {
150
182
  configureServer(server) {
151
183
  // Dev toolbar endpoints (dev only). `/__toil/devinfo` -> build/config snapshot;
152
184
  // `/__toil/open?file=` -> open the file in the editor.
153
- server.middlewares.use('/__toil/devinfo', (_req, res) => {
185
+ server.middlewares.use('/__toil/devinfo', (req, res) => {
186
+ if (isCrossSiteRequest(req.headers)) {
187
+ res.statusCode = 403;
188
+ res.end();
189
+ return;
190
+ }
154
191
  const port = server.config.server.port ?? cfg.port;
155
192
  res.setHeader('content-type', 'application/json');
156
193
  res.end(JSON.stringify(devInfo(cfg, port)));
@@ -158,6 +195,11 @@ export function toilPlugin(cfg: ResolvedToilConfig): Plugin {
158
195
  // `/__toil/ai` -> server-side AI proxy. The key is read from the env here and never
159
196
  // reaches the browser; 404 when AI isn't configured (the toolbar then only hands off).
160
197
  server.middlewares.use('/__toil/ai', (req, res) => {
198
+ if (isCrossSiteRequest(req.headers)) {
199
+ res.statusCode = 403;
200
+ res.end();
201
+ return;
202
+ }
161
203
  if (req.method !== 'POST') {
162
204
  res.statusCode = 405;
163
205
  res.end();
@@ -170,31 +212,52 @@ export function toilPlugin(cfg: ResolvedToilConfig): Plugin {
170
212
  return;
171
213
  }
172
214
  let body = '';
173
- req.on('data', (chunk) => (body += String(chunk)));
215
+ let aborted = false;
216
+ req.on('data', (chunk) => {
217
+ if (aborted) return;
218
+ body += String(chunk);
219
+ if (body.length > 100_000) {
220
+ // Cap the request body so a runaway/malicious POST can't grow it unbounded.
221
+ aborted = true;
222
+ res.statusCode = 413;
223
+ res.end();
224
+ req.destroy();
225
+ }
226
+ });
174
227
  req.on('end', () => {
228
+ if (aborted) return;
175
229
  void (async () => {
176
230
  try {
177
- const { prompt } = JSON.parse(body || '{}') as { prompt?: string };
178
- const text = await aiComplete(ai, prompt ?? '');
231
+ const parsed = JSON.parse(body || '{}') as { prompt?: string };
232
+ // Cap the prompt actually forwarded upstream (independent of the raw-body cap).
233
+ const prompt =
234
+ typeof parsed.prompt === 'string' ? parsed.prompt.slice(0, 16000) : '';
235
+ const text = await aiComplete(ai, prompt);
179
236
  res.setHeader('content-type', 'application/json');
180
237
  res.end(JSON.stringify({ text }));
181
238
  } catch (e) {
239
+ // Log the detail to the dev's terminal; return a generic message to the
240
+ // client so upstream/provider error text is never reflected over HTTP.
241
+ process.stderr.write(
242
+ `toil: /__toil/ai failed: ${e instanceof Error ? e.message : String(e)}\n`,
243
+ );
182
244
  res.statusCode = 500;
183
245
  res.setHeader('content-type', 'application/json');
184
- res.end(
185
- JSON.stringify({ error: e instanceof Error ? e.message : String(e) }),
186
- );
246
+ res.end(JSON.stringify({ error: 'AI request failed (see dev server logs).' }));
187
247
  }
188
248
  })();
189
249
  });
190
250
  });
191
251
  server.middlewares.use('/__toil/open', (req, res) => {
192
252
  try {
253
+ if (isCrossSiteRequest(req.headers)) {
254
+ res.statusCode = 403;
255
+ res.end();
256
+ return;
257
+ }
193
258
  const url = new URL(req.url ?? '', 'http://localhost');
194
- const file = url.searchParams.get('file');
195
- const abs = file ? path.resolve(file) : '';
196
- // Only files inside the project root, never an arbitrary path.
197
- if (abs && abs.startsWith(cfg.root) && fs.existsSync(abs)) openInEditor(abs);
259
+ const abs = safeProjectPath(cfg, url.searchParams.get('file'));
260
+ if (abs) openInEditor(abs);
198
261
  res.statusCode = 204;
199
262
  res.end();
200
263
  } catch {
@@ -202,6 +265,31 @@ export function toilPlugin(cfg: ResolvedToilConfig): Plugin {
202
265
  res.end();
203
266
  }
204
267
  });
268
+ // `/__toil/source?file=` -> the file's text, so the AI tab can include the page's code in
269
+ // its prompt. Same root-confinement (`safeProjectPath`) as `/__toil/open`; capped so a
270
+ // stray huge file can't bloat the response.
271
+ server.middlewares.use('/__toil/source', (req, res) => {
272
+ try {
273
+ if (isCrossSiteRequest(req.headers)) {
274
+ res.statusCode = 403;
275
+ res.end();
276
+ return;
277
+ }
278
+ const url = new URL(req.url ?? '', 'http://localhost');
279
+ const abs = safeProjectPath(cfg, url.searchParams.get('file'));
280
+ if (!abs) {
281
+ res.statusCode = 404;
282
+ res.end();
283
+ return;
284
+ }
285
+ const text = fs.readFileSync(abs, 'utf8').slice(0, 20000);
286
+ res.setHeader('content-type', 'text/plain; charset=utf-8');
287
+ res.end(text);
288
+ } catch {
289
+ res.statusCode = 400;
290
+ res.end();
291
+ }
292
+ });
205
293
 
206
294
  // Trailing slash so a sibling like `routes-extra/` doesn't match the `routes/` prefix.
207
295
  const routesPrefix = cfg.routesAbsDir.replace(/\\/g, '/').replace(/\/?$/, '/');
@@ -135,6 +135,22 @@ function escapeAttr(value: string): string {
135
135
  export function escapeHtml(value: string): string {
136
136
  return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
137
137
  }
138
+ /**
139
+ * Neutralizes a string for safe single-line Markdown interpolation (e.g. `llms.txt`): collapses
140
+ * newlines/control chars to a space and backslash-escapes the characters that would break out of a
141
+ * `[text](url)` link or start a new block (`[ ] ( ) < > \``). Without this, a page title/description
142
+ * from `generateMetadata` could inject extra list items, links, or headings.
143
+ */
144
+ function escapeMarkdownInline(value: string): string {
145
+ return value
146
+ .replace(/[\r\n\t\f\v]+/g, ' ')
147
+ .replace(/[\\[\]()<>`]/g, (c) => `\\${c}`)
148
+ .trim();
149
+ }
150
+ /** Escapes a URL for a Markdown link target: strips whitespace/control chars and parens. */
151
+ function escapeMarkdownUrl(value: string): string {
152
+ return value.replace(/\s+/g, '').replace(/[()<>]/g, encodeURIComponent);
153
+ }
138
154
  /**
139
155
  * Serializes a value for embedding in an inline `<script>` (JSON-LD). Escapes `<`, `>`, and `&`,
140
156
  * which neutralizes `</script>` and `<!--` (the only HTML-significant sequences inside a script),
@@ -388,9 +404,13 @@ export function llmsTxt(
388
404
  if (resolvedPages.length) {
389
405
  out.push('\n## Pages\n');
390
406
  for (const page of resolvedPages) {
391
- out.push(
392
- `- [${page.title}](${page.url})${page.description !== undefined ? `: ${page.description}` : ''}`,
393
- );
407
+ const title = escapeMarkdownInline(page.title);
408
+ const url = escapeMarkdownUrl(page.url);
409
+ const desc =
410
+ page.description !== undefined
411
+ ? `: ${escapeMarkdownInline(page.description)}`
412
+ : '';
413
+ out.push(`- [${title}](${url})${desc}`);
394
414
  }
395
415
  }
396
416
  return out.join('\n') + '\n';
@@ -98,6 +98,16 @@ export async function prerenderStaticParams(cfg: ResolvedToilConfig): Promise<st
98
98
  const paramSets = await mod.generateStaticParams();
99
99
  for (const params of paramSets) {
100
100
  const url = fillPattern(route.pattern, params);
101
+ // Containment guard: a param value with `..`/separators could make `url` resolve the
102
+ // output path outside `outDir`. Resolve the target up front and skip anything that
103
+ // escapes, so a (possibly externally-derived) param can't clobber files elsewhere.
104
+ const target = path.join(outDir, url.replace(/^\//, ''), 'index.html');
105
+ const outRoot = path.resolve(outDir);
106
+ const absTarget = path.resolve(target);
107
+ if (absTarget !== outRoot && !absTarget.startsWith(outRoot + path.sep)) {
108
+ warn(`skipped ${route.pattern}: params escape outDir (${url})`);
109
+ continue;
110
+ }
101
111
  let metadata: Record<string, unknown> | null = null;
102
112
  try {
103
113
  if (typeof mod.generateMetadata === 'function') {
@@ -114,7 +124,6 @@ export async function prerenderStaticParams(cfg: ResolvedToilConfig): Promise<st
114
124
  warn(`metadata failed for ${url} (${err instanceof Error ? err.message : String(err)})`);
115
125
  }
116
126
  const html = injectSeoHtml(shell, routeSeo(cfg.seo, metadata, url));
117
- const target = path.join(outDir, url.replace(/^\//, ''), 'index.html');
118
127
  fs.mkdirSync(path.dirname(target), { recursive: true });
119
128
  fs.writeFileSync(target, html);
120
129
  generated.push(url);
@@ -1,3 +1,4 @@
1
+ import fs from 'node:fs';
1
2
  import { createRequire } from 'node:module';
2
3
  import path from 'node:path';
3
4
  import { pathToFileURL } from 'node:url';
@@ -41,6 +42,36 @@ const IMAGE_EXT = /^(png|jpe?g|svg|gif|tiff|bmp|ico|webp|avif)$/i;
41
42
  /** Font extensions routed to `fonts/`. */
42
43
  const FONT_EXT = /^(woff|woff2|eot|ttf|otf)$/i;
43
44
 
45
+ /**
46
+ * Resolves bare `shared/*` imports to the project's `shared/` folder (replacing a plain alias so it
47
+ * can run early), and fails `shared/server` with an actionable message when the module has not been
48
+ * generated yet (it comes from the server build, which must run first), instead of Vite's opaque
49
+ * `UNLOADABLE_DEPENDENCY`.
50
+ */
51
+ function sharedResolverPlugin(cfg: ResolvedToilConfig): PluginOption {
52
+ const sharedDir = path.join(cfg.root, 'shared');
53
+ return {
54
+ name: 'toiljs:shared-resolver',
55
+ enforce: 'pre',
56
+ resolveId(source: string) {
57
+ if (source !== 'shared' && !source.startsWith('shared/')) return null;
58
+ const rel = source === 'shared' ? 'index' : source.slice('shared/'.length);
59
+ for (const ext of ['', '.ts', '.tsx', '.js', '.jsx', '.mjs']) {
60
+ const candidate = path.join(sharedDir, rel + ext);
61
+ if (fs.existsSync(candidate)) return candidate;
62
+ }
63
+ if (source === 'shared/server') {
64
+ throw new Error(
65
+ 'toiljs: "shared/server" is generated by the server build but is missing. ' +
66
+ 'Run the server build first (it emits shared/server.ts from your @data/@remote code): ' +
67
+ '`npm run build:server` (toilscript --target release --rpcModule shared/server.ts).',
68
+ );
69
+ }
70
+ return null;
71
+ },
72
+ };
73
+ }
74
+
44
75
  /** Routes a built asset to a typed sub-folder (`images/`, `fonts/`, `css/`, else `assets/`). */
45
76
  function assetFileName(name: string): string {
46
77
  const ext = name.split('.').pop() ?? '';
@@ -116,12 +147,15 @@ export async function createViteConfig(cfg: ResolvedToilConfig): Promise<InlineC
116
147
  cfg.fonts ? fontPreloadPlugin(cfg) : undefined,
117
148
  nodePolyfills({ globals: { Buffer: true, global: true, process: true } }),
118
149
  react(),
150
+ sharedResolverPlugin(cfg),
119
151
  toilPlugin(cfg),
120
152
  ],
121
153
  resolve: {
122
154
  alias: {
123
155
  'toiljs/client': cfg.runtimePath,
124
156
  'toiljs/routes': path.join(cfg.toilDir, 'routes.ts'),
157
+ // `shared/*` is resolved by sharedResolverPlugin (above) so a missing generated
158
+ // shared/server.ts gives an actionable error instead of an opaque load failure.
125
159
  ...polyfillShimAliases,
126
160
  },
127
161
  dedupe: ['react', 'react-dom'],