toiljs 0.0.112 → 0.0.114

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 (46) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/CHANGELOG.md +10 -0
  3. package/build/backend/.tsbuildinfo +1 -1
  4. package/build/cli/.tsbuildinfo +1 -1
  5. package/build/cli/index.js +145 -27
  6. package/build/client/.tsbuildinfo +1 -1
  7. package/build/compiler/.tsbuildinfo +1 -1
  8. package/build/compiler/index.js +1 -1
  9. package/build/compiler/pages.js +1 -13
  10. package/build/compiler/prerender.d.ts +1 -0
  11. package/build/compiler/prerender.js +39 -2
  12. package/build/compiler/toil-docs.generated.js +4 -4
  13. package/build/devserver/.tsbuildinfo +1 -1
  14. package/build/devserver/analytics/index.d.ts +2 -0
  15. package/build/devserver/analytics/index.js +15 -9
  16. package/build/devserver/daemon/host.d.ts +2 -1
  17. package/build/devserver/daemon/host.js +12 -12
  18. package/build/devserver/daemon/index.js +3 -3
  19. package/build/io/.tsbuildinfo +1 -1
  20. package/build/logger/.tsbuildinfo +1 -1
  21. package/build/shared/.tsbuildinfo +1 -1
  22. package/docs/background/daemons.md +49 -3
  23. package/docs/cli/README.md +4 -2
  24. package/docs/getting-started/installation.md +18 -0
  25. package/docs/services/analytics.md +28 -17
  26. package/examples/basic/server/routes/Analytics.ts +6 -5
  27. package/package.json +15 -15
  28. package/src/cli/create.ts +1 -0
  29. package/src/cli/diagnostics.ts +74 -0
  30. package/src/cli/doctor.ts +105 -27
  31. package/src/cli/update.ts +30 -3
  32. package/src/cli/updates.ts +23 -0
  33. package/src/compiler/index.ts +11 -2
  34. package/src/compiler/pages.ts +1 -22
  35. package/src/compiler/prerender.ts +57 -3
  36. package/src/compiler/toil-docs.generated.ts +4 -4
  37. package/src/devserver/analytics/index.ts +40 -17
  38. package/src/devserver/daemon/host.ts +29 -19
  39. package/src/devserver/daemon/index.ts +12 -5
  40. package/test/analytics-dev.test.ts +8 -8
  41. package/test/daemon-emulation.test.ts +44 -2
  42. package/test/doctor.test.ts +28 -0
  43. package/test/fixtures/daemon-app.ts +9 -4
  44. package/test/prerender.test.ts +64 -2
  45. package/test/update.test.ts +15 -1
  46. package/tsconfig.base.json +0 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "toiljs",
3
3
  "type": "module",
4
- "version": "0.0.112",
4
+ "version": "0.0.114",
5
5
  "author": "Dacely",
6
6
  "description": "The modern React framework: a file-based React frontend and a ToilScript-compiled WebAssembly backend.",
7
7
  "repository": {
@@ -124,10 +124,10 @@
124
124
  "@dacely/hyper-express": "6.17.4",
125
125
  "@dacely/noble-post-quantum": "^0.6.1",
126
126
  "@dacely/toilscript-loader": "^0.1.0",
127
- "@eslint-react/eslint-plugin": "^5.10.1",
127
+ "@eslint-react/eslint-plugin": "^5.13.2",
128
128
  "@eslint/js": "^10.0.1",
129
129
  "@noble/curves": "^2.2.0",
130
- "@typescript-eslint/utils": "^8.62.1",
130
+ "@typescript-eslint/utils": "^8.63.0",
131
131
  "@vitejs/plugin-react": "^6.0.3",
132
132
  "eslint-plugin-react-hooks": "^7.1.1",
133
133
  "eslint-plugin-react-refresh": "^0.5.3",
@@ -136,9 +136,9 @@
136
136
  "nodemailer": "^9.0.3",
137
137
  "picocolors": "^1.1.1",
138
138
  "sharp": "^0.35.3",
139
- "toilscript": "^0.1.56",
140
- "typescript-eslint": "^8.62.1",
141
- "vite": "^8.1.3",
139
+ "toilscript": "^0.1.60",
140
+ "typescript-eslint": "^8.63.0",
141
+ "vite": "^8.1.4",
142
142
  "vite-imagetools": "^10.0.1",
143
143
  "vite-plugin-node-polyfills": "^0.28.0"
144
144
  },
@@ -147,8 +147,8 @@
147
147
  "prettier": ">=3.0.0",
148
148
  "react": ">=18.0.0",
149
149
  "react-dom": ">=18.0.0",
150
- "toilscript": ">=0.1.56",
151
- "typescript": ">=6.0.0"
150
+ "toilscript": ">=0.1.60",
151
+ "typescript": ">=6.0.0 <7.0.0"
152
152
  },
153
153
  "overrides": {
154
154
  "elliptic": "file:./stubs/elliptic"
@@ -157,25 +157,25 @@
157
157
  "@babel/core": "^8.0.1",
158
158
  "@babel/preset-env": "^8.0.2",
159
159
  "@babel/preset-typescript": "^8.0.1",
160
- "@clack/prompts": "^1.6.0",
160
+ "@clack/prompts": "^1.7.0",
161
161
  "@microsoft/api-extractor": "7.58.9",
162
162
  "@testing-library/dom": "^10.4.1",
163
163
  "@testing-library/react": "^16.3.2",
164
- "@types/node": "^26.1.0",
164
+ "@types/node": "^26.1.1",
165
165
  "@types/nodemailer": "^8.0.1",
166
166
  "@types/react": "^19.2.17",
167
167
  "@types/react-dom": "^19.2.3",
168
- "@vitest/coverage-v8": "^4.1.9",
169
- "@vitest/ui": "^4.1.9",
168
+ "@vitest/coverage-v8": "^4.1.10",
169
+ "@vitest/ui": "^4.1.10",
170
170
  "esbuild": "^0.28.1",
171
171
  "eslint": "^10.6.0",
172
172
  "jsdom": "^29.1.1",
173
173
  "micromatch": "^4.0.8",
174
- "prettier": "^3.9.4",
174
+ "prettier": "^3.9.5",
175
175
  "react": "^19.2.7",
176
176
  "react-dom": "^19.2.7",
177
- "typedoc": "^0.28.19",
177
+ "typedoc": "^0.28.20",
178
178
  "typescript": "^6.0.3",
179
- "vitest": "^4.1.9"
179
+ "vitest": "^4.1.10"
180
180
  }
181
181
  }
package/src/cli/create.ts CHANGED
@@ -157,6 +157,7 @@ function scaffold(
157
157
  private: true,
158
158
  type: 'module',
159
159
  scripts: {
160
+ start: 'toiljs start',
160
161
  dev: 'toiljs dev',
161
162
  build: 'toiljs build',
162
163
  'build:server': 'toiljs build --server',
@@ -83,6 +83,80 @@ export function checkPeer(name: string, installed: string | null, range: string)
83
83
  };
84
84
  }
85
85
 
86
+ /** The TypeScript range toiljs supports, and the range `doctor --fix` pins an unsupported one to. */
87
+ export const TYPESCRIPT_SUPPORTED = '>=6.0.0 <7.0.0';
88
+ export const TYPESCRIPT_FIX_RANGE = '^6.0.3';
89
+
90
+ /** First TypeScript major that dropped the JavaScript compiler API (the native port). */
91
+ const TYPESCRIPT_NATIVE_MAJOR = 7;
92
+
93
+ /**
94
+ * The major a dependency range floats to: the first number in the range, once the comparators are
95
+ * stripped (`^7.0.2` -> 7, `>=6.0.0 <7.0.0` -> 6). Returns null for a range that names no version
96
+ * (`*`, `latest`), which is treated as "unpinned" rather than "unsupported".
97
+ */
98
+ export function rangeMajor(range: string): number | null {
99
+ const m = /(\d+)/.exec(range.replace(/^[\s^~>=<v]*/, ''));
100
+ return m ? Number(m[1]) : null;
101
+ }
102
+
103
+ /**
104
+ * TypeScript 7 is the native (Go) port: it publishes `tsc` but no longer exports the JavaScript
105
+ * compiler API, whose main entry is now just `{ version, versionMajorMinor }`. toiljs reads each
106
+ * route's static `metadata` through that API to bake SEO tags into the built HTML, and the eslint
107
+ * and typedoc presets load it too. So a TS 7 install does not fail loudly, it silently stops baking
108
+ * metadata (and hard-crashes typescript-eslint), which is worth a `fail` rather than a warning.
109
+ *
110
+ * `installed` is the version resolved from node_modules (authoritative, it is what actually runs);
111
+ * `declared` is the project's package.json range, which is flagged when it *admits* 7 even if the
112
+ * currently-installed copy is older, since the next install would break.
113
+ */
114
+ export function checkTypeScript(installed: string | null, declared: string | null): Check {
115
+ const id = 'peer:typescript';
116
+ const label = 'typescript';
117
+ const unsupported = (version: string, detail: string): Check => ({
118
+ id,
119
+ label,
120
+ status: 'fail',
121
+ detail,
122
+ fix:
123
+ `TypeScript ${version} removed the JavaScript compiler API (it moved to ` +
124
+ `'typescript/unstable/*'), which toiljs's route-metadata extractor, the eslint preset, ` +
125
+ `and typedoc all load. Pin typescript to "${TYPESCRIPT_FIX_RANGE}" and reinstall ` +
126
+ `(\`toiljs doctor --fix\` does this).`,
127
+ });
128
+
129
+ if (installed === null) {
130
+ return {
131
+ id,
132
+ label,
133
+ status: 'fail',
134
+ detail: `not installed (requires ${TYPESCRIPT_SUPPORTED})`,
135
+ fix: `Install typescript@"${TYPESCRIPT_FIX_RANGE}".`,
136
+ };
137
+ }
138
+
139
+ const installedMajor = rangeMajor(installed);
140
+ if (installedMajor !== null && installedMajor >= TYPESCRIPT_NATIVE_MAJOR) {
141
+ return unsupported(String(installedMajor), `${installed} installed, unsupported`);
142
+ }
143
+
144
+ // Installed copy is fine, but the declared range would pull an unsupported major next install.
145
+ const declaredMajor = declared === null ? null : rangeMajor(declared);
146
+ if (declaredMajor !== null && declaredMajor >= TYPESCRIPT_NATIVE_MAJOR) {
147
+ return unsupported(String(declaredMajor), `package.json declares ${declared ?? ''}`);
148
+ }
149
+
150
+ const ok = satisfiesMin(installed, TYPESCRIPT_SUPPORTED);
151
+ return {
152
+ id,
153
+ label,
154
+ status: ok ? 'pass' : 'warn',
155
+ detail: `${installed} (requires ${TYPESCRIPT_SUPPORTED})`,
156
+ fix: ok ? undefined : `Update typescript to ${TYPESCRIPT_SUPPORTED}.`,
157
+ };
158
+ }
159
+
86
160
  export function checkPackageManager(lockfiles: readonly string[]): Check {
87
161
  if (lockfiles.length === 0) {
88
162
  return {
package/src/cli/doctor.ts CHANGED
@@ -46,15 +46,18 @@ import {
46
46
  checkToilconfig,
47
47
  checkToiljsInstalled,
48
48
  checkToilscriptInstalled,
49
+ checkTypeScript,
49
50
  checkWasmBuilt,
50
51
  findRelativeAssets,
51
52
  hasFailures,
53
+ rangeMajor,
52
54
  type RestFacts,
53
55
  RPC_TOILSCRIPT_MIN,
54
56
  type RpcFacts,
55
57
  satisfiesMin,
56
58
  type SourceFile,
57
59
  summarize,
60
+ TYPESCRIPT_FIX_RANGE,
58
61
  } from './diagnostics.js';
59
62
  import {
60
63
  detectTailwind,
@@ -70,7 +73,7 @@ export interface DoctorOptions {
70
73
  readonly cwd: string;
71
74
  /** Emit machine-readable JSON instead of the human report. */
72
75
  readonly json?: boolean;
73
- /** Auto-fix what can be fixed in place (currently the typed-RPC wiring). */
76
+ /** Auto-fix what can be fixed in place (the typed-RPC wiring, and an unsupported typescript). */
74
77
  readonly fix?: boolean;
75
78
  }
76
79
 
@@ -129,6 +132,72 @@ function isPackageInstalled(root: string, name: string): boolean {
129
132
  }
130
133
  }
131
134
 
135
+ /**
136
+ * The version of `name` actually installed for the project at `root`, or null when absent. Reads the
137
+ * resolved package.json rather than trusting the declared range, since the installed copy is what
138
+ * the compiler API consumers load. Falls back to walking `node_modules` (see `isPackageInstalled`).
139
+ */
140
+ function installedVersion(root: string, name: string): string | null {
141
+ const require = createRequire(path.join(root, 'package.json'));
142
+ let pkgPath: string | null = null;
143
+ try {
144
+ pkgPath = require.resolve(`${name}/package.json`);
145
+ } catch {
146
+ for (let dir = root; pkgPath === null; ) {
147
+ const candidate = path.join(dir, 'node_modules', name, 'package.json');
148
+ if (fs.existsSync(candidate)) pkgPath = candidate;
149
+ const parent = path.dirname(dir);
150
+ if (parent === dir) break;
151
+ dir = parent;
152
+ }
153
+ }
154
+ if (pkgPath === null) return null;
155
+ const pkg = readJsonObject(pkgPath);
156
+ return pkg && typeof pkg.version === 'string' ? pkg.version : null;
157
+ }
158
+
159
+ /**
160
+ * Pins an unsupported TypeScript (the native 7.x, which ships no JavaScript compiler API) back to a
161
+ * range toiljs can drive. Rewrites whichever of `devDependencies`/`dependencies` declares it; a
162
+ * project with no declaration at all is left alone (nothing to pin), and the reinstall is left to
163
+ * the user since only they know their package manager.
164
+ */
165
+ function applyTypeScriptFix(root: string): RpcFixResult {
166
+ const changed: string[] = [];
167
+ const skipped: string[] = [];
168
+
169
+ const pkgPath = path.join(root, 'package.json');
170
+ const pkg = readJsonObject(pkgPath);
171
+ if (pkg === null) return { changed, skipped };
172
+
173
+ const field = (['devDependencies', 'dependencies'] as const).find((f) => {
174
+ const deps = asRecord(pkg[f]);
175
+ return deps !== null && typeof deps.typescript === 'string';
176
+ });
177
+ if (field === undefined) {
178
+ // Nothing declares typescript: it is either absent or hoisted from a workspace root.
179
+ if (installedVersion(root, 'typescript') !== null) {
180
+ skipped.push('typescript (installed but not declared in package.json; pin it by hand)');
181
+ }
182
+ return { changed, skipped };
183
+ }
184
+
185
+ const deps = asRecord(pkg[field]);
186
+ if (deps === null) return { changed, skipped };
187
+ const declared = deps.typescript as string;
188
+ const declaredMajor = rangeMajor(declared);
189
+ const installedMajor = rangeMajor(installedVersion(root, 'typescript') ?? '');
190
+ const unsupported =
191
+ (declaredMajor !== null && declaredMajor >= 7) ||
192
+ (installedMajor !== null && installedMajor >= 7);
193
+ if (!unsupported) return { changed, skipped };
194
+
195
+ deps.typescript = TYPESCRIPT_FIX_RANGE;
196
+ writeFile(pkgPath, JSON.stringify(pkg, null, 4) + '\n');
197
+ changed.push(`package.json (typescript ${declared} -> ${TYPESCRIPT_FIX_RANGE})`);
198
+ return { changed, skipped };
199
+ }
200
+
132
201
  /** Narrows a value to a plain (non-array) object, or null. */
133
202
  function asRecord(value: unknown): Record<string, unknown> | null {
134
203
  return typeof value === 'object' && value !== null && !Array.isArray(value)
@@ -764,7 +833,26 @@ export async function runDoctor(opts: DoctorOptions): Promise<void> {
764
833
  }
765
834
 
766
835
  const peerName = (n: string): Check => checkPeer(n, deps[n] ?? null, meta.peers[n] ?? '*');
767
- const peerChecks = Object.keys(meta.peers).map(peerName);
836
+ // typescript gets its own check: its peer range is the only one whose upper bound matters (7.x
837
+ // clears the `>=6.0.0` floor but ships no compiler API), and it is fixable in place.
838
+ const peerChecks = Object.keys(meta.peers)
839
+ .filter((n) => n !== 'typescript')
840
+ .map(peerName);
841
+ const typeScriptFix = opts.fix ? applyTypeScriptFix(root) : null;
842
+ // Re-read the declared range: `--fix` may have just rewritten it.
843
+ const declaredTypeScript = (() => {
844
+ const pkg = readJsonObject(path.join(root, 'package.json'));
845
+ if (pkg === null) return deps.typescript ?? null;
846
+ for (const field of ['devDependencies', 'dependencies'] as const) {
847
+ const range = asRecord(pkg[field])?.typescript;
848
+ if (typeof range === 'string') return range;
849
+ }
850
+ return null;
851
+ })();
852
+ const typeScriptCheck = checkTypeScript(
853
+ installedVersion(root, 'typescript'),
854
+ declaredTypeScript,
855
+ );
768
856
 
769
857
  // Server tooling (RPC wiring + the prettier plugin + the editor LS plugin): optionally fix in
770
858
  // place, then re-read.
@@ -784,19 +872,15 @@ export async function runDoctor(opts: DoctorOptions): Promise<void> {
784
872
  const serverTsParsed = serverTsPath ? readJsonObject(serverTsPath) : null;
785
873
  const serverTsPluginPresent =
786
874
  serverTsPath === null || serverTsParsed === null ? true : tsconfigHasToilPlugin(serverTsParsed);
875
+ // The typescript pin is fixable without a server; the rest only apply to one.
876
+ const applied = [typeScriptFix, rpcFix, prettierFix, editorFix].filter(
877
+ (fix): fix is RpcFixResult => fix !== null,
878
+ );
787
879
  const serverFix =
788
- rpcFix || prettierFix || editorFix
880
+ applied.length > 0
789
881
  ? {
790
- changed: [
791
- ...(rpcFix?.changed ?? []),
792
- ...(prettierFix?.changed ?? []),
793
- ...(editorFix?.changed ?? []),
794
- ],
795
- skipped: [
796
- ...(rpcFix?.skipped ?? []),
797
- ...(prettierFix?.skipped ?? []),
798
- ...(editorFix?.skipped ?? []),
799
- ],
882
+ changed: applied.flatMap((fix) => fix.changed),
883
+ skipped: applied.flatMap((fix) => fix.skipped),
800
884
  }
801
885
  : null;
802
886
 
@@ -807,6 +891,7 @@ export async function runDoctor(opts: DoctorOptions): Promise<void> {
807
891
  checkNode(process.versions.node, meta.node),
808
892
  checkToiljsInstalled('toiljs' in deps ? version() : null),
809
893
  ...peerChecks,
894
+ typeScriptCheck,
810
895
  checkPackageManager(LOCKFILES.filter((f) => fs.existsSync(path.join(root, f)))),
811
896
  ],
812
897
  },
@@ -876,28 +961,21 @@ export async function runDoctor(opts: DoctorOptions): Promise<void> {
876
961
  } else {
877
962
  process.stdout.write('\n' + accent(' Doctor') + dim(` ${root}`) + '\n\n');
878
963
  renderHuman(groups);
879
- if (serverFix) renderRpcFix(serverFix);
880
- else if (opts.fix && !serverPresent) {
881
- process.stdout.write(
882
- ' ' + dim('--fix: no server (toilconfig.json) found, nothing to wire.') + '\n\n',
883
- );
884
- }
964
+ if (serverFix) renderFix(serverFix);
885
965
  }
886
966
  if (hasFailures(summary)) process.exitCode = 1;
887
967
  }
888
968
 
889
- /** Prints the result of `--fix`, and whether a reinstall is needed (toilscript bump). */
890
- function renderRpcFix(result: RpcFixResult): void {
969
+ /** Prints the result of `--fix`, and whether a reinstall is needed (a changed dependency range). */
970
+ function renderFix(result: RpcFixResult): void {
891
971
  const out: string[] = [];
892
972
  if (result.changed.length > 0) {
893
- out.push(' ' + success('fixed server wiring') + dim(` ${result.changed.join(', ')}`));
894
- if (result.changed.includes('package.json')) {
895
- out.push(
896
- ' ' + dim('run your installer (npm/pnpm/yarn) if the toilscript version changed.'),
897
- );
973
+ out.push(' ' + success('fixed') + dim(` ${result.changed.join(', ')}`));
974
+ if (result.changed.some((entry) => entry.startsWith('package.json'))) {
975
+ out.push(' ' + dim('run your installer (npm/pnpm/yarn) to apply the version changes.'));
898
976
  }
899
977
  } else {
900
- out.push(' ' + dim('server wiring already in place, nothing to fix.'));
978
+ out.push(' ' + dim('nothing to fix.'));
901
979
  }
902
980
  for (const item of result.skipped) out.push(' ' + warn('skipped') + dim(` ${item}`));
903
981
  process.stdout.write(out.join('\n') + '\n\n');
package/src/cli/update.ts CHANGED
@@ -11,7 +11,7 @@ import { cancel, intro, isCancel, multiselect, note, outro, spinner } from '@cla
11
11
 
12
12
  import { MIGRATIONS_README } from './create.js';
13
13
  import { capture, run } from './proc.js';
14
- import { buildRows, type Bump, parseNcuJson, type UpdateRow } from './updates.js';
14
+ import { buildRows, type Bump, parseNcuJson, type UpdateRow, withheldUpgrades } from './updates.js';
15
15
  import { accent, danger, dim, success, warn } from './ui.js';
16
16
 
17
17
  export interface UpdateOptions {
@@ -109,6 +109,20 @@ function rowLine(row: UpdateRow): string {
109
109
  return `${row.name} ${dim(row.from)} ${dim('->')} ${bumpColor(row.bump, row.to)}`;
110
110
  }
111
111
 
112
+ /** Tells the user which upgrades were held back, and why they are not simply missing. */
113
+ function noteWithheld(names: readonly string[]): void {
114
+ note(
115
+ names.map((n) => `${dim('-')} ${n}`).join('\n') +
116
+ '\n\n' +
117
+ dim(
118
+ 'Held back: this major is not supported by toiljs yet. TypeScript 7 is the native\n' +
119
+ 'port and ships no JavaScript compiler API, so route metadata would stop being\n' +
120
+ 'baked into the built HTML and typescript-eslint would not load.',
121
+ ),
122
+ warn('Not upgraded'),
123
+ );
124
+ }
125
+
112
126
  const TARGETS = new Set(['latest', 'minor', 'patch', 'newest', 'greatest']);
113
127
 
114
128
  export async function runUpdate(opts: UpdateOptions): Promise<void> {
@@ -152,13 +166,21 @@ export async function runUpdate(opts: UpdateOptions): Promise<void> {
152
166
  process.exitCode = 1;
153
167
  return;
154
168
  }
155
- const rows = buildRows(parseNcuJson(res.stdout), currentDeps);
169
+ // Hold back upgrades into a major toiljs cannot run on (typescript 7), so neither the picker nor
170
+ // a `-y` run can install one. `--reject` keeps ncu from applying them during `-u` below.
171
+ const upgraded = parseNcuJson(res.stdout);
172
+ const withheld = withheldUpgrades(upgraded);
173
+ for (const name of withheld) delete upgraded[name];
174
+
175
+ const rows = buildRows(upgraded, currentDeps);
156
176
  if (rows.length === 0) {
157
177
  s.stop('Everything is up to date');
178
+ if (withheld.length > 0) noteWithheld(withheld);
158
179
  outro(success('Nothing to update.'));
159
180
  return;
160
181
  }
161
182
  s.stop(`${String(rows.length)} update${rows.length === 1 ? '' : 's'} available`);
183
+ if (withheld.length > 0) noteWithheld(withheld);
162
184
 
163
185
  const counts = { major: 0, minor: 0, patch: 0, other: 0 };
164
186
  for (const r of rows) counts[r.bump]++;
@@ -195,7 +217,12 @@ export async function runUpdate(opts: UpdateOptions): Promise<void> {
195
217
 
196
218
  s.start('Updating package.json');
197
219
  const applyAll = selected.length === rows.length;
198
- await run('npx', ncuArgs(applyAll ? ['-u'] : ['-u', '--filter', selected.join(' ')]), root);
220
+ const reject = withheld.length > 0 ? ['--reject', withheld.join(' ')] : [];
221
+ await run(
222
+ 'npx',
223
+ ncuArgs(applyAll ? ['-u', ...reject] : ['-u', '--filter', selected.join(' '), ...reject]),
224
+ root,
225
+ );
199
226
  s.stop('package.json updated');
200
227
 
201
228
  // Run the install with VISIBLE output (so a failure is diagnosable — npm
@@ -50,6 +50,29 @@ export function parseNcuJson(stdout: string): Record<string, string> {
50
50
  }
51
51
  }
52
52
 
53
+ /**
54
+ * Majors `toiljs update` refuses to upgrade into, by package. TypeScript 7 is the native (Go) port:
55
+ * it ships `tsc` but no JavaScript compiler API, which toiljs's route-metadata extractor, the eslint
56
+ * preset, and typedoc all load. Upgrading into it silently stops SEO metadata being baked into the
57
+ * built HTML and hard-crashes typescript-eslint, so the upgrade is withheld until the ecosystem
58
+ * catches up. Bumps *within* the supported major are still offered.
59
+ */
60
+ export const UNSUPPORTED_MAJOR: Readonly<Record<string, number>> = { typescript: 7 };
61
+
62
+ /**
63
+ * The names ncu wants to upgrade into a major toiljs does not support (see {@link UNSUPPORTED_MAJOR}).
64
+ * These are dropped from the picker and passed to ncu's `--reject`, so `-u` cannot apply them either.
65
+ */
66
+ export function withheldUpgrades(upgraded: Record<string, string>): string[] {
67
+ return Object.entries(upgraded)
68
+ .filter(([name, to]) => {
69
+ const ceiling = UNSUPPORTED_MAJOR[name];
70
+ return ceiling !== undefined && parseVersion(to)[0] >= ceiling;
71
+ })
72
+ .map(([name]) => name)
73
+ .sort();
74
+ }
75
+
53
76
  const SEVERITY: Record<Bump, number> = { major: 0, minor: 1, patch: 2, other: 3 };
54
77
 
55
78
  /**
@@ -308,9 +308,18 @@ export async function buildServer(root: string, auth: boolean = false): Promise<
308
308
  return;
309
309
  }
310
310
 
311
- // Default request-only single-artifact path (no daemon/stream surface).
311
+ // Default request-only single-artifact path (no daemon/stream surface). Declare `hot` rather
312
+ // than leaving --targetMode off: with no explicit mode toilscript treats a source tree that
313
+ // declares no surface decorator as a plain AssemblyScript compile and omits `toil.surface`
314
+ // (to keep an ordinary AS build byte-identical). A toiljs server IS a Toil request artifact
315
+ // even before it grows its first `@rest`/`@data`, and the host refuses to load an artifact
316
+ // without that section, so a freshly scaffolded app would never deploy. `hot` is the mode the
317
+ // section already records for the default request artifact, so the bytes are unchanged for a
318
+ // project that has a surface; it only stops the section from going missing for one that
319
+ // doesn't. (`@daemon`/`@scheduled` are the only decorators `hot` rejects, and this branch runs
320
+ // exactly when the tree has neither.)
312
321
  await runToilscriptPass(root, binJs, files, {
313
- mode: null,
322
+ mode: 'hot',
314
323
  outFile: null,
315
324
  withRpc: true,
316
325
  authUser: authOn,
@@ -1,13 +1,6 @@
1
- import { createRequire } from 'node:module';
2
- import path from 'node:path';
3
-
4
- import type * as TS from 'typescript';
5
-
6
- import { extractStaticExports } from './prerender.js';
1
+ import { extractStaticExports, loadTypeScriptSync } from './prerender.js';
7
2
  import type { ScannedRoute } from './routes.js';
8
3
 
9
- type Ts = typeof TS;
10
-
11
4
  /**
12
5
  * A page in the build-time search index: its URL pattern, whether it's dynamic, and the
13
6
  * statically-extracted `metadata` literal (the searchable subset; dynamic `generateMetadata` and
@@ -20,20 +13,6 @@ export interface PageIndexEntry {
20
13
  readonly metadata: Record<string, unknown>;
21
14
  }
22
15
 
23
- /**
24
- * Loads the project's TypeScript synchronously (so {@link buildPageIndex} can run inside the sync
25
- * `generate()`), or `null` if it isn't installed, in which case pages are indexed by path only.
26
- */
27
- function loadTypeScriptSync(root: string): Ts | null {
28
- try {
29
- const require = createRequire(path.join(root, 'package.json'));
30
- const mod = require('typescript') as { default?: Ts } & Ts;
31
- return mod.default ?? mod;
32
- } catch {
33
- return null;
34
- }
35
- }
36
-
37
16
  /** True when a route pattern has dynamic (`:param` / `*catch-all`) segments. */
38
17
  function isDynamic(pattern: string): boolean {
39
18
  return /[:*]/.test(pattern);
@@ -12,15 +12,69 @@ import { injectSeoHtml, routeSeo } from './seo.js';
12
12
 
13
13
  type Ts = typeof TS;
14
14
 
15
- /** Loads the project's TypeScript (used to read each route's static `metadata`), or `null` if absent. */
15
+ /**
16
+ * True when `mod` exposes the classic TypeScript compiler API the static extractor drives.
17
+ *
18
+ * Resolving `typescript` is not enough to know it can parse: TypeScript 7 (the native port) moved
19
+ * the compiler API off the package's main entry, which now exports only `version`. That module is a
20
+ * perfectly good object, so a truthy check passes and the first `ts.ScriptTarget.Latest` then dies
21
+ * with `Cannot read properties of undefined (reading 'Latest')`. Probe the members we actually use.
22
+ */
23
+ function isCompilerApi(mod: unknown): mod is Ts {
24
+ const ts = mod as Partial<Ts> | null | undefined;
25
+ return (
26
+ typeof ts?.createSourceFile === 'function' &&
27
+ typeof ts.isVariableStatement === 'function' &&
28
+ typeof ts.ScriptTarget === 'object' &&
29
+ typeof ts.ScriptKind === 'object' &&
30
+ typeof ts.SyntaxKind === 'object'
31
+ );
32
+ }
33
+
34
+ /** Warn once per process: an unusable TypeScript silently costs baked metadata, so say so. */
35
+ let warnedUnusable = false;
36
+ function warnUnusableTypeScript(mod: unknown): void {
37
+ if (warnedUnusable) return;
38
+ warnedUnusable = true;
39
+ const version = (mod as { version?: string } | null)?.version;
40
+ process.stderr.write(
41
+ ` toil: typescript${version ? `@${version}` : ''} does not expose the compiler API ` +
42
+ `(TypeScript 7 moved it to 'typescript/unstable/*').\n` +
43
+ ` toil: route metadata will NOT be baked into the built HTML. Install typescript ^6 to restore it.\n`,
44
+ );
45
+ }
46
+
47
+ /**
48
+ * Resolves the project's TypeScript, used to read each route's static `metadata`. Returns `null`
49
+ * when it isn't installed (callers then index pages by path only) *or* when the resolved version
50
+ * doesn't expose the compiler API, which is warned about rather than crashing the build.
51
+ */
16
52
  export async function loadTypeScript(root: string): Promise<Ts | null> {
53
+ let mod: unknown;
17
54
  try {
18
55
  const resolved = createRequire(path.join(root, 'package.json')).resolve('typescript');
19
- const mod = (await import(pathToFileURL(resolved).href)) as { default?: Ts } & Ts;
20
- return mod.default ?? mod;
56
+ const ns = (await import(pathToFileURL(resolved).href)) as { default?: Ts } & Ts;
57
+ mod = ns.default ?? ns;
58
+ } catch {
59
+ return null;
60
+ }
61
+ if (isCompilerApi(mod)) return mod;
62
+ warnUnusableTypeScript(mod);
63
+ return null;
64
+ }
65
+
66
+ /** The sync twin of {@link loadTypeScript}, for callers that can't await (e.g. `buildPageIndex`). */
67
+ export function loadTypeScriptSync(root: string): Ts | null {
68
+ let mod: unknown;
69
+ try {
70
+ mod = createRequire(path.join(root, 'package.json'))('typescript');
21
71
  } catch {
22
72
  return null;
23
73
  }
74
+ const ts = (mod as { default?: Ts })?.default ?? mod;
75
+ if (isCompilerApi(ts)) return ts;
76
+ warnUnusableTypeScript(ts);
77
+ return null;
24
78
  }
25
79
 
26
80
  /** Marks an AST node that isn't a static literal (so its value can't be baked at build). */