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.
- package/CHANGELOG.md +128 -0
- package/README.md +313 -128
- package/as-pect.config.js +1 -1
- package/build/backend/.tsbuildinfo +1 -1
- package/build/backend/index.d.ts +1 -0
- package/build/backend/index.js +20 -1
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/index.js +1320 -697
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/dev/devtools.js +42 -5
- package/build/client/errors.d.ts +1 -0
- package/build/client/errors.js +3 -0
- package/build/client/index.d.ts +2 -0
- package/build/client/index.js +2 -0
- package/build/client/rpc.d.ts +1 -0
- package/build/client/rpc.js +37 -0
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/config.js +3 -1
- package/build/compiler/docs.js +69 -7
- package/build/compiler/generate.js +5 -4
- package/build/compiler/index.d.ts +1 -0
- package/build/compiler/index.js +30 -1
- package/build/compiler/plugin.js +80 -8
- package/build/compiler/seo.js +15 -1
- package/build/compiler/ssg.js +7 -1
- package/build/compiler/vite.js +25 -0
- package/build/io/.tsbuildinfo +1 -1
- package/build/io/codec.d.ts +54 -0
- package/build/io/codec.js +143 -0
- package/build/io/index.d.ts +1 -2
- package/build/io/index.js +1 -2
- package/eslint.config.js +1 -1
- package/examples/basic/client/routes/features/index.tsx +1 -1
- package/examples/basic/client/routes/io.tsx +6 -7
- package/examples/basic/client/routes/rest.tsx +84 -0
- package/examples/basic/client/routes/rpc.tsx +43 -0
- package/package.json +19 -7
- package/presets/prettier-plugin.js +51 -0
- package/presets/prettier.json +1 -0
- package/server/runtime/README.md +97 -0
- package/server/runtime/abort/abort.ts +27 -0
- package/server/runtime/env/Server.ts +61 -0
- package/server/runtime/envelope.ts +191 -0
- package/server/runtime/exports/index.ts +52 -0
- package/server/runtime/handlers/ToilHandler.ts +34 -0
- package/server/runtime/index.ts +26 -0
- package/server/runtime/lang/Potential.ts +5 -0
- package/server/runtime/memory.ts +81 -0
- package/server/runtime/request.ts +55 -0
- package/server/runtime/response.ts +86 -0
- package/server/runtime/rest/Rest.ts +39 -0
- package/server/runtime/rest/RestHandler.ts +20 -0
- package/server/runtime/rest/RouteContext.ts +82 -0
- package/server/runtime/rest/match.ts +48 -0
- package/server/runtime/tsconfig.json +7 -0
- package/src/backend/index.ts +45 -3
- package/src/cli/create.ts +16 -6
- package/src/cli/diagnostics.ts +81 -0
- package/src/cli/doctor.ts +384 -7
- package/src/cli/index.ts +11 -2
- package/src/client/dev/devtools.tsx +49 -4
- package/src/client/errors.ts +11 -0
- package/src/client/index.ts +2 -0
- package/src/client/rpc.ts +64 -0
- package/src/compiler/config.ts +3 -1
- package/src/compiler/docs.ts +69 -7
- package/src/compiler/generate.ts +6 -5
- package/src/compiler/index.ts +50 -1
- package/src/compiler/plugin.ts +99 -11
- package/src/compiler/seo.ts +23 -3
- package/src/compiler/ssg.ts +10 -1
- package/src/compiler/vite.ts +34 -0
- package/src/io/FastMap.ts +24 -0
- package/src/io/FastSet.ts +15 -1
- package/src/io/codec.ts +217 -0
- package/src/io/index.ts +1 -2
- package/src/io/types.ts +2 -1
- package/test/assembly/example.spec.ts +14 -4
- package/test/doctor.test.ts +65 -0
- package/test/errors.test.ts +21 -0
- package/test/io.test.ts +65 -41
- package/test/prettier-plugin.test.ts +46 -0
- package/test/rpc.test.ts +50 -0
- package/tests/data-parity/generated-parity.ts +99 -0
- package/tests/data-parity/parity.ts +80 -0
- package/tests/data-parity/spec.ts +46 -0
- package/tsconfig.json +1 -1
- package/tsconfig.server.json +1 -1
- package/build/io/BinaryReader.d.ts +0 -44
- package/build/io/BinaryReader.js +0 -244
- package/build/io/BinaryWriter.d.ts +0 -44
- package/build/io/BinaryWriter.js +0 -297
- package/build/server/release.wasm +0 -0
- package/build/server/release.wat +0 -9
- package/src/io/BinaryReader.ts +0 -340
- package/src/io/BinaryWriter.ts +0 -385
- package/src/server/index.ts +0 -10
- package/src/server/main.ts +0 -13
- package/src/server/tsconfig.json +0 -4
- package/toilconfig.json +0 -30
package/src/cli/diagnostics.ts
CHANGED
|
@@ -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: @rest/@route HTTP layer + RPC codegen + hardened decoders + @data editor decls. */
|
|
406
|
+
export const RPC_TOILSCRIPT_MIN = '0.1.13';
|
|
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
|
-
|
|
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
|
-
|
|
591
|
-
|
|
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
|
-
|
|
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'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>
|