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.
- package/.github/workflows/ci.yml +1 -1
- package/CHANGELOG.md +10 -0
- package/build/backend/.tsbuildinfo +1 -1
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/index.js +145 -27
- package/build/client/.tsbuildinfo +1 -1
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/index.js +1 -1
- package/build/compiler/pages.js +1 -13
- package/build/compiler/prerender.d.ts +1 -0
- package/build/compiler/prerender.js +39 -2
- package/build/compiler/toil-docs.generated.js +4 -4
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/analytics/index.d.ts +2 -0
- package/build/devserver/analytics/index.js +15 -9
- package/build/devserver/daemon/host.d.ts +2 -1
- package/build/devserver/daemon/host.js +12 -12
- package/build/devserver/daemon/index.js +3 -3
- package/build/io/.tsbuildinfo +1 -1
- package/build/logger/.tsbuildinfo +1 -1
- package/build/shared/.tsbuildinfo +1 -1
- package/docs/background/daemons.md +49 -3
- package/docs/cli/README.md +4 -2
- package/docs/getting-started/installation.md +18 -0
- package/docs/services/analytics.md +28 -17
- package/examples/basic/server/routes/Analytics.ts +6 -5
- package/package.json +15 -15
- package/src/cli/create.ts +1 -0
- package/src/cli/diagnostics.ts +74 -0
- package/src/cli/doctor.ts +105 -27
- package/src/cli/update.ts +30 -3
- package/src/cli/updates.ts +23 -0
- package/src/compiler/index.ts +11 -2
- package/src/compiler/pages.ts +1 -22
- package/src/compiler/prerender.ts +57 -3
- package/src/compiler/toil-docs.generated.ts +4 -4
- package/src/devserver/analytics/index.ts +40 -17
- package/src/devserver/daemon/host.ts +29 -19
- package/src/devserver/daemon/index.ts +12 -5
- package/test/analytics-dev.test.ts +8 -8
- package/test/daemon-emulation.test.ts +44 -2
- package/test/doctor.test.ts +28 -0
- package/test/fixtures/daemon-app.ts +9 -4
- package/test/prerender.test.ts +64 -2
- package/test/update.test.ts +15 -1
- package/tsconfig.base.json +0 -1
|
@@ -15,8 +15,19 @@ import type { DbDevState } from '../db/index.js';
|
|
|
15
15
|
import type { MemoryRef } from '../runtime/host.js';
|
|
16
16
|
|
|
17
17
|
/** Frame version + counter count, kept in lockstep with the edge (`analytics.rs` / `metric_id.rs`). */
|
|
18
|
-
const FRAME_VERSION =
|
|
18
|
+
const FRAME_VERSION = 3;
|
|
19
19
|
const METRIC_COUNTERS = 42;
|
|
20
|
+
|
|
21
|
+
/** The sustained-rate window: requests are metered over a 3-minute ROLLING window. A plan's
|
|
22
|
+
* `sustained_rps` (X) allows `X * BURST_WINDOW_SECS` requests across it; instantaneous spikes above
|
|
23
|
+
* that are NOT capped per-tier (only the box `firewall.global_pps` DDoS backstop applies). */
|
|
24
|
+
export const BURST_WINDOW_SECS = 180;
|
|
25
|
+
|
|
26
|
+
/** The plan's sustained rate in requests/second, derived from the rolling window's allowance.
|
|
27
|
+
* `0` means unmetered (no sustained cap), and must NOT be divided into a rate. */
|
|
28
|
+
export function sustainedRpsCap(reqBurstCap: number): number {
|
|
29
|
+
return reqBurstCap === 0 ? 0 : reqBurstCap / BURST_WINDOW_SECS;
|
|
30
|
+
}
|
|
20
31
|
/** MetricId indices we seed with sample data (others default 0). Mirrors the AUTHORITATIVE edge wire
|
|
21
32
|
* contract EXACTLY (`toil-backend/src/analytics/metric_id.rs`): counter ids 0..=41, gauge ids
|
|
22
33
|
* ConnectedStreamsAvg=42, CommittedMemoryAvg=44. There is NO WasmDispatches (removed + reindexed); the
|
|
@@ -43,10 +54,16 @@ interface DevTenantStats {
|
|
|
43
54
|
life: bigint[]; // length METRIC_COUNTERS, indexed by MetricId
|
|
44
55
|
connectedStreams: number;
|
|
45
56
|
committedMemory: number;
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
57
|
+
// Fleet-global requests in the trailing 3-minute ROLLING window, and its allowance
|
|
58
|
+
// (= sustained_rps * BURST_WINDOW_SECS; 0 = unmetered). Spikes are not tier-capped.
|
|
59
|
+
reqBurstUsed: number;
|
|
60
|
+
reqBurstCap: number;
|
|
61
|
+
// Fleet-global requests in the current 30-day tumbling bucket, and the 30-day quota (0 = unmetered).
|
|
62
|
+
req30dUsed: number;
|
|
63
|
+
req30dCap: number;
|
|
64
|
+
// Derived (NOT part of the wire frame): the plan's sustained request rate, `reqBurstCap /
|
|
65
|
+
// BURST_WINDOW_SECS` (0 = unmetered). Surfaced so a dev dashboard can show "X sustained rps".
|
|
66
|
+
sustainedRpsCap: number;
|
|
50
67
|
// LIVE per-second rates (f64), appended after the windows. Mirrors the edge `encode_stats`.
|
|
51
68
|
rps: number;
|
|
52
69
|
bytesInPerSec: number;
|
|
@@ -76,14 +93,18 @@ function devStats(): DevTenantStats {
|
|
|
76
93
|
life[MID.MemGrownBytes] = 262144n;
|
|
77
94
|
life[MID.CacheHits] = 900n;
|
|
78
95
|
life[MID.CacheMisses] = 100n;
|
|
96
|
+
// Free tier: sustained 1000 rps -> a 3-minute burst cap of 1000 * 180 = 180_000, plus a 30-day
|
|
97
|
+
// quota of 10_000_000. `used` values are small so the dashboard shows plenty of headroom.
|
|
98
|
+
const reqBurstCap: number = 180_000;
|
|
79
99
|
return {
|
|
80
100
|
life,
|
|
81
101
|
connectedStreams: 3,
|
|
82
102
|
committedMemory: 65536,
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
103
|
+
reqBurstUsed: 1_200,
|
|
104
|
+
reqBurstCap,
|
|
105
|
+
req30dUsed: 250_000,
|
|
106
|
+
req30dCap: 10_000_000,
|
|
107
|
+
sustainedRpsCap: sustainedRpsCap(reqBurstCap),
|
|
87
108
|
// Plausible non-zero live rates so a dev dashboard shows moving gauges.
|
|
88
109
|
rps: 0.7,
|
|
89
110
|
bytesInPerSec: 68.2,
|
|
@@ -97,10 +118,12 @@ function devStats(): DevTenantStats {
|
|
|
97
118
|
}
|
|
98
119
|
|
|
99
120
|
/**
|
|
100
|
-
* Encode the
|
|
101
|
-
* layout (no strings).
|
|
121
|
+
* Encode the v3 snapshot frame BYTE-FOR-BYTE like the edge (`analytics.rs` `encode_stats`), FIXED
|
|
122
|
+
* layout (no strings). The v2 -> v3 bump renamed two u64 pairs (req_minute_* -> req_burst_*,
|
|
123
|
+
* req_day_* -> req_30d_*); the BYTE LAYOUT is UNCHANGED (same field count, order, and widths).
|
|
124
|
+
* Counters/levels are u64; the 7 trailing LIVE per-second rates are f64:
|
|
102
125
|
* u16 version | u64 now_ms | u32 count | u64 × count | u64 connectedStreams | u64 committedMemory |
|
|
103
|
-
* u64
|
|
126
|
+
* u64 reqBurstUsed | u64 reqBurstCap | u64 req30dUsed | u64 req30dCap |
|
|
104
127
|
* f64 rps | f64 bytesInPerSec | f64 bytesOutPerSec | f64 streamBytesInPerSec |
|
|
105
128
|
* f64 streamBytesOutPerSec | f64 dbOpsPerSec | f64 gasPerSec
|
|
106
129
|
* The rates are APPENDED (version-gated by length): the guest's bounds-checked reader yields 0.0 for a
|
|
@@ -120,10 +143,10 @@ function encodeStats(s: DevTenantStats): Buffer {
|
|
|
120
143
|
}
|
|
121
144
|
body.writeBigUInt64LE(BigInt(s.connectedStreams), o); o += 8;
|
|
122
145
|
body.writeBigUInt64LE(BigInt(s.committedMemory), o); o += 8;
|
|
123
|
-
body.writeBigUInt64LE(BigInt(s.
|
|
124
|
-
body.writeBigUInt64LE(BigInt(s.
|
|
125
|
-
body.writeBigUInt64LE(BigInt(s.
|
|
126
|
-
body.writeBigUInt64LE(BigInt(s.
|
|
146
|
+
body.writeBigUInt64LE(BigInt(s.reqBurstUsed), o); o += 8;
|
|
147
|
+
body.writeBigUInt64LE(BigInt(s.reqBurstCap), o); o += 8;
|
|
148
|
+
body.writeBigUInt64LE(BigInt(s.req30dUsed), o); o += 8;
|
|
149
|
+
body.writeBigUInt64LE(BigInt(s.req30dCap), o); o += 8;
|
|
127
150
|
body.writeDoubleLE(s.rps, o); o += 8;
|
|
128
151
|
body.writeDoubleLE(s.bytesInPerSec, o); o += 8;
|
|
129
152
|
body.writeDoubleLE(s.bytesOutPerSec, o); o += 8;
|
|
@@ -165,7 +188,7 @@ function rangeShape(range: number, metricId: number): { count: number; bucketSec
|
|
|
165
188
|
}
|
|
166
189
|
|
|
167
190
|
/**
|
|
168
|
-
* Encode the
|
|
191
|
+
* Encode the v3 series frame BYTE-FOR-BYTE like the edge `encode_series` (points are u64 now):
|
|
169
192
|
* u16 version | u16 metricId | u32 bucketSecs | u64 headMs | u32 count | u64 × count
|
|
170
193
|
* A synthetic gentle ramp so a dev dashboard draws a non-flat line.
|
|
171
194
|
*/
|
|
@@ -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
|
-
|
|
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
|
|
68
|
-
*
|
|
69
|
-
*
|
|
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
|
-
|
|
73
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
|
107
|
-
*
|
|
108
|
-
* `daemon
|
|
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
|
|
199
|
-
// functions). Mirrors `WasmServerModule.assertImportSurface` (section 7.1).
|
|
200
|
-
|
|
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(
|
|
203
|
-
|
|
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(
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Dev-server analytics stub parity: the `env.analytics_read` / `env.analytics_series` dev imports must
|
|
3
|
-
* encode the
|
|
3
|
+
* encode the v3 frames BYTE-FOR-BYTE like the edge (`analytics.rs`), so a guest's toilscript `Analytics`
|
|
4
4
|
* decode is identical in dev and prod. We decode the stashed frame positionally, exactly as the guest.
|
|
5
5
|
*/
|
|
6
6
|
import { describe, expect, it } from 'vitest';
|
|
@@ -16,7 +16,7 @@ function harness() {
|
|
|
16
16
|
return { imports: buildAnalyticsImports(ref, db), db };
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
describe('dev analytics stub
|
|
19
|
+
describe('dev analytics stub v3 frame parity', () => {
|
|
20
20
|
it('analytics_read encodes the fixed-layout snapshot frame', () => {
|
|
21
21
|
const { imports, db } = harness();
|
|
22
22
|
const n = imports.analytics_read(0, 0);
|
|
@@ -29,7 +29,7 @@ describe('dev analytics stub v2 frame parity', () => {
|
|
|
29
29
|
const u64 = () => { const v = buf.readBigUInt64LE(p); p += 8; return v; };
|
|
30
30
|
const f64 = () => { const v = buf.readDoubleLE(p); p += 8; return v; };
|
|
31
31
|
|
|
32
|
-
expect(u16()).toBe(
|
|
32
|
+
expect(u16()).toBe(3); // FRAME_VERSION
|
|
33
33
|
u64(); // now_ms
|
|
34
34
|
const count = u32();
|
|
35
35
|
expect(count).toBe(42); // METRIC_COUNTERS (edge N_COUNTERS; no WasmDispatches)
|
|
@@ -44,10 +44,10 @@ describe('dev analytics stub v2 frame parity', () => {
|
|
|
44
44
|
expect(life[41]).toBe(100n); // CacheMisses (id 41, was 42)
|
|
45
45
|
expect(u64()).toBe(3n); // connectedStreams
|
|
46
46
|
expect(u64()).toBe(65536n); // committedMemory
|
|
47
|
-
expect(u64()).toBe(
|
|
48
|
-
expect(u64()).toBe(
|
|
49
|
-
expect(u64()).toBe(
|
|
50
|
-
expect(u64()).toBe(
|
|
47
|
+
expect(u64()).toBe(1_200n); // reqBurstUsed (current 3-min tumbling bucket)
|
|
48
|
+
expect(u64()).toBe(180_000n); // reqBurstCap (sustained 1000 rps * 180s window)
|
|
49
|
+
expect(u64()).toBe(250_000n); // req30dUsed (current 30-day bucket)
|
|
50
|
+
expect(u64()).toBe(10_000_000n); // req30dCap (30-day quota)
|
|
51
51
|
// 7 LIVE per-second rates (f64 LE), appended after the windows.
|
|
52
52
|
expect(f64()).toBeCloseTo(0.7); // rps
|
|
53
53
|
expect(f64()).toBeCloseTo(68.2); // bytesInPerSec
|
|
@@ -66,7 +66,7 @@ describe('dev analytics stub v2 frame parity', () => {
|
|
|
66
66
|
const buf = db.lastResult as unknown as Buffer;
|
|
67
67
|
expect(n).toBe(buf.length);
|
|
68
68
|
let p = 0;
|
|
69
|
-
expect(buf.readUInt16LE(p)).toBe(
|
|
69
|
+
expect(buf.readUInt16LE(p)).toBe(3); p += 2; // version
|
|
70
70
|
expect(buf.readUInt16LE(p)).toBe(0); p += 2; // metric id
|
|
71
71
|
expect(buf.readUInt32LE(p)).toBe(3600); p += 4; // bucketSecs
|
|
72
72
|
p += 8; // headMs
|
|
@@ -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
|
|
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();
|
package/test/doctor.test.ts
CHANGED
|
@@ -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
|
-
//
|
|
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("
|
|
17
|
+
@external("daemon", "is_leader")
|
|
13
18
|
declare function daemonIsLeader(): i32;
|
|
14
19
|
|
|
15
|
-
@external("
|
|
20
|
+
@external("daemon", "current_epoch")
|
|
16
21
|
declare function daemonCurrentEpoch(): i64;
|
|
17
22
|
|
|
18
|
-
@external("
|
|
23
|
+
@external("daemon", "task_count")
|
|
19
24
|
declare function daemonTaskCount(): i32;
|
|
20
25
|
|
|
21
26
|
let started: i32 = 0;
|
package/test/prerender.test.ts
CHANGED
|
@@ -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 {
|
|
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
|
+
});
|
package/test/update.test.ts
CHANGED
|
@@ -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
|
+
});
|