toiljs 0.0.113 → 0.0.115

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 (41) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/build/backend/.tsbuildinfo +1 -1
  3. package/build/cli/.tsbuildinfo +1 -1
  4. package/build/cli/index.js +198 -41
  5. package/build/client/.tsbuildinfo +1 -1
  6. package/build/compiler/.tsbuildinfo +1 -1
  7. package/build/compiler/index.js +1 -1
  8. package/build/compiler/pages.js +1 -13
  9. package/build/compiler/prerender.d.ts +1 -0
  10. package/build/compiler/prerender.js +39 -2
  11. package/build/compiler/toil-docs.generated.js +3 -3
  12. package/build/devserver/.tsbuildinfo +1 -1
  13. package/build/devserver/daemon/host.d.ts +2 -1
  14. package/build/devserver/daemon/host.js +12 -12
  15. package/build/devserver/daemon/index.js +3 -3
  16. package/build/io/.tsbuildinfo +1 -1
  17. package/build/logger/.tsbuildinfo +1 -1
  18. package/build/shared/.tsbuildinfo +1 -1
  19. package/docs/background/daemons.md +49 -3
  20. package/docs/cli/README.md +4 -2
  21. package/docs/getting-started/installation.md +18 -0
  22. package/package.json +15 -15
  23. package/src/cli/create.ts +171 -25
  24. package/src/cli/diagnostics.ts +74 -0
  25. package/src/cli/doctor.ts +105 -27
  26. package/src/cli/features.ts +7 -3
  27. package/src/cli/index.ts +2 -2
  28. package/src/cli/update.ts +30 -3
  29. package/src/cli/updates.ts +23 -0
  30. package/src/compiler/index.ts +11 -2
  31. package/src/compiler/pages.ts +1 -22
  32. package/src/compiler/prerender.ts +57 -3
  33. package/src/compiler/toil-docs.generated.ts +3 -3
  34. package/src/devserver/daemon/host.ts +29 -19
  35. package/src/devserver/daemon/index.ts +12 -5
  36. package/test/daemon-emulation.test.ts +44 -2
  37. package/test/doctor.test.ts +28 -0
  38. package/test/fixtures/daemon-app.ts +9 -4
  39. package/test/prerender.test.ts +64 -2
  40. package/test/update.test.ts +15 -1
  41. package/tsconfig.base.json +0 -1
@@ -10,6 +10,12 @@
10
10
  * - Part 3 error bridge: a u16 subsystem code `c` is returned as `-(0x10000 + c)`;
11
11
  * the buffer sentinels `-1` (TOO_SMALL) / `-2` (ABSENT) are unchanged.
12
12
  *
13
+ * These live in their OWN wasm module namespace, `daemon`, with BARE names, exactly
14
+ * as the edge registers them (toil-backend `src/wasm/cold/imports.rs`, `NS_DAEMON`).
15
+ * They are NOT dotted names under `env` like `data.*` / `crypto.*` are: a cold box
16
+ * that declares them that way resolves here and then trap-stubs at the edge, which
17
+ * is the one shape of drift a dev emulator must never hide.
18
+ *
13
19
  * The cold box also imports the request-surface `env.*` MINUS the response/stream
14
20
  * functions it must not have (no `set_status`/`set_header`/`respond_file`/
15
21
  * `client_ip`/`ratelimit_check`); it keeps `@data`/crypto/env/`Date.now`/email so a
@@ -38,10 +44,14 @@ export interface ResolvedDaemonConfig {
38
44
 
39
45
  /** RECONCILIATION Part 3 u16 error registry (the subset the daemon imports use). */
40
46
  export const enum AbiError {
41
- DaemonScheduleRejected = 0x0403,
47
+ DaemonNotLeader = 0x0401,
48
+ DaemonLeaseLost = 0x0402,
42
49
  DaemonCallFailed = 0x0405,
43
50
  }
44
51
 
52
+ /** The edge's `EPOCH_NONE` / "no such task" sentinel: a plain -1, not a bridged error. */
53
+ const DAEMON_NONE = -1n;
54
+
45
55
  /** Encode a u16 subsystem error per the Part 3 negative-return bridge:
46
56
  * `code = (-v) - 0x10000`, so `v = -(0x10000 + code)`. */
47
57
  export function encodeAbiError(code: number): number {
@@ -64,26 +74,27 @@ export interface DaemonRuntime {
64
74
  * `number | bigint` individually, matching the existing db/crypto import maps). */
65
75
  type HostFnMap = Record<string, (...args: number[]) => number | bigint>;
66
76
 
67
- /** Build the `daemon.*` host imports, closing over the resident `DaemonRuntime`.
68
- * These imports do not read guest memory (they answer from the resident scheduler
69
- * state), so they take no `MemoryRef`. */
77
+ /** Build the `daemon` namespace, closing over the resident `DaemonRuntime`. The names
78
+ * are BARE (`is_leader`, not `daemon.is_leader`): they are keyed under the `daemon`
79
+ * wasm module, matching the edge. These imports do not read guest memory (they answer
80
+ * from the resident scheduler state), so they take no `MemoryRef`. */
70
81
  export function buildDaemonNamespace(rt: DaemonRuntime): HostFnMap {
71
82
  return {
72
- 'daemon.is_leader': (): number => (rt.isLeader() ? 1 : 0),
73
- 'daemon.current_epoch': (): bigint => rt.epoch(),
83
+ is_leader: (): number => (rt.isLeader() ? 1 : 0),
84
+ // The edge answers -1 (EPOCH_NONE) when this node does not hold the lease.
85
+ current_epoch: (): bigint => (rt.isLeader() ? rt.epoch() : DAEMON_NONE),
74
86
  // The dev lease never expires, so yield/sleep never report LEASE_LOST.
75
- 'daemon.yield': (): number => 0,
76
- 'daemon.sleep_ms': (_ms: number | bigint): number => 0,
77
- 'daemon.task_count': (): number => rt.taskCount(),
78
- 'daemon.next_fire_ms': (taskId: number): bigint => {
87
+ yield: (): number => 0,
88
+ sleep_ms: (_ms: number | bigint): number => 0,
89
+ task_count: (): number => rt.taskCount(),
90
+ // An unknown / never-firing task is -1 at the edge, NOT a bridged error.
91
+ next_fire_ms: (taskId: number): bigint => {
79
92
  const at = rt.nextFireMs(taskId);
80
- return at === null
81
- ? BigInt(encodeAbiError(AbiError.DaemonScheduleRejected))
82
- : BigInt(at);
93
+ return at === null ? DAEMON_NONE : BigInt(at);
83
94
  },
84
95
  // Outbound HTTP call stub: dev returns a "call failed" sentinel rather than
85
96
  // performing real network I/O from a synchronous wasm import (section 5.4).
86
- 'daemon.http_call': (
97
+ http_call: (
87
98
  _reqPtr: number,
88
99
  _reqLen: number,
89
100
  _outPtr: number,
@@ -103,10 +114,9 @@ export function freshDaemonState(): DaemonState {
103
114
  }
104
115
 
105
116
  /**
106
- * The full `env` import object for the cold daemon box: the request-surface env
107
- * MINUS the response/stream functions (built by `buildEnvImports`), PLUS the
108
- * `daemon.*` namespace. The cold box has no `handle` entry and no response
109
- * surface.
117
+ * The import object for the cold daemon box: the request-surface `env` MINUS the
118
+ * response/stream functions (built by `buildEnvImports`), plus the separate
119
+ * `daemon` module. The cold box has no `handle` entry and no response surface.
110
120
  */
111
121
  export function buildDaemonImports(
112
122
  ref: MemoryRef,
@@ -118,7 +128,7 @@ export function buildDaemonImports(
118
128
  ...buildEnvImports(ref, state),
119
129
  ...buildCryptoImports(ref, state.crypto),
120
130
  ...buildDatabaseImports(ref, state.db),
121
- ...buildDaemonNamespace(rt),
122
131
  },
132
+ daemon: buildDaemonNamespace(rt),
123
133
  };
124
134
  }
@@ -195,12 +195,19 @@ export class DaemonHost implements DaemonRuntime {
195
195
  const imports = buildDaemonImports(ref, this.state, this);
196
196
 
197
197
  // Fail-closed up front, with names, when the cold box imports anything outside its allowed
198
- // surface (the request env subset + crypto + @data + daemon.*; NO response/stream
199
- // functions). Mirrors `WasmServerModule.assertImportSurface` (section 7.1).
200
- const provided = new Set(Object.keys((imports as { env: Record<string, unknown> }).env));
198
+ // surface (the request env subset + crypto + @data, and the separate `daemon` module; NO
199
+ // response/stream functions). Mirrors `WasmServerModule.assertImportSurface` (section 7.1).
200
+ // Both namespaces are checked: `daemon.*` is its own wasm module at the edge, so a guest
201
+ // that declares those names under `env` must fail HERE rather than trap only in production.
202
+ // A non-function import is rejected outright, as the edge does (`NonFunctionImport`):
203
+ // no memory/global/table is ever registered, so leaving one to fail later would
204
+ // surface as an opaque LinkError rather than a named, fail-closed diagnostic.
205
+ const provided = imports as Record<string, Record<string, unknown>>;
201
206
  const missing = WebAssembly.Module.imports(this.module)
202
- .filter((i) => i.kind === 'function' && (i.module !== 'env' || !provided.has(i.name)))
203
- .map((i) => `${i.module}.${i.name}`);
207
+ .filter(
208
+ (i) => i.kind !== 'function' || !Object.hasOwn(provided[i.module] ?? {}, i.name),
209
+ )
210
+ .map((i) => `${i.module}.${i.name}${i.kind === 'function' ? '' : ` (${i.kind})`}`);
204
211
  if (missing.length > 0) {
205
212
  this.log(
206
213
  pc.red(
@@ -16,7 +16,7 @@
16
16
  */
17
17
 
18
18
  import { spawnSync } from 'node:child_process';
19
- import { existsSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
19
+ import { existsSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
20
20
  import { tmpdir } from 'node:os';
21
21
  import { dirname, join } from 'node:path';
22
22
  import { fileURLToPath } from 'node:url';
@@ -24,7 +24,11 @@ import { fileURLToPath } from 'node:url';
24
24
  import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
25
25
 
26
26
  import { DaemonHost } from '../src/devserver/daemon/index.js';
27
- import type { ResolvedDaemonConfig } from '../src/devserver/daemon/host.js';
27
+ import {
28
+ buildDaemonImports,
29
+ freshDaemonState,
30
+ type ResolvedDaemonConfig,
31
+ } from '../src/devserver/daemon/host.js';
28
32
 
29
33
  const here = dirname(fileURLToPath(import.meta.url));
30
34
  const FIXTURE = join(here, 'fixtures', 'daemon-app.ts');
@@ -91,6 +95,44 @@ describe.skipIf(!existsSync(LOCAL_TOILSCRIPT_BIN))('dev daemon emulation', () =>
91
95
  expect(toilscriptAvailable, 'cold compile of the @daemon fixture failed').toBe(true);
92
96
  });
93
97
 
98
+ // The edge registers these in their OWN wasm module (`NS_DAEMON`), not as dotted
99
+ // names under `env` the way `data.*` and `crypto.*` are. A cold box that gets it
100
+ // wrong resolves against this emulator and then trap-stubs in production, so pin
101
+ // the namespace on both sides: the guest's declared imports, and what we provide.
102
+ it('declares its daemon imports in the `daemon` module with bare names', () => {
103
+ const module = new WebAssembly.Module(readFileSync(coldWasm));
104
+ const daemonImports = WebAssembly.Module.imports(module)
105
+ .filter((i) => i.module === 'daemon')
106
+ .map((i) => i.name)
107
+ .sort();
108
+ expect(daemonImports).toEqual(['current_epoch', 'is_leader', 'task_count']);
109
+ expect(
110
+ WebAssembly.Module.imports(module).filter((i) => i.name.startsWith('daemon.')),
111
+ ).toEqual([]);
112
+ });
113
+
114
+ it('provides the daemon host functions under the `daemon` namespace', () => {
115
+ const imports = buildDaemonImports({ memory: null }, freshDaemonState(), {
116
+ isLeader: () => true,
117
+ epoch: () => 1n,
118
+ taskCount: () => 0,
119
+ nextFireMs: () => null,
120
+ }) as unknown as Record<string, Record<string, unknown>>;
121
+ expect(Object.keys(imports).sort()).toEqual(['daemon', 'env']);
122
+ expect(Object.keys(imports.daemon).sort()).toEqual([
123
+ 'current_epoch',
124
+ 'http_call',
125
+ 'is_leader',
126
+ 'next_fire_ms',
127
+ 'sleep_ms',
128
+ 'task_count',
129
+ 'yield',
130
+ ]);
131
+ expect(Object.keys(imports.env).some((k) => k.startsWith('daemon.'))).toBe(false);
132
+ // An unknown task is the edge's plain -1 sentinel, not a bridged error code.
133
+ expect((imports.daemon.next_fire_ms as (id: number) => bigint)(9)).toBe(-1n);
134
+ });
135
+
94
136
  it('runs daemon_start exactly once on load', () => {
95
137
  const host = new DaemonHost(coldWasm, DAEMON_CFG, 'all', () => {});
96
138
  host.refresh();
@@ -18,7 +18,9 @@ import {
18
18
  checkSeoUrl,
19
19
  checkServerTsPlugin,
20
20
  checkStyling,
21
+ checkTypeScript,
21
22
  findRelativeAssets,
23
+ rangeMajor,
22
24
  satisfiesMin,
23
25
  summarize,
24
26
  } from '../src/cli/diagnostics';
@@ -91,6 +93,32 @@ describe('config + environment checks', () => {
91
93
  expect(checkPeer('react', '^19.0.0', '>=18.0.0').status).toBe('pass');
92
94
  });
93
95
 
96
+ it('checkTypeScript fails an installed 7.x (the native port, no compiler API)', () => {
97
+ const check = checkTypeScript('7.0.2', '^7.0.2');
98
+ expect(check.status).toBe('fail');
99
+ expect(check.detail).toContain('7.0.2');
100
+ expect(check.fix).toContain('^6.0.3');
101
+ });
102
+
103
+ it('checkTypeScript fails a declared 7.x even while a supported copy is installed', () => {
104
+ // The installed copy still works, but the next install would pull the native port.
105
+ expect(checkTypeScript('6.0.3', '^7.0.0').status).toBe('fail');
106
+ expect(checkTypeScript('6.0.3', '>=7').status).toBe('fail');
107
+ });
108
+
109
+ it('checkTypeScript passes a supported 6.x and warns below the floor', () => {
110
+ expect(checkTypeScript('6.0.3', '^6.0.3').status).toBe('pass');
111
+ expect(checkTypeScript('5.9.3', '^5.9.0').status).toBe('warn');
112
+ expect(checkTypeScript(null, null).status).toBe('fail');
113
+ });
114
+
115
+ it('rangeMajor reads the major a range floats to, ignoring comparators', () => {
116
+ expect(rangeMajor('^7.0.2')).toBe(7);
117
+ expect(rangeMajor('>=6.0.0 <7.0.0')).toBe(6);
118
+ expect(rangeMajor('~6.0')).toBe(6);
119
+ expect(rangeMajor('*')).toBe(null);
120
+ });
121
+
94
122
  it('checkDuplicatePatterns flags repeated route URLs', () => {
95
123
  expect(checkDuplicatePatterns(['/', '/about', '/blog/:id']).status).toBe('pass');
96
124
  expect(checkDuplicatePatterns(['/a', '/a']).status).toBe('warn');
@@ -1,21 +1,26 @@
1
1
  // A minimal @daemon fixture for the dev daemon-emulation test. It declares the
2
- // `daemon.*` host imports directly (so it needs no toiljs globals lib) and records
2
+ // daemon host imports directly (so it needs no toiljs globals lib) and records
3
3
  // its activity into resident daemon wasm memory. Compiled with `--targetMode cold`
4
4
  // by the test.
5
5
  //
6
6
  // onStart() -> increments `started` and stamps the lease epoch
7
7
  // tick() @scheduled -> increments `tickFast` (1s interval)
8
8
  // sixHourly() cron -> increments `tickCron` (0 */6 * * *)
9
+ //
10
+ // The imports live in the `daemon` wasm module with BARE names, as the edge
11
+ // registers them; they are not dotted names under `env`. toilscript's stdlib
12
+ // exposes the same surface as the ambient `Daemon` class (`~lib/daemon`), which
13
+ // real daemons should use instead of hand-declaring these.
9
14
 
10
15
  // @ts-nocheck — this is AssemblyScript source compiled by toilscript, not TS.
11
16
 
12
- @external("env", "daemon.is_leader")
17
+ @external("daemon", "is_leader")
13
18
  declare function daemonIsLeader(): i32;
14
19
 
15
- @external("env", "daemon.current_epoch")
20
+ @external("daemon", "current_epoch")
16
21
  declare function daemonCurrentEpoch(): i64;
17
22
 
18
- @external("env", "daemon.task_count")
23
+ @external("daemon", "task_count")
19
24
  declare function daemonTaskCount(): i32;
20
25
 
21
26
  let started: i32 = 0;
@@ -3,9 +3,13 @@ import os from 'node:os';
3
3
  import path from 'node:path';
4
4
 
5
5
  import ts from 'typescript';
6
- import { afterEach, describe, expect, it } from 'vitest';
6
+ import { afterEach, describe, expect, it, vi } from 'vitest';
7
7
 
8
- import { extractStaticMetadata } from '../src/compiler/prerender';
8
+ import {
9
+ extractStaticMetadata,
10
+ loadTypeScript,
11
+ loadTypeScriptSync,
12
+ } from '../src/compiler/prerender';
9
13
 
10
14
  const written: string[] = [];
11
15
  function tmp(source: string): string {
@@ -52,3 +56,61 @@ describe('extractStaticMetadata', () => {
52
56
  expect(extractStaticMetadata(ts, file)).toEqual({ title: 'X' });
53
57
  });
54
58
  });
59
+
60
+ /**
61
+ * Builds a throwaway project root whose resolvable `typescript` is `main`-ed at a module exporting
62
+ * only `version` — the shape TypeScript 7 ships, having moved the compiler API to
63
+ * `typescript/unstable/*`. Resolution succeeds, so a truthy check can't tell it apart from a usable
64
+ * compiler; only probing the API can.
65
+ */
66
+ function rootWithApiLessTypeScript(): string {
67
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), `toil-ts7-${process.pid}-`));
68
+ dirs.push(root);
69
+ const pkgDir = path.join(root, 'node_modules', 'typescript');
70
+ fs.mkdirSync(pkgDir, { recursive: true });
71
+ fs.writeFileSync(path.join(root, 'package.json'), '{"name":"fixture"}');
72
+ fs.writeFileSync(
73
+ path.join(pkgDir, 'package.json'),
74
+ '{"name":"typescript","version":"7.0.2","main":"./version.cjs"}',
75
+ );
76
+ fs.writeFileSync(
77
+ path.join(pkgDir, 'version.cjs'),
78
+ `module.exports = { version: '7.0.2', versionMajorMinor: '7.0' };\n`,
79
+ );
80
+ return root;
81
+ }
82
+
83
+ const dirs: string[] = [];
84
+ afterEach(() => {
85
+ for (const d of dirs.splice(0)) fs.rmSync(d, { recursive: true, force: true });
86
+ });
87
+
88
+ describe('loadTypeScript', () => {
89
+ it('returns null (not a half-built module) when typescript exposes no compiler API', async () => {
90
+ const root = rootWithApiLessTypeScript();
91
+ const warn = vi.spyOn(process.stderr, 'write').mockReturnValue(true);
92
+ try {
93
+ // Both must degrade rather than hand back `{ version }`, whose first `ts.ScriptTarget`
94
+ // read would throw `Cannot read properties of undefined (reading 'Latest')` mid-build.
95
+ expect(loadTypeScriptSync(root)).toBeNull();
96
+ await expect(loadTypeScript(root)).resolves.toBeNull();
97
+ } finally {
98
+ warn.mockRestore();
99
+ }
100
+ });
101
+
102
+ it('returns null when typescript is not installed at all', async () => {
103
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), `toil-nots-${process.pid}-`));
104
+ dirs.push(root);
105
+ fs.writeFileSync(path.join(root, 'package.json'), '{"name":"fixture"}');
106
+ expect(loadTypeScriptSync(root)).toBeNull();
107
+ await expect(loadTypeScript(root)).resolves.toBeNull();
108
+ });
109
+
110
+ it('returns the compiler API when a usable typescript is installed', async () => {
111
+ // This repo's own root resolves its devDependency typescript, which has the classic API.
112
+ const root = path.resolve(__dirname, '..');
113
+ expect(loadTypeScriptSync(root)?.createSourceFile).toBeTypeOf('function');
114
+ expect((await loadTypeScript(root))?.ScriptTarget.Latest).toBeTypeOf('number');
115
+ });
116
+ });
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
 
3
- import { buildRows, classifyBump, parseNcuJson } from '../src/cli/updates';
3
+ import { buildRows, classifyBump, parseNcuJson, withheldUpgrades } from '../src/cli/updates';
4
4
 
5
5
  describe('classifyBump', () => {
6
6
  it('classifies major / minor / patch from ranges', () => {
@@ -42,3 +42,17 @@ describe('buildRows', () => {
42
42
  expect(rows[0]).toMatchObject({ name: 'foo', from: '?', to: '^2.0.0', bump: 'major' });
43
43
  });
44
44
  });
45
+
46
+ describe('withheldUpgrades', () => {
47
+ it('withholds an upgrade into typescript 7 (the native port has no compiler API)', () => {
48
+ expect(withheldUpgrades({ typescript: '^7.0.2', react: '^20.0.0' })).toEqual(['typescript']);
49
+ });
50
+
51
+ it('still offers typescript bumps inside the supported major', () => {
52
+ expect(withheldUpgrades({ typescript: '^6.1.0' })).toEqual([]);
53
+ });
54
+
55
+ it('ignores packages with no declared ceiling', () => {
56
+ expect(withheldUpgrades({ vite: '^9.0.0', eslint: '^11.0.0' })).toEqual([]);
57
+ });
58
+ });
@@ -3,7 +3,6 @@
3
3
  "declaration": true,
4
4
  "noImplicitAny": true,
5
5
  "removeComments": true,
6
- "suppressImplicitAnyIndexErrors": false,
7
6
  "preserveConstEnums": true,
8
7
  "resolveJsonModule": true,
9
8
  "skipLibCheck": true,