cursedops 0.10.8 → 0.10.10
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/package.json +1 -1
- package/src/d1Schema.ts +3 -2
- package/src/edgeFetch.ts +32 -0
- package/src/publicClient.ts +104 -7
- package/src/relay.ts +80 -12
- package/src/workerDeploy.ts +125 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedops",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.10",
|
|
4
4
|
"description": "The build-and-ops answers this generation's apps wrote independently and identically: finding a generation's roots — and printing a command that runs when pasted — without knowing a path, the generation's whole-tree laws run over one repo from a checkout or a worktree, macOS launchd agent install/replace/remove and the live port a job serves, the scaffolding and verdicts of a deployed smoke (origin probe, the smoke's own environment, a network that lies about DNS, a settled version), the static-serving helpers eight apps copied — the path-traversal guard among them — the API floor that keeps an unmatched /api/... from ever being answered with the app shell, the commit and dirty flag a checkout-served process reports, the Cloudflare Worker deploy toolkit four apps copied (the deploy sequence, exact-set secrets over a pipe, origin-first rollback, the curl edge fetch, the row-for-row D1 import proof, the billed-CPU tail check around a deploy's walk and smoke, and each app's worker:secrets and worker:smoke main as one function of its data), the relay a Worker fronts a Mac-bound app with (the Durable Object, the frames, the Mac's dialer and key rotation — lifted from station for roms — and the signed-in stage walk's skeleton and the relay app's whole worker:deploy), the Worker import-graph and await-port checks every Worker app's suite runs over its own source, and the public-surface ratchet three published libraries each carried a forked copy of. Mechanism only — no app knows its name from here. Bun, zero runtime dependencies (typescript is an optional peer, for public-surface only), ships source.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
package/src/d1Schema.ts
CHANGED
|
@@ -13,8 +13,9 @@
|
|
|
13
13
|
* A Worker has no boot. An app's `migrate()` issues dozens of statements and inspects
|
|
14
14
|
* `PRAGMA table_info` as it goes; running that on every invocation would spend a D1 invocation's
|
|
15
15
|
* query budget before the request started, and D1 has no `PRAGMA table_info` to inspect with. So
|
|
16
|
-
* D1's schema is applied
|
|
17
|
-
*
|
|
16
|
+
* D1's schema is applied ahead of the Worker, by every `worker:deploy` — ordinary `--command`
|
|
17
|
+
* queries, never D1's blocking `--file` import (`schemaApplyArgv` in `cursedops/worker-deploy`) —
|
|
18
|
+
* and there are two descriptions of the app's tables. Each app's own
|
|
18
19
|
* schema-matches test fails its gate when they disagree, so the file is a build product with a
|
|
19
20
|
* checker rather than a document with a convention. {@link d1SchemaText} writes it, and refuses a
|
|
20
21
|
* statement that spans lines, because D1's `exec` splits on newlines.
|
package/src/edgeFetch.ts
CHANGED
|
@@ -242,3 +242,35 @@ export function fleetEdgeFetch(
|
|
|
242
242
|
if (!state) throw new Error(`no generation state root above ${from}: set $FORGE_STATE or run inside a checkout with forge.env`);
|
|
243
243
|
return createStageEdgeFetch(join(state, "secrets", "cloudflare-access.env"), options);
|
|
244
244
|
}
|
|
245
|
+
|
|
246
|
+
/** The status {@link settledFetch} answers when the fetch THREW — outside HTTP's range on purpose, so no leg can mistake it for an answer. */
|
|
247
|
+
export const NO_ANSWER_STATUS = 599;
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* A fetch that never throws: a rejection (curl's timeout, DNS, a reset) becomes a
|
|
251
|
+
* {@link NO_ANSWER_STATUS} `Response` whose body and `x-edge-error` header carry the reason, and
|
|
252
|
+
* `onThrow` hears it first so the smoke's ledger can name it.
|
|
253
|
+
*
|
|
254
|
+
* 🔴 Why (2026-09-25): roms' `worker:smoke` asked a relayed path while the Mac's link redialled,
|
|
255
|
+
* `edgeFetch` threw on curl exit 28, and the process died with a stack trace and no ledger — so a
|
|
256
|
+
* healthy release exited 1. Every leg of a smoke is supposed to be RECORDED: a throw is a failed
|
|
257
|
+
* leg naming its path, never the end of the run.
|
|
258
|
+
*/
|
|
259
|
+
export function settledFetch(
|
|
260
|
+
fetcher: (url: string, init?: RequestInit) => Promise<Response>,
|
|
261
|
+
onThrow: (url: string, error: Error) => void = () => {},
|
|
262
|
+
): (url: string, init?: RequestInit) => Promise<Response> {
|
|
263
|
+
return async (url, init) => {
|
|
264
|
+
try {
|
|
265
|
+
return await fetcher(url, init);
|
|
266
|
+
} catch (thrown) {
|
|
267
|
+
const error = thrown instanceof Error ? thrown : new Error(String(thrown));
|
|
268
|
+
onThrow(url, error);
|
|
269
|
+
const reason = error.message.replace(/[^\x20-\x7e]+/g, " ").slice(0, 300);
|
|
270
|
+
return new Response(`no answer: ${reason}`, {
|
|
271
|
+
status: NO_ANSWER_STATUS,
|
|
272
|
+
headers: { "content-type": "text/plain", "x-edge-error": reason },
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
}
|
package/src/publicClient.ts
CHANGED
|
@@ -26,10 +26,11 @@
|
|
|
26
26
|
* anonymous `/` there is a 302 to the Access issuer, which names no built asset and never matches.
|
|
27
27
|
*/
|
|
28
28
|
import { spawnSync } from "node:child_process";
|
|
29
|
-
import { readFileSync } from "node:fs";
|
|
30
|
-
import { join } from "node:path";
|
|
29
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
30
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
31
31
|
import { builtAssetsIn, type Smoke } from "cursedops/smoke";
|
|
32
32
|
import { stagedClientDir } from "cursedops/staged-client";
|
|
33
|
+
import { valueImports } from "cursedops/worker-safety";
|
|
33
34
|
|
|
34
35
|
/** The smoke row's name — one spelling for every relay app. */
|
|
35
36
|
export const PUBLIC_CLIENT_CHECK = "public-client";
|
|
@@ -123,6 +124,56 @@ export async function recordPublicClient(smoke: Smoke, staged: string | null, op
|
|
|
123
124
|
return verdict.matched;
|
|
124
125
|
}
|
|
125
126
|
|
|
127
|
+
/**
|
|
128
|
+
* What the relay Worker is BUILT from, relative to the app's checkout: every file reachable by
|
|
129
|
+
* relative import from wrangler's `main` (the Worker reaches into `src/` — roms'
|
|
130
|
+
* `../src/server/limits`, station's `../scripts/link`), plus the config and the lockfile that pin
|
|
131
|
+
* what it bundles from `node_modules`. Test files are never reachable, so a test-only change
|
|
132
|
+
* does not re-ship. Unresolvable edges are skipped; esbuild would refuse them anyway.
|
|
133
|
+
*/
|
|
134
|
+
export function workerInputs(root: string, main = "worker/index.ts"): string[] {
|
|
135
|
+
const seen = new Set<string>();
|
|
136
|
+
const queue = [resolve(root, main)];
|
|
137
|
+
while (queue.length > 0) {
|
|
138
|
+
const file = queue.shift() as string;
|
|
139
|
+
if (seen.has(file)) continue;
|
|
140
|
+
seen.add(file);
|
|
141
|
+
let code: string;
|
|
142
|
+
try {
|
|
143
|
+
code = readFileSync(file, "utf8").replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:"'`])\/\/.*$/gm, "$1");
|
|
144
|
+
} catch {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
for (const spec of valueImports(code)) {
|
|
148
|
+
if (!spec.startsWith(".")) continue;
|
|
149
|
+
const base = resolve(dirname(file), spec);
|
|
150
|
+
const hit = [base, `${base}.ts`, `${base}.tsx`, `${base}.js`, join(base, "index.ts")].find(
|
|
151
|
+
(candidate) => existsSync(candidate) && statSync(candidate).isFile(),
|
|
152
|
+
);
|
|
153
|
+
if (hit) queue.push(hit);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const files = [...seen].map((file) => relative(root, file)).sort();
|
|
157
|
+
return [...files, "wrangler.jsonc", "package.json", "bun.lock"];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Pure: does the served Worker need re-uploading? `served` is `/healthz`'s `worker` object;
|
|
162
|
+
* `diffStatus` is `git diff --quiet <served.commit> HEAD -- <inputs>`'s exit status (0 same,
|
|
163
|
+
* 1 changed, anything else — an unknown or unreachable commit — unknowable). Null when it is
|
|
164
|
+
* current; otherwise the sentence saying why it is not. Unknown is behind: shipping is
|
|
165
|
+
* idempotent, and believing a fix is live when it is not is the failure this exists for.
|
|
166
|
+
*/
|
|
167
|
+
export function workerCodeBehind(served: { commit?: unknown; dirty?: unknown } | null, head: string, diffStatus: number | null): string | null {
|
|
168
|
+
const commit = typeof served?.commit === "string" ? served.commit : "";
|
|
169
|
+
if (!commit) return "/healthz names no `worker.commit` — nothing says which Worker is serving";
|
|
170
|
+
if (served?.dirty === true) return `the served Worker (${commit}) was deployed from a dirty tree`;
|
|
171
|
+
if (head.startsWith(commit) || commit.startsWith(head)) return null;
|
|
172
|
+
if (diffStatus === 0) return null;
|
|
173
|
+
if (diffStatus === 1) return `the Worker's code changed since the served Worker (${commit} → ${head.slice(0, 8)})`;
|
|
174
|
+
return `the served Worker's commit ${commit} is not readable in this checkout — cannot prove it is current`;
|
|
175
|
+
}
|
|
176
|
+
|
|
126
177
|
export interface ShipClientOptions extends PublicShellOptions {
|
|
127
178
|
/** The public origin, e.g. `https://roms.cursedalchemy.com`. */
|
|
128
179
|
base: string;
|
|
@@ -132,23 +183,52 @@ export interface ShipClientOptions extends PublicShellOptions {
|
|
|
132
183
|
root: string;
|
|
133
184
|
/** Runs `bun run worker:deploy` in `root`; returns its exit status. Injected for tests. */
|
|
134
185
|
runWorkerDeploy?: (root: string) => number | null;
|
|
186
|
+
/** `git <args>` in `root` → exit status and trimmed stdout. Injected for tests. */
|
|
187
|
+
git?: (root: string, args: readonly string[]) => { status: number | null; stdout: string };
|
|
188
|
+
/** What the Worker is built from ({@link workerInputs}); default computed from `root`. */
|
|
189
|
+
workerInputs?: readonly string[];
|
|
135
190
|
log?: (line: string) => void;
|
|
136
191
|
}
|
|
137
192
|
|
|
138
193
|
export interface ShipClientResult {
|
|
139
|
-
/** `already` — the public shell named this build; `shipped` — worker:deploy ran and exited 0; `failed` — it did not. */
|
|
194
|
+
/** `already` — the public shell named this build AND the served Worker is current; `shipped` — worker:deploy ran and exited 0; `failed` — it did not. */
|
|
140
195
|
outcome: "already" | "shipped" | "failed";
|
|
141
196
|
/** worker:deploy's exit status when it ran. */
|
|
142
197
|
status?: number | null;
|
|
143
198
|
before: PublicClientVerdict | null;
|
|
199
|
+
/** Why the Worker itself was behind ({@link workerCodeBehind}), when the client was not the reason. */
|
|
200
|
+
workerBehind?: string;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const spawnGit = (root: string, args: readonly string[]): { status: number | null; stdout: string } => {
|
|
204
|
+
const run = spawnSync("git", [...args], { cwd: root, encoding: "utf8" });
|
|
205
|
+
return { status: run.status, stdout: (run.stdout ?? "").trim() };
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
/** `/healthz`'s `worker` object, or null when it cannot be read. */
|
|
209
|
+
async function servedWorker(options: ShipClientOptions): Promise<{ commit?: unknown; dirty?: unknown } | null> {
|
|
210
|
+
try {
|
|
211
|
+
const ask = options.fetch ?? ((u: string, init: RequestInit) => fetch(u, init));
|
|
212
|
+
const response = await ask(new URL("/healthz", options.base).toString(), {
|
|
213
|
+
redirect: "manual",
|
|
214
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? 20_000),
|
|
215
|
+
headers: { accept: "application/json", ...(options.headers ?? {}) },
|
|
216
|
+
});
|
|
217
|
+
const body = (await response.json()) as { worker?: { commit?: unknown; dirty?: unknown } };
|
|
218
|
+
return body?.worker ?? null;
|
|
219
|
+
} catch {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
144
222
|
}
|
|
145
223
|
|
|
146
224
|
const spawnWorkerDeploy = (root: string): number | null =>
|
|
147
225
|
spawnSync("bun", ["run", "worker:deploy"], { cwd: root, stdio: ["ignore", "inherit", "inherit"] }).status;
|
|
148
226
|
|
|
149
227
|
/**
|
|
150
|
-
* The deploy step: when the public shell does not name this build
|
|
151
|
-
*
|
|
228
|
+
* The deploy step: when the public shell does not name this build — or it does but the served
|
|
229
|
+
* Worker (`/healthz` `worker.commit`) predates a change to what the Worker is built from
|
|
230
|
+
* ({@link workerInputs}) — run `worker:deploy` (its own stage walk first) so ONE command ships the
|
|
231
|
+
* release. Never throws for a fetch that fails — an
|
|
152
232
|
* unreadable public shell is "behind", because shipping the Worker is idempotent and the smoke's
|
|
153
233
|
* `public-client` row is what proves it took.
|
|
154
234
|
*/
|
|
@@ -162,8 +242,25 @@ export async function shipClientIfBehind(options: ShipClientOptions): Promise<Sh
|
|
|
162
242
|
);
|
|
163
243
|
before = comparePublicClient(html, options.staged, options.base);
|
|
164
244
|
if (before.matched) {
|
|
165
|
-
|
|
166
|
-
|
|
245
|
+
// 🔴 The client matching is not the Worker matching: roms 86e5e522 changed only
|
|
246
|
+
// worker/index.ts, this said "already serving it", and /healthz kept answering the
|
|
247
|
+
// previous Worker until worker:deploy was run by hand (2026-09-25).
|
|
248
|
+
const git = options.git ?? spawnGit;
|
|
249
|
+
const head = git(options.root, ["rev-parse", "HEAD"]).stdout;
|
|
250
|
+
const served = await servedWorker(options);
|
|
251
|
+
const commit = typeof served?.commit === "string" ? served.commit : "";
|
|
252
|
+
const inputs = options.workerInputs ?? (commit ? workerInputs(options.root) : []);
|
|
253
|
+
const atHead = commit !== "" && head !== "" && (head.startsWith(commit) || commit.startsWith(head));
|
|
254
|
+
const diff = commit && head && !atHead ? git(options.root, ["diff", "--quiet", commit, "HEAD", "--", ...inputs]).status : null;
|
|
255
|
+
const behind = workerCodeBehind(served, head, diff);
|
|
256
|
+
if (behind === null) {
|
|
257
|
+
log(` already serving it — ${before.built.join(", ")}, Worker ${commit}`);
|
|
258
|
+
return { outcome: "already", before };
|
|
259
|
+
}
|
|
260
|
+
log(` the client is current but ${behind} — shipping the Worker`);
|
|
261
|
+
log(" running `bun run worker:deploy` — the stage walk, then production");
|
|
262
|
+
const status = (options.runWorkerDeploy ?? spawnWorkerDeploy)(options.root);
|
|
263
|
+
return { outcome: status === 0 ? "shipped" : "failed", status, before, workerBehind: behind };
|
|
167
264
|
}
|
|
168
265
|
log(` ${workerBehind(before, options.base, options.root)}`);
|
|
169
266
|
} else {
|
package/src/relay.ts
CHANGED
|
@@ -316,6 +316,20 @@ const json = (status: number, body: unknown) =>
|
|
|
316
316
|
* the edge has noticed its old socket died, and a relay that sent to the dead one would time every
|
|
317
317
|
* request out for as long as that took.
|
|
318
318
|
*
|
|
319
|
+
* 🔴 **Never a hang across a redial (2026-09-25).** roms' post-deploy smoke died on curl's timeout
|
|
320
|
+
* asking a relayed path while the Mac's link reconnected twice in one minute. Two holes, both shut:
|
|
321
|
+
*
|
|
322
|
+
* · a replaced socket is closed by US, so no `webSocketClose` ever fires for it — its pending
|
|
323
|
+
* requests waited out {@link DEFAULT_RELAY_TIMEOUT_MS}. `accept` now answers each the link-down
|
|
324
|
+
* 503 before closing it;
|
|
325
|
+
* · workerd keeps listing a closed socket until its close handshake completes, which a dead Mac
|
|
326
|
+
* never finishes — and it was `getWebSockets()[0]`, ahead of the new one. {@link RelayLink.live}
|
|
327
|
+
* picks the NEWEST link not marked replaced.
|
|
328
|
+
*
|
|
329
|
+
* A request caught by either answers the same JSON 503 (`link: "down"`) as a Mac away, which is
|
|
330
|
+
* what every smoke already accepts. `relay.test.ts` holds both paths against a state that lists
|
|
331
|
+
* closed sockets the way workerd does.
|
|
332
|
+
*
|
|
319
333
|
* 🔴 A plain class with a `fetch` method, NOT `extends DurableObject`: importing
|
|
320
334
|
* `cloudflare:workers` leaks Worker globals over a Bun host's type graph. The hibernation handlers
|
|
321
335
|
* are found by name.
|
|
@@ -344,14 +358,43 @@ export class RelayLink {
|
|
|
344
358
|
return this.relay(request);
|
|
345
359
|
}
|
|
346
360
|
|
|
361
|
+
/**
|
|
362
|
+
* The link requests go to: the newest by `since` that `accept` has not marked replaced. Never
|
|
363
|
+
* `getWebSockets()[0]` — see the class header.
|
|
364
|
+
*/
|
|
365
|
+
protected live(): LinkSocket | undefined {
|
|
366
|
+
let best: LinkSocket | undefined;
|
|
367
|
+
let bestSince = "";
|
|
368
|
+
for (const socket of this.state.getWebSockets()) {
|
|
369
|
+
const attachment = socket.deserializeAttachment() as { since?: string; replaced?: boolean } | null | undefined;
|
|
370
|
+
if (attachment?.replaced) continue;
|
|
371
|
+
const since = attachment?.since ?? "";
|
|
372
|
+
if (best === undefined || since >= bestSince) {
|
|
373
|
+
best = socket;
|
|
374
|
+
bestSince = since;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return best;
|
|
378
|
+
}
|
|
379
|
+
|
|
347
380
|
status(): LinkStatus {
|
|
348
|
-
const socket = this.
|
|
381
|
+
const socket = this.live();
|
|
349
382
|
const attachment = socket?.deserializeAttachment() as { since?: string } | null | undefined;
|
|
350
383
|
return { connected: socket !== undefined, since: attachment?.since ?? null };
|
|
351
384
|
}
|
|
352
385
|
|
|
353
386
|
private accept(): Response {
|
|
354
|
-
for (const old of this.state.getWebSockets())
|
|
387
|
+
for (const old of this.state.getWebSockets()) {
|
|
388
|
+
// Closed by us, so no `webSocketClose` fires for it: fail what it holds NOW, or each waits
|
|
389
|
+
// out the timeout. Marked first, because workerd goes on listing it until the handshake ends.
|
|
390
|
+
try {
|
|
391
|
+
old.serializeAttachment({ ...((old.deserializeAttachment() as object | null) ?? {}), replaced: true });
|
|
392
|
+
} catch {}
|
|
393
|
+
this.failPending(old, "replaced by a newer link");
|
|
394
|
+
try {
|
|
395
|
+
old.close(4000, "replaced by a newer link");
|
|
396
|
+
} catch {}
|
|
397
|
+
}
|
|
355
398
|
const { client, server } = this.makePair();
|
|
356
399
|
this.state.acceptWebSocket(server);
|
|
357
400
|
server.serializeAttachment({ since: new Date().toISOString() });
|
|
@@ -362,17 +405,27 @@ export class RelayLink {
|
|
|
362
405
|
return json(503, { error: this.options.offlineMessage, link: "down" });
|
|
363
406
|
}
|
|
364
407
|
|
|
408
|
+
/** Answer every request waiting on `socket` with the link-down 503 — it will never be answered. */
|
|
409
|
+
private failPending(socket: LinkSocket, why: string): void {
|
|
410
|
+
for (const [id, waiting] of this.pending) {
|
|
411
|
+
if (waiting.socket !== socket) continue;
|
|
412
|
+
clearTimeout(waiting.timer);
|
|
413
|
+
this.pending.delete(id);
|
|
414
|
+
waiting.resolve(json(503, { error: `${this.options.app}'s link went down mid-request (${why}) — ask again`, link: "down" }));
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
365
418
|
/** Relay, and keep or drop what the answer says about the cookie that asked — see {@link OfflineCache}. */
|
|
366
419
|
private async relay(request: Request): Promise<Response> {
|
|
367
420
|
const cache = this.options.offline;
|
|
368
|
-
if (!cache) return this.
|
|
421
|
+
if (!cache) return this.live() ? this.send(request) : this.offline();
|
|
369
422
|
const url = new URL(request.url);
|
|
370
423
|
const cookie = cookieValue(request.headers.get("cookie"), cache.cookie);
|
|
371
424
|
const owner = cookie ? await sha256Hex(cookie) : null;
|
|
372
425
|
const keyed = `${url.pathname}${url.search}`;
|
|
373
426
|
const cacheable = owner !== null && request.method === "GET" && url.pathname.startsWith("/api/");
|
|
374
427
|
if (owner && cache.signOut.includes(url.pathname)) await this.forget(owner);
|
|
375
|
-
if (!this.
|
|
428
|
+
if (!this.live()) {
|
|
376
429
|
if (cacheable) {
|
|
377
430
|
const kept = await this.state.storage.get<Kept>(`c:${owner}:${keyed}`);
|
|
378
431
|
if (kept && this.now() - kept.at < cache.ttlMs) {
|
|
@@ -413,13 +466,13 @@ export class RelayLink {
|
|
|
413
466
|
const warm = await this.state.storage.get<{ cookie: string; at: number }>("warm");
|
|
414
467
|
if (!warm || this.now() - warm.at >= cache.ttlMs) return;
|
|
415
468
|
for (const path of paths) {
|
|
416
|
-
if (!this.
|
|
469
|
+
if (!this.live()) return;
|
|
417
470
|
await this.relay(new Request(`https://relay.link${path}`, { headers: { cookie: `${cache.cookie}=${warm.cookie}` } }));
|
|
418
471
|
}
|
|
419
472
|
}
|
|
420
473
|
|
|
421
474
|
private async send(request: Request): Promise<Response> {
|
|
422
|
-
const socket = this.
|
|
475
|
+
const socket = this.live();
|
|
423
476
|
if (!socket) return this.offline();
|
|
424
477
|
const tooBig = () => json(413, { error: `request body over the link's ${this.maxBodyBytes} bytes` });
|
|
425
478
|
const declared = Number(request.headers.get("content-length") ?? 0);
|
|
@@ -460,12 +513,7 @@ export class RelayLink {
|
|
|
460
513
|
}
|
|
461
514
|
|
|
462
515
|
webSocketClose(socket: LinkSocket, code: number, reason: string): void {
|
|
463
|
-
|
|
464
|
-
if (waiting.socket !== socket) continue;
|
|
465
|
-
clearTimeout(waiting.timer);
|
|
466
|
-
this.pending.delete(id);
|
|
467
|
-
waiting.resolve(json(502, { error: `${this.options.app}'s link closed mid-request (${code}${reason ? ` ${reason}` : ""})` }));
|
|
468
|
-
}
|
|
516
|
+
this.failPending(socket, `closed ${code}${reason ? ` ${reason}` : ""}`);
|
|
469
517
|
try {
|
|
470
518
|
socket.close(code, reason);
|
|
471
519
|
} catch {}
|
|
@@ -492,3 +540,23 @@ export async function acceptLink(
|
|
|
492
540
|
if (!(await linkKeyMatches(request.headers.get("authorization"), digest))) return json(401, { error: `not ${names.app}'s Mac` });
|
|
493
541
|
return link.fetch(new Request(`${new URL(request.url).origin}/__link`, request));
|
|
494
542
|
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* The Worker's call into the link object, with a THROW turned into the link-down 503.
|
|
546
|
+
*
|
|
547
|
+
* A Worker upload resets every Durable Object, and a request in flight in the old one rejects
|
|
548
|
+
* ("Durable Object reset because its code was updated"). Unhandled, that is Cloudflare's HTML
|
|
549
|
+
* error page — not JSON, not a 503, and a smoke's leg reads it as a broken release. The same
|
|
550
|
+
* answer as a Mac away is the honest one: ask again in a second.
|
|
551
|
+
*/
|
|
552
|
+
export async function askLink(
|
|
553
|
+
link: { fetch(request: Request): Promise<Response> },
|
|
554
|
+
request: Request,
|
|
555
|
+
app: string,
|
|
556
|
+
): Promise<Response> {
|
|
557
|
+
try {
|
|
558
|
+
return await link.fetch(request);
|
|
559
|
+
} catch (error) {
|
|
560
|
+
return json(503, { error: `${app}'s link object restarted mid-request (${(error as Error).message}) — ask again`, link: "down" });
|
|
561
|
+
}
|
|
562
|
+
}
|
package/src/workerDeploy.ts
CHANGED
|
@@ -38,7 +38,8 @@
|
|
|
38
38
|
* check green and its owner unable to sign in; the stage walk is what found why, and a step
|
|
39
39
|
* in the sequence is one a person in a hurry cannot skip;
|
|
40
40
|
* 5. build → 6. **schema** (`IF NOT EXISTS`, the database named explicitly, never the binding a
|
|
41
|
-
* forgotten `--env` would resolve
|
|
41
|
+
* forgotten `--env` would resolve; ordinary `--command` queries, never the blocking
|
|
42
|
+
* `--file` import — {@link schemaApplyArgv}) → 7. **deploy with the `--var` stamp** (commit AND dirty
|
|
42
43
|
* flag, so `/healthz` publishes what is running) → 8. **secrets** (after the deploy: a secret
|
|
43
44
|
* needs a script to attach to) → 9. **smoke**.
|
|
44
45
|
*
|
|
@@ -238,6 +239,8 @@ export interface DeployDeps {
|
|
|
238
239
|
run: (argv: readonly string[], env: Record<string, string>) => number;
|
|
239
240
|
/** `git <args>` in the checkout; trimmed stdout, `""` on failure. */
|
|
240
241
|
git: (args: readonly string[]) => string;
|
|
242
|
+
/** A file in the checkout, as text — the schema. Default: `readFileSync` against the process's cwd. */
|
|
243
|
+
read?: (path: string) => string;
|
|
241
244
|
log?: (line: string) => void;
|
|
242
245
|
error?: (line: string) => void;
|
|
243
246
|
}
|
|
@@ -260,13 +263,124 @@ export interface DeployResult {
|
|
|
260
263
|
export function workerDeployDeps(cwd: string): DeployDeps {
|
|
261
264
|
return {
|
|
262
265
|
run: (argv, env) => {
|
|
263
|
-
|
|
266
|
+
// a schema `--command` is kilobytes of DDL — the echo names it, it does not reprint it
|
|
267
|
+
console.log(` $ ${argv.map((arg) => (arg.length > 160 ? `${arg.slice(0, 120)}… (${arg.length} chars)` : arg)).join(" ")}`);
|
|
264
268
|
return spawnSync(argv[0] as string, argv.slice(1), { cwd, stdio: "inherit", env: { ...process.env, ...env } }).status ?? 1;
|
|
265
269
|
},
|
|
266
270
|
git: (args) => (spawnSync("git", [...args], { cwd, encoding: "utf8" }).stdout ?? "").trim(),
|
|
271
|
+
read: (path) => readFileSync(join(cwd, path), "utf8"),
|
|
267
272
|
};
|
|
268
273
|
}
|
|
269
274
|
|
|
275
|
+
/**
|
|
276
|
+
* The statements of a schema file, each on one line with its comments gone — split on the `;`s
|
|
277
|
+
* that end a statement, never on one inside a string, a quoted name, a comment, or a trigger's
|
|
278
|
+
* `BEGIN … END` / an expression's `CASE … END`. Hand-written schemas (patterns') format a
|
|
279
|
+
* CREATE TABLE across lines; generated ones (`d1SchemaText`) are already one per line.
|
|
280
|
+
*/
|
|
281
|
+
export function sqlStatements(text: string): string[] {
|
|
282
|
+
const statements: string[] = [];
|
|
283
|
+
let current = "";
|
|
284
|
+
let depth = 0;
|
|
285
|
+
let word = "";
|
|
286
|
+
const endWord = (): void => {
|
|
287
|
+
const w = word.toUpperCase();
|
|
288
|
+
word = "";
|
|
289
|
+
if (w === "CASE") depth++;
|
|
290
|
+
else if (w === "BEGIN" && /^\s*CREATE\s+(TEMP\s+|TEMPORARY\s+)?TRIGGER\b/i.test(current)) depth++;
|
|
291
|
+
else if (w === "END" && depth > 0) depth--;
|
|
292
|
+
};
|
|
293
|
+
for (let i = 0; i < text.length; i++) {
|
|
294
|
+
const c = text[i] as string;
|
|
295
|
+
if (/[A-Za-z_]/.test(c)) {
|
|
296
|
+
word += c;
|
|
297
|
+
current += c;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (word) endWord();
|
|
301
|
+
if (c === "-" && text[i + 1] === "-") {
|
|
302
|
+
while (i < text.length && text[i] !== "\n") i++;
|
|
303
|
+
current += " ";
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
if (c === "/" && text[i + 1] === "*") {
|
|
307
|
+
const close = text.indexOf("*/", i + 2);
|
|
308
|
+
if (close < 0) throw new Error("an unterminated /* comment");
|
|
309
|
+
i = close + 1;
|
|
310
|
+
current += " ";
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (c === "'" || c === '"' || c === "`" || c === "[") {
|
|
314
|
+
const closer = c === "[" ? "]" : c;
|
|
315
|
+
let j = i + 1;
|
|
316
|
+
for (;;) {
|
|
317
|
+
const at = text.indexOf(closer, j);
|
|
318
|
+
if (at < 0) throw new Error(`an unterminated ${c} quote`);
|
|
319
|
+
// SQL escapes a quote by doubling it: 'it''s'
|
|
320
|
+
if (closer !== "]" && text[at + 1] === closer) {
|
|
321
|
+
j = at + 2;
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
j = at;
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
current += text.slice(i, j + 1);
|
|
328
|
+
i = j;
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
if (c === ";" && depth === 0) {
|
|
332
|
+
const statement = current.replace(/\s+/g, " ").trim();
|
|
333
|
+
if (statement) statements.push(statement);
|
|
334
|
+
current = "";
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
current += /\s/.test(c) ? " " : c;
|
|
338
|
+
}
|
|
339
|
+
if (word) endWord();
|
|
340
|
+
const tail = current.replace(/\s+/g, " ").trim();
|
|
341
|
+
if (tail) statements.push(tail);
|
|
342
|
+
return statements;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* D1's limit on one SQL statement is 100 KB; a command is kept well under it (and under any argv
|
|
347
|
+
* ceiling) by starting a new one before it would cross this many bytes.
|
|
348
|
+
*/
|
|
349
|
+
export const SCHEMA_COMMAND_BYTES = 90_000;
|
|
350
|
+
|
|
351
|
+
/** {@link sqlStatements}, packed into as few `--command` strings as {@link SCHEMA_COMMAND_BYTES} allows. */
|
|
352
|
+
export function schemaCommands(text: string, maxBytes: number = SCHEMA_COMMAND_BYTES): string[] {
|
|
353
|
+
const commands: string[] = [];
|
|
354
|
+
let current = "";
|
|
355
|
+
for (const statement of sqlStatements(text)) {
|
|
356
|
+
const next = current ? `${current}; ${statement}` : statement;
|
|
357
|
+
if (current && Buffer.byteLength(`${next};`) > maxBytes) {
|
|
358
|
+
commands.push(`${current};`);
|
|
359
|
+
current = statement;
|
|
360
|
+
} else current = next;
|
|
361
|
+
}
|
|
362
|
+
if (current) commands.push(`${current};`);
|
|
363
|
+
const over = commands.find((command) => Buffer.byteLength(command) > maxBytes);
|
|
364
|
+
if (over) throw new Error(`one statement is over ${maxBytes} bytes, past what D1 takes in a query: ${over.slice(0, 80)}…`);
|
|
365
|
+
return commands;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* The schema step's argv — ordinary queries, `wrangler d1 execute --remote --command`, one per
|
|
370
|
+
* {@link schemaCommands} chunk.
|
|
371
|
+
*
|
|
372
|
+
* 🔴 NEVER `--file`. With `--remote`, `--file` goes through D1's IMPORT API, and wrangler says what
|
|
373
|
+
* that costs: "your D1 database will be unavailable to serve queries" for the length of it — the
|
|
374
|
+
* same blocking class as `wrangler d1 export`, which failed 72 of 198 concurrent family reads on
|
|
375
|
+
* 2026-09-24 (`pullD1ToSqlite`'s header). This step runs on every production deploy of every
|
|
376
|
+
* Worker app with a schema, and the schema is idempotent, so the import blocked production to
|
|
377
|
+
* change nothing. `--command` runs through the query API like any request the Worker makes.
|
|
378
|
+
* `workerDeploy.test.ts` pins that no argv here ever carries `--file`.
|
|
379
|
+
*/
|
|
380
|
+
export function schemaApplyArgv(databaseName: string, commands: readonly string[], envArgs: readonly string[]): string[][] {
|
|
381
|
+
return commands.map((command) => ["bunx", "wrangler", "d1", "execute", databaseName, "--remote", "--command", command, "-y", ...envArgs]);
|
|
382
|
+
}
|
|
383
|
+
|
|
270
384
|
/**
|
|
271
385
|
* Run the sequence in the header's order, stopping at the first red with a sentence. Never
|
|
272
386
|
* exits — the caller does, with {@link DeployResult.code}.
|
|
@@ -319,8 +433,15 @@ export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): Deplo
|
|
|
319
433
|
if (spec.databaseName) {
|
|
320
434
|
const schema = spec.schemaFile ?? "db/schema.sql";
|
|
321
435
|
step(`apply ${schema} to D1 \`${spec.databaseName}\``);
|
|
322
|
-
|
|
323
|
-
|
|
436
|
+
let commands: string[];
|
|
437
|
+
try {
|
|
438
|
+
commands = schemaCommands((deps.read ?? ((path: string) => readFileSync(path, "utf8")))(schema));
|
|
439
|
+
} catch (cause) {
|
|
440
|
+
return stop("schema", `${schema} could not be read as statements (${(cause as Error).message}) — nothing was deployed.`);
|
|
441
|
+
}
|
|
442
|
+
for (const argv of schemaApplyArgv(spec.databaseName, commands, envArgs)) {
|
|
443
|
+
if (deps.run(argv, spec.credential) !== 0) return stop("schema", "the schema did not apply — nothing was deployed.");
|
|
444
|
+
}
|
|
324
445
|
}
|
|
325
446
|
step(`deploy ${spec.workerName} @ ${commit.slice(0, 8)}${dirty ? " (dirty — stage only)" : ""}`);
|
|
326
447
|
const stamp = spec.stampVars
|