toiljs 0.0.16 → 0.0.19

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 +111 -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 -696
  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 +62 -5
  20. package/build/compiler/generate.js +5 -4
  21. package/build/compiler/index.d.ts +1 -0
  22. package/build/compiler/index.js +1 -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 +74 -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 +15 -5
  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 +62 -5
  67. package/src/compiler/generate.ts +6 -5
  68. package/src/compiler/index.ts +3 -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
@@ -400,6 +400,87 @@ export function checkWasmBuilt(exists: boolean): Check {
400
400
  };
401
401
  }
402
402
 
403
+ // --- Typed RPC (@data / @remote / @service) -------------------------------------------------------
404
+
405
+ /** Minimum toilscript: the @rest/@route HTTP layer + RPC codegen + hardened decoders + editor decls. */
406
+ export const RPC_TOILSCRIPT_MIN = '0.1.11';
407
+
408
+ /** Whether each piece of the typed-RPC wiring is in place (computed in `doctor.ts`). */
409
+ export interface RpcFacts {
410
+ /** `build:server` runs toilscript with `--rpcModule`. */
411
+ readonly buildServerWired: boolean;
412
+ /** tsconfig includes `shared` and has the `shared/*` path alias. */
413
+ readonly tsconfigWired: boolean;
414
+ /** `.gitignore` ignores the generated `shared/server.ts`. */
415
+ readonly gitignoreWired: boolean;
416
+ /** The declared toilscript range is at least {@link RPC_TOILSCRIPT_MIN}. */
417
+ readonly toilscriptOk: boolean;
418
+ }
419
+
420
+ /**
421
+ * One check for the typed-RPC setup (`@data`/`@remote` -> generated `Server`). Warns (does not fail)
422
+ * when an existing project predates the feature, and points at the one-command upgrade.
423
+ */
424
+ export function checkRpcWiring(f: RpcFacts): Check {
425
+ const missing: string[] = [];
426
+ if (!f.toilscriptOk) missing.push(`toilscript >=${RPC_TOILSCRIPT_MIN}`);
427
+ if (!f.buildServerWired) missing.push('build:server --rpcModule');
428
+ if (!f.tsconfigWired) missing.push('tsconfig shared/ + alias');
429
+ if (!f.gitignoreWired) missing.push('.gitignore shared/server.ts');
430
+ if (missing.length === 0) {
431
+ return { id: 'rpc-wiring', label: 'typed RPC wiring', status: 'pass' };
432
+ }
433
+ return {
434
+ id: 'rpc-wiring',
435
+ label: 'typed RPC wiring',
436
+ status: 'warn',
437
+ detail: `missing: ${missing.join(', ')}`,
438
+ fix: 'Run `toiljs doctor --fix` to wire @data/@remote RPC (build:server, tsconfig, .gitignore, toilscript).',
439
+ };
440
+ }
441
+
442
+ /**
443
+ * Whether the project's prettier setup pulls in the toilscript plugin (`toiljs/prettier-plugin`,
444
+ * or the `toiljs/prettier` shareable that bundles it). Without it, prettier throws on the server's
445
+ * native function decorators (`@main`, `@remote function ...`).
446
+ */
447
+ export function checkPrettierPlugin(present: boolean): Check {
448
+ return present
449
+ ? { id: 'prettier-plugin', label: 'prettier toilscript plugin', status: 'pass' }
450
+ : {
451
+ id: 'prettier-plugin',
452
+ label: 'prettier toilscript plugin',
453
+ status: 'warn',
454
+ detail: 'prettier will fail on @main / @remote-on-function in server code',
455
+ fix: 'Run `toiljs doctor --fix` to add toiljs/prettier-plugin to your prettier config.',
456
+ };
457
+ }
458
+
459
+ export interface RestFacts {
460
+ /** The server declares at least one `@rest` controller. */
461
+ readonly hasControllers: boolean;
462
+ /** Some server file dispatches them: a `Rest.dispatch(` call, or a `RestHandler`. */
463
+ readonly dispatched: boolean;
464
+ }
465
+
466
+ /**
467
+ * Guards the easy-to-miss wiring step for the HTTP layer: a `@rest` controller self-registers,
468
+ * but its routes are only served if a handler calls `Rest.dispatch(req)` (or the project uses
469
+ * `RestHandler`). Without that, the routes silently 404 - a confusing footgun, so we warn.
470
+ */
471
+ export function checkRestDispatch(f: RestFacts): Check {
472
+ if (!f.hasControllers || f.dispatched) {
473
+ return { id: 'rest-dispatch', label: 'REST dispatch wiring', status: 'pass' };
474
+ }
475
+ return {
476
+ id: 'rest-dispatch',
477
+ label: 'REST dispatch wiring',
478
+ status: 'warn',
479
+ detail: '@rest controllers found, but nothing calls Rest.dispatch(req) - their routes will not be served',
480
+ fix: 'In your handler add `const hit = Rest.dispatch(req); if (hit != null) return hit;`, or set `Server.handler = () => new RestHandler()`.',
481
+ };
482
+ }
483
+
403
484
  // --- Summary --------------------------------------------------------------------------------------
404
485
 
405
486
  export function summarize(groups: readonly CheckGroup[]): DoctorSummary {
package/src/cli/doctor.ts CHANGED
@@ -23,9 +23,12 @@ import {
23
23
  checkNode,
24
24
  checkPackageManager,
25
25
  checkPeer,
26
+ checkPrettierPlugin,
26
27
  checkRelativeAssets,
28
+ checkRestDispatch,
27
29
  checkRootElement,
28
30
  checkRoutesPresent,
31
+ checkRpcWiring,
29
32
  checkSeoUrl,
30
33
  checkServerEntry,
31
34
  type CheckStatus,
@@ -36,6 +39,10 @@ import {
36
39
  checkWasmBuilt,
37
40
  findRelativeAssets,
38
41
  hasFailures,
42
+ type RestFacts,
43
+ type RpcFacts,
44
+ RPC_TOILSCRIPT_MIN,
45
+ satisfiesMin,
39
46
  type SourceFile,
40
47
  summarize,
41
48
  } from './diagnostics.js';
@@ -53,6 +60,8 @@ export interface DoctorOptions {
53
60
  readonly cwd: string;
54
61
  /** Emit machine-readable JSON instead of the human report. */
55
62
  readonly json?: boolean;
63
+ /** Auto-fix what can be fixed in place (currently the typed-RPC wiring). */
64
+ readonly fix?: boolean;
56
65
  }
57
66
 
58
67
  /** Parses a JSON file into a plain object, or null on any error / non-object. */
@@ -83,6 +92,336 @@ function readFile(file: string): string | null {
83
92
  }
84
93
  }
85
94
 
95
+ function writeFile(file: string, content: string): void {
96
+ fs.writeFileSync(file, content);
97
+ }
98
+
99
+ /**
100
+ * Whether `name` is installed for the project at `root`. Tries Node resolution first (handles
101
+ * hoisting), then falls back to walking `node_modules`, since a strict `exports` map can make
102
+ * `require.resolve('<pkg>')` throw even when the package is present (the toilscript false positive).
103
+ */
104
+ function isPackageInstalled(root: string, name: string): boolean {
105
+ const require = createRequire(path.join(root, 'package.json'));
106
+ for (const id of [`${name}/package.json`, name]) {
107
+ try {
108
+ require.resolve(id);
109
+ return true;
110
+ } catch {
111
+ // try the next resolution strategy
112
+ }
113
+ }
114
+ for (let dir = root; ; ) {
115
+ if (fs.existsSync(path.join(dir, 'node_modules', name, 'package.json'))) return true;
116
+ const parent = path.dirname(dir);
117
+ if (parent === dir) return false;
118
+ dir = parent;
119
+ }
120
+ }
121
+
122
+ /** Narrows a value to a plain (non-array) object, or null. */
123
+ function asRecord(value: unknown): Record<string, unknown> | null {
124
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
125
+ ? (value as Record<string, unknown>)
126
+ : null;
127
+ }
128
+
129
+ const RPC_MODULE_FLAG = '--rpcModule shared/server.ts';
130
+ const RPC_GITIGNORE_LINE = 'shared/server.ts';
131
+ const RPC_GITIGNORE_RE = /(^|\n)\s*shared\/server\.ts\s*(\r?\n|$)/;
132
+
133
+ /**
134
+ * Whether a dependency range is an ordinary registry semver range (so a deliberate
135
+ * `latest`/`*`/`file:`/`github:`/`workspace:`/`npm:` pin is left untouched and treated
136
+ * as already-OK rather than clobbered to a version).
137
+ */
138
+ function looksLikeSemverRange(range: string): boolean {
139
+ return /^\s*[v^~>=<]*\s*\d+\.\d+/.test(range);
140
+ }
141
+
142
+ /** Reads the project and reports which parts of the typed-RPC wiring are present. */
143
+ function gatherRpcFacts(root: string): RpcFacts {
144
+ const pkg = readJsonObject(path.join(root, 'package.json'));
145
+ const scripts = pkg ? stringRecord(pkg.scripts) : {};
146
+ const deps = {
147
+ ...(pkg ? stringRecord(pkg.dependencies) : {}),
148
+ ...(pkg ? stringRecord(pkg.devDependencies) : {}),
149
+ };
150
+ const tsconfig = readJsonObject(path.join(root, 'tsconfig.json'));
151
+ const gitignore = readFile(path.join(root, '.gitignore'));
152
+
153
+ // Either the combined `build` or `build:server` carrying --rpcModule counts (the fixer writes both).
154
+ const buildServerWired = [scripts['build:server'], scripts['build']].some(
155
+ (s) => typeof s === 'string' && s.includes('--rpcModule'),
156
+ );
157
+
158
+ let tsconfigWired = false;
159
+ if (tsconfig) {
160
+ // An absent `include` compiles all files, so `shared` is covered implicitly.
161
+ const include = tsconfig.include;
162
+ const hasShared = !Array.isArray(include) || include.includes('shared');
163
+ const paths = asRecord(asRecord(tsconfig.compilerOptions)?.paths);
164
+ tsconfigWired = hasShared && paths !== null && 'shared/*' in paths;
165
+ }
166
+
167
+ const gitignoreWired = gitignore !== null && RPC_GITIGNORE_RE.test(gitignore);
168
+ const range = deps.toilscript;
169
+ // A non-semver range (file:/github:/latest/*) can't be assessed; don't flag it.
170
+ const toilscriptOk =
171
+ range == null
172
+ ? false
173
+ : looksLikeSemverRange(range)
174
+ ? satisfiesMin(range, RPC_TOILSCRIPT_MIN)
175
+ : true;
176
+
177
+ return { buildServerWired, tsconfigWired, gitignoreWired, toilscriptOk };
178
+ }
179
+
180
+ /** The server `.ts` sources, read from the directories of the toilconfig entries (capped). */
181
+ function serverSources(root: string, toilconfig: Record<string, unknown> | null): string[] {
182
+ const dirs = new Set<string>();
183
+ const entries = Array.isArray(toilconfig?.entries)
184
+ ? (toilconfig.entries as unknown[]).filter((e): e is string => typeof e === 'string')
185
+ : [];
186
+ for (const e of entries) dirs.add(path.dirname(path.resolve(root, e)));
187
+
188
+ const out: string[] = [];
189
+ const cap = 200;
190
+ const maxDepth = 16; // bounds the walk so a symlink cycle in a hostile project can't hang doctor
191
+ const visit = (current: string, depth: number): void => {
192
+ if (out.length >= cap || depth > maxDepth) return;
193
+ let listing: fs.Dirent[];
194
+ try {
195
+ listing = fs.readdirSync(current, { withFileTypes: true });
196
+ } catch {
197
+ return;
198
+ }
199
+ for (const entry of listing) {
200
+ if (out.length >= cap) break;
201
+ const full = path.join(current, entry.name);
202
+ // isDirectory() follows symlinks; the depth cap keeps a symlink cycle bounded.
203
+ if (entry.isDirectory()) {
204
+ if (entry.name !== 'node_modules') visit(full, depth + 1);
205
+ } else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) {
206
+ const src = readFile(full);
207
+ if (src !== null) out.push(src);
208
+ }
209
+ }
210
+ };
211
+ for (const dir of dirs) visit(dir, 0);
212
+ return out;
213
+ }
214
+
215
+ /** Scans the server sources for `@rest` controllers and whether anything dispatches them. */
216
+ function gatherRestFacts(root: string, toilconfig: Record<string, unknown> | null): RestFacts {
217
+ let hasControllers = false;
218
+ let dispatched = false;
219
+ for (const src of serverSources(root, toilconfig)) {
220
+ if (/@rest\b/.test(src)) hasControllers = true;
221
+ if (/\bRest\s*\.\s*dispatch\s*\(/.test(src) || /\bRestHandler\b/.test(src))
222
+ dispatched = true;
223
+ if (hasControllers && dispatched) break;
224
+ }
225
+ return { hasControllers, dispatched };
226
+ }
227
+
228
+ interface RpcFixResult {
229
+ /** Files written. */
230
+ readonly changed: string[];
231
+ /** Files that need a manual edit (e.g. tsconfig with comments). */
232
+ readonly skipped: string[];
233
+ }
234
+
235
+ /**
236
+ * Applies the typed-RPC wiring in place: appends `--rpcModule` to the toilscript build scripts,
237
+ * adds `shared` (+ the `shared/*` alias) to tsconfig, ignores the generated module, and lifts the
238
+ * toilscript floor. Idempotent; only writes files it actually changes.
239
+ */
240
+ function applyRpcFix(root: string): RpcFixResult {
241
+ const changed: string[] = [];
242
+ const skipped: string[] = [];
243
+
244
+ const pkgPath = path.join(root, 'package.json');
245
+ const pkgRaw = readFile(pkgPath);
246
+ const pkg = pkgRaw !== null ? readJsonObject(pkgPath) : null;
247
+ if (pkg !== null) {
248
+ let touched = false;
249
+ const scripts = asRecord(pkg.scripts) ?? {};
250
+ for (const key of ['build', 'build:server']) {
251
+ const value = scripts[key];
252
+ if (
253
+ typeof value === 'string' &&
254
+ value.includes('toilscript') &&
255
+ !value.includes('--rpcModule')
256
+ ) {
257
+ scripts[key] = `${value} ${RPC_MODULE_FLAG}`;
258
+ pkg.scripts = scripts;
259
+ touched = true;
260
+ }
261
+ }
262
+ let toilscriptDeclared = false;
263
+ for (const field of ['devDependencies', 'dependencies']) {
264
+ const bag = asRecord(pkg[field]);
265
+ const current = bag?.toilscript;
266
+ if (bag && typeof current === 'string') {
267
+ toilscriptDeclared = true;
268
+ // Only lift a real semver range that floors below the minimum; leave file:/latest/* pins alone.
269
+ if (looksLikeSemverRange(current) && !satisfiesMin(current, RPC_TOILSCRIPT_MIN)) {
270
+ bag.toilscript = `^${RPC_TOILSCRIPT_MIN}`;
271
+ touched = true;
272
+ }
273
+ }
274
+ }
275
+ if (!toilscriptDeclared) {
276
+ // A server project needs toilscript; add it so the wiring actually resolves.
277
+ const dd = asRecord(pkg.devDependencies) ?? {};
278
+ dd.toilscript = `^${RPC_TOILSCRIPT_MIN}`;
279
+ pkg.devDependencies = dd;
280
+ touched = true;
281
+ }
282
+ if (touched) {
283
+ writeFile(pkgPath, JSON.stringify(pkg, null, 4) + '\n');
284
+ changed.push('package.json');
285
+ }
286
+ } else if (pkgRaw !== null) {
287
+ skipped.push('package.json (unparseable)');
288
+ }
289
+
290
+ const tsPath = path.join(root, 'tsconfig.json');
291
+ const tsRaw = readFile(tsPath);
292
+ const tsconfig = tsRaw !== null ? readJsonObject(tsPath) : null;
293
+ if (tsconfig !== null) {
294
+ let touched = false;
295
+ // Only touch `include` if it already exists; an absent `include` compiles all files,
296
+ // and synthesizing one would narrow what TypeScript sees.
297
+ if (Array.isArray(tsconfig.include)) {
298
+ const include = [...(tsconfig.include as unknown[])];
299
+ if (!include.includes('shared')) {
300
+ const at = include.indexOf('client');
301
+ include.splice(at >= 0 ? at + 1 : include.length, 0, 'shared');
302
+ tsconfig.include = include;
303
+ touched = true;
304
+ }
305
+ }
306
+ const co = asRecord(tsconfig.compilerOptions) ?? {};
307
+ const paths = asRecord(co.paths) ?? {};
308
+ if (!('shared/*' in paths)) {
309
+ paths['shared/*'] = ['./shared/*'];
310
+ co.paths = paths;
311
+ tsconfig.compilerOptions = co;
312
+ touched = true;
313
+ }
314
+ if (touched) {
315
+ writeFile(tsPath, JSON.stringify(tsconfig, null, 4) + '\n');
316
+ changed.push('tsconfig.json');
317
+ }
318
+ } else if (tsRaw !== null) {
319
+ skipped.push('tsconfig.json (JSON with comments, add "shared" + paths by hand)');
320
+ }
321
+
322
+ const giPath = path.join(root, '.gitignore');
323
+ const giRaw = readFile(giPath);
324
+ if (giRaw === null) {
325
+ writeFile(giPath, `${RPC_GITIGNORE_LINE}\n`);
326
+ changed.push('.gitignore');
327
+ } else if (!RPC_GITIGNORE_RE.test(giRaw)) {
328
+ const sep = giRaw.length === 0 || giRaw.endsWith('\n') ? '' : '\n';
329
+ writeFile(giPath, `${giRaw}${sep}${RPC_GITIGNORE_LINE}\n`);
330
+ changed.push('.gitignore');
331
+ }
332
+
333
+ return { changed, skipped };
334
+ }
335
+
336
+ const PRETTIER_PLUGIN = 'toiljs/prettier-plugin';
337
+ const PRETTIER_MENTION = /toiljs\/prettier(-plugin)?/;
338
+ const PRETTIER_CONFIG_FILES = [
339
+ '.prettierrc',
340
+ '.prettierrc.json',
341
+ '.prettierrc.json5',
342
+ '.prettierrc.yaml',
343
+ '.prettierrc.yml',
344
+ '.prettierrc.js',
345
+ '.prettierrc.cjs',
346
+ '.prettierrc.mjs',
347
+ '.prettierrc.ts',
348
+ 'prettier.config.js',
349
+ 'prettier.config.cjs',
350
+ 'prettier.config.mjs',
351
+ 'prettier.config.ts',
352
+ ];
353
+
354
+ /** Whether any prettier config (file or package.json field) pulls in the toilscript plugin. */
355
+ function prettierPluginPresent(root: string, pkg: Record<string, unknown> | null): boolean {
356
+ if (pkg && pkg.prettier !== undefined && PRETTIER_MENTION.test(JSON.stringify(pkg.prettier))) {
357
+ return true;
358
+ }
359
+ for (const name of PRETTIER_CONFIG_FILES) {
360
+ const raw = readFile(path.join(root, name));
361
+ if (raw !== null && PRETTIER_MENTION.test(raw)) return true;
362
+ }
363
+ return false;
364
+ }
365
+
366
+ /**
367
+ * Adds `toiljs/prettier-plugin` to the project's prettier config so prettier can format the
368
+ * toilscript server. Handles the common cases (package.json `prettier`, a JSON `.prettierrc`,
369
+ * or no config at all); warns for shapes it can't safely edit (a JS config, or a string preset).
370
+ */
371
+ function applyPrettierFix(root: string, pkg: Record<string, unknown> | null): RpcFixResult {
372
+ const changed: string[] = [];
373
+ const skipped: string[] = [];
374
+ if (prettierPluginPresent(root, pkg)) return { changed, skipped };
375
+
376
+ // package.json "prettier" object.
377
+ const pkgPath = path.join(root, 'package.json');
378
+ const pkgConfig = pkg ? asRecord(pkg.prettier) : null;
379
+ if (pkgConfig !== null) {
380
+ const full = readJsonObject(pkgPath);
381
+ const target = full ? asRecord(full.prettier) : null;
382
+ if (full && target) {
383
+ target.plugins = [
384
+ ...(Array.isArray(target.plugins) ? target.plugins : []),
385
+ PRETTIER_PLUGIN,
386
+ ];
387
+ writeFile(pkgPath, JSON.stringify(full, null, 4) + '\n');
388
+ changed.push('package.json');
389
+ return { changed, skipped };
390
+ }
391
+ }
392
+
393
+ // A JSON .prettierrc / .prettierrc.json object.
394
+ for (const name of ['.prettierrc', '.prettierrc.json']) {
395
+ const filePath = path.join(root, name);
396
+ const raw = readFile(filePath);
397
+ if (raw === null) continue;
398
+ const obj = readJsonObject(filePath);
399
+ if (obj === null) {
400
+ skipped.push(`${name} (add "${PRETTIER_PLUGIN}" to plugins by hand)`);
401
+ return { changed, skipped };
402
+ }
403
+ obj.plugins = [...(Array.isArray(obj.plugins) ? obj.plugins : []), PRETTIER_PLUGIN];
404
+ writeFile(filePath, JSON.stringify(obj, null, 4) + '\n');
405
+ changed.push(name);
406
+ return { changed, skipped };
407
+ }
408
+
409
+ // A JS/TS config we can't safely edit.
410
+ const jsConfig = PRETTIER_CONFIG_FILES.find((name) => readFile(path.join(root, name)) !== null);
411
+ if (jsConfig) {
412
+ skipped.push(`${jsConfig} (add "${PRETTIER_PLUGIN}" to its plugins by hand)`);
413
+ return { changed, skipped };
414
+ }
415
+
416
+ // No config at all: create one.
417
+ writeFile(
418
+ path.join(root, '.prettierrc.json'),
419
+ JSON.stringify({ plugins: [PRETTIER_PLUGIN] }, null, 4) + '\n',
420
+ );
421
+ changed.push('.prettierrc.json');
422
+ return { changed, skipped };
423
+ }
424
+
86
425
  /** Reads the framework's own package.json (engines + peerDependencies) for the requirements. */
87
426
  function frameworkMeta(): { node: string; peers: Record<string, string> } {
88
427
  const pkgPath = path.resolve(
@@ -216,12 +555,7 @@ export async function runDoctor(opts: DoctorOptions): Promise<void> {
216
555
  ? toilconfig.entries.filter((e): e is string => typeof e === 'string')
217
556
  : [];
218
557
  missingEntries = entries.filter((e) => !fs.existsSync(path.join(root, e)));
219
- try {
220
- createRequire(path.join(root, 'package.json')).resolve('toilscript');
221
- toilscriptInstalled = true;
222
- } catch {
223
- toilscriptInstalled = false;
224
- }
558
+ toilscriptInstalled = isPackageInstalled(root, 'toilscript');
225
559
  const targets =
226
560
  typeof toilconfig.targets === 'object' && toilconfig.targets !== null
227
561
  ? (toilconfig.targets as Record<string, unknown>)
@@ -248,6 +582,23 @@ export async function runDoctor(opts: DoctorOptions): Promise<void> {
248
582
  const peerName = (n: string): Check => checkPeer(n, deps[n] ?? null, meta.peers[n] ?? '*');
249
583
  const peerChecks = Object.keys(meta.peers).map(peerName);
250
584
 
585
+ // Server tooling (RPC wiring + the prettier plugin): optionally fix in place, then re-read.
586
+ const rpcFix = serverPresent && opts.fix ? applyRpcFix(root) : null;
587
+ const prettierFix = serverPresent && opts.fix ? applyPrettierFix(root, projectPkg) : null;
588
+ const rpcFacts = gatherRpcFacts(root);
589
+ const restFacts = gatherRestFacts(root, toilconfig);
590
+ const prettierPresent = prettierPluginPresent(
591
+ root,
592
+ readJsonObject(path.join(root, 'package.json')),
593
+ );
594
+ const serverFix =
595
+ rpcFix || prettierFix
596
+ ? {
597
+ changed: [...(rpcFix?.changed ?? []), ...(prettierFix?.changed ?? [])],
598
+ skipped: [...(rpcFix?.skipped ?? []), ...(prettierFix?.skipped ?? [])],
599
+ }
600
+ : null;
601
+
251
602
  const groups: CheckGroup[] = [
252
603
  {
253
604
  title: 'Environment',
@@ -302,6 +653,9 @@ export async function runDoctor(opts: DoctorOptions): Promise<void> {
302
653
  checkServerEntry(missingEntries),
303
654
  checkToilscriptInstalled(toilscriptInstalled),
304
655
  checkWasmBuilt(wasmExists),
656
+ checkRpcWiring(rpcFacts),
657
+ checkRestDispatch(restFacts),
658
+ checkPrettierPlugin(prettierPresent),
305
659
  ]
306
660
  : [checkToilconfig(false)],
307
661
  },
@@ -309,10 +663,33 @@ export async function runDoctor(opts: DoctorOptions): Promise<void> {
309
663
 
310
664
  const summary = summarize(groups);
311
665
  if (opts.json) {
312
- process.stdout.write(JSON.stringify({ groups, summary }, null, 2) + '\n');
666
+ process.stdout.write(JSON.stringify({ groups, summary, fixed: serverFix }, null, 2) + '\n');
313
667
  } else {
314
668
  process.stdout.write('\n' + accent(' Doctor') + dim(` ${root}`) + '\n\n');
315
669
  renderHuman(groups);
670
+ if (serverFix) renderRpcFix(serverFix);
671
+ else if (opts.fix && !serverPresent) {
672
+ process.stdout.write(
673
+ ' ' + dim('--fix: no server (toilconfig.json) found, nothing to wire.') + '\n\n',
674
+ );
675
+ }
316
676
  }
317
677
  if (hasFailures(summary)) process.exitCode = 1;
318
678
  }
679
+
680
+ /** Prints the result of `--fix`, and whether a reinstall is needed (toilscript bump). */
681
+ function renderRpcFix(result: RpcFixResult): void {
682
+ const out: string[] = [];
683
+ if (result.changed.length > 0) {
684
+ out.push(' ' + success('fixed RPC wiring') + dim(` ${result.changed.join(', ')}`));
685
+ if (result.changed.includes('package.json')) {
686
+ out.push(
687
+ ' ' + dim('run your installer (npm/pnpm/yarn) if the toilscript version changed.'),
688
+ );
689
+ }
690
+ } else {
691
+ out.push(' ' + dim('RPC wiring already in place, nothing to fix.'));
692
+ }
693
+ for (const item of result.skipped) out.push(' ' + warn('skipped') + dim(` ${item}`));
694
+ process.stdout.write(out.join('\n') + '\n\n');
695
+ }
package/src/cli/index.ts CHANGED
@@ -16,6 +16,7 @@ import { accent, banner, bold, danger, dim, success, version } from './ui.js';
16
16
  interface Flags {
17
17
  root?: string;
18
18
  port?: number;
19
+ host?: string;
19
20
  name?: string;
20
21
  template?: Template;
21
22
  preprocessor?: Preprocessor;
@@ -27,6 +28,7 @@ interface Flags {
27
28
  pm?: string;
28
29
  yes?: boolean;
29
30
  json?: boolean;
31
+ fix?: boolean;
30
32
  target?: string;
31
33
  }
32
34
 
@@ -43,6 +45,9 @@ function parseArgs(argv: string[]): Flags {
43
45
  if (!Number.isNaN(port)) flags.port = port;
44
46
  break;
45
47
  }
48
+ case '--host':
49
+ flags.host = argv[++i];
50
+ break;
46
51
  case '--template':
47
52
  case '-t': {
48
53
  const t = argv[++i];
@@ -95,6 +100,9 @@ function parseArgs(argv: string[]): Flags {
95
100
  case '--json':
96
101
  flags.json = true;
97
102
  break;
103
+ case '--fix':
104
+ flags.fix = true;
105
+ break;
98
106
  case '--target':
99
107
  flags.target = argv[++i];
100
108
  break;
@@ -130,6 +138,7 @@ function printHelp(): void {
130
138
  cmd('-y, --yes', 'create: accept defaults (non-interactive)'),
131
139
  cmd('--no-install', "create: don't install dependencies"),
132
140
  cmd('--json', 'doctor: machine-readable output'),
141
+ cmd('--fix', 'doctor: auto-fix what it can (typed-RPC wiring)'),
133
142
  cmd('--target <t>', 'update: latest | minor | patch | newest | greatest'),
134
143
  cmd('-v, --version', 'print the toiljs version'),
135
144
  '',
@@ -193,7 +202,7 @@ async function main(): Promise<void> {
193
202
  case 'start': {
194
203
  banner();
195
204
  process.stdout.write(dim(' self-hosting the built app…') + '\n\n');
196
- const server = await start({ root: flags.root, port: flags.port });
205
+ const server = await start({ root: flags.root, port: flags.port, host: flags.host });
197
206
  process.stdout.write(
198
207
  accent(' ➜ ') +
199
208
  bold(`http://localhost:${String(server.port)}`) +
@@ -206,7 +215,7 @@ async function main(): Promise<void> {
206
215
  case 'doctor':
207
216
  // Skip the banner for --json so stdout stays valid JSON.
208
217
  if (!flags.json) banner();
209
- await runDoctor({ root: flags.root, cwd: process.cwd(), json: flags.json });
218
+ await runDoctor({ root: flags.root, cwd: process.cwd(), json: flags.json, fix: flags.fix });
210
219
  break;
211
220
 
212
221
  case 'update':
@@ -587,16 +587,56 @@ const DOC_LINKS: { label: string; slug: string }[] = [
587
587
  { label: 'Parallel routes and slots', slug: 'slots' },
588
588
  ];
589
589
 
590
- function AiTab({ info }: { info: DevInfo | null }): ReactNode {
591
- useCurrentUrl(); // rebuild page context on navigation
590
+ /** Max chars of route source to inline into the prompt (keeps the hand-off URL usable). */
591
+ const AI_CODE_MAX = 8000;
592
+
593
+ function AiTab({ info, routes }: { info: DevInfo | null; routes: RouteDef[] }): ReactNode {
594
+ const url = useCurrentUrl(); // rebuild page context + refetch source on navigation
592
595
  const [question, setQuestion] = useState('');
593
596
  const [answer, setAnswer] = useState<string | null>(null);
594
597
  const [busy, setBusy] = useState(false);
598
+ const [source, setSource] = useState<{ file: string; code: string } | null>(null);
595
599
  const configured = info?.ai === true;
596
600
 
601
+ // Resolve the current route's source file (pattern -> absolute path from the dev server).
602
+ const pathname = url.split('?')[0];
603
+ let file: string | undefined;
604
+ for (const r of routes) {
605
+ if (matchRoute(r.pattern, pathname)) {
606
+ file = info?.routes[r.pattern];
607
+ break;
608
+ }
609
+ }
610
+
611
+ useEffect(() => {
612
+ if (!file) {
613
+ setSource(null);
614
+ return;
615
+ }
616
+ let cancelled = false;
617
+ void fetch(`/__toil/source?file=${encodeURIComponent(file)}`)
618
+ .then((r) => (r.ok ? r.text() : null))
619
+ .then((code) => {
620
+ if (!cancelled) setSource(code !== null ? { file, code } : null);
621
+ })
622
+ .catch(() => {
623
+ if (!cancelled) setSource(null);
624
+ });
625
+ return () => {
626
+ cancelled = true;
627
+ };
628
+ }, [file]);
629
+
597
630
  const prompt = (): string => {
598
631
  const q = question.trim() || 'Explain this page and suggest improvements.';
599
- return `${buildAiContext()}\n\nQuestion: ${q}`;
632
+ const parts = [buildAiContext()];
633
+ if (source) {
634
+ const code = source.code.slice(0, AI_CODE_MAX);
635
+ const cut = source.code.length > AI_CODE_MAX ? '\n... (truncated)' : '';
636
+ parts.push(`\nPage source (${source.file}):\n\`\`\`tsx\n${code}${cut}\n\`\`\``);
637
+ }
638
+ parts.push(`\nQuestion: ${q}`);
639
+ return parts.join('\n');
600
640
  };
601
641
  const handOff = (base: string): void => {
602
642
  window.open(`${base}${encodeURIComponent(prompt())}`, '_blank', 'noopener');
@@ -668,6 +708,11 @@ function AiTab({ info }: { info: DevInfo | null }): ReactNode {
668
708
  </button>
669
709
  )}
670
710
  </div>
711
+ {source && (
712
+ <p className="toil-dt-k">
713
+ Prompt includes this route&apos;s source ({source.file.split('/').pop()}).
714
+ </p>
715
+ )}
671
716
  {!configured && (
672
717
  <p className="toil-dt-k">
673
718
  Inline answers are off. Set <span className="toil-dt-tag">devtools.ai</span> in
@@ -963,7 +1008,7 @@ export function DevToolbar({
963
1008
  {p.tab === 'head' && <HeadTab />}
964
1009
  {p.tab === 'build' && <BuildTab info={info} />}
965
1010
  {p.tab === 'errors' && <ErrorsTab />}
966
- {p.tab === 'ai' && <AiTab info={info} />}
1011
+ {p.tab === 'ai' && <AiTab info={info} routes={routes} />}
967
1012
  {p.tab === 'prefs' && <PrefsTab />}
968
1013
  </div>
969
1014
  </div>