cursedops 0.10.7 → 0.10.9
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/README.md +1 -0
- package/package.json +7 -1
- package/src/edgeFetch.ts +32 -0
- package/src/publicClient.ts +175 -0
- package/src/relay.ts +80 -12
package/README.md
CHANGED
|
@@ -28,6 +28,7 @@ bun add cursedops
|
|
|
28
28
|
| `cursedops/relay` | the WORKER half of a relay — a Worker in front of a Mac-bound app over ONE outbound WebSocket: the frames (station's wire, byte for byte), the key-digest door (`acceptLink`), and `RelayLink`, the Durable Object an app subclasses with its name, body ceiling, timeout and (optionally) station's per-cookie offline cache. No `node:` import (0.10.0, lifted from station for roms, task 065) |
|
|
29
29
|
| `cursedops/relay-link` | the MAC half — `dialLink` (answer each frame with the app's own `fetch`, redial 1 s → 60 s on close, no timer otherwise), `readLinkSettings` for `<APP>_LINK_URL`/`<APP>_LINK_KEY`, and `rotateLinkKey` (digest to the Worker FIRST, the key to the 0600 file only if it took) (0.10.0) |
|
|
30
30
|
| `cursedops/relay-stage` | the signed-in STAGE WALK's skeleton both relay apps shared — `stageByteCheck` (SHA-256 of every built file, a stale byte re-asked 4 × 10 s), `putStageLinkDigest` (`--env stage` only), `grantStageOwner` (auth-stage's 2FA grant, never production's), `oldKeyRefused` (the rotation leg: 20 s refused window, 3 min ceiling), `awaitSystemResolver`, `servedPath`, `STAGE_AUTH_URL`. With `relayWorkerDeployCli` in `cursedops/worker-deploy`, a relay app's whole `worker:deploy` as data (0.10.6) |
|
|
31
|
+
| `cursedops/public-client` | does a relay app's PUBLIC hostname serve the client this release built? `comparePublicClient` (built assets the shells name, never bodies), `stagedShell`, `publicShell` (cache-busted, Access headers as an option), `recordPublicClient` — the smoke's `public-client` row, an EDGE fault (exit 2, never a rollback) — and `shipClientIfBehind`, the deploy step that runs `worker:deploy` when the public shell is behind. Lifted from roms for station (0.10.8) |
|
|
31
32
|
| `cursedops/public-surface` | the ratchet on a LIBRARY's public surface — a symbol count per export subpath against a committed baseline that may only fall — and its `public-surface` bin. Not an app's: the one entry here admitted for three published libraries, see below |
|
|
32
33
|
|
|
33
34
|
Bun, zero runtime dependencies, ships TypeScript source. Nothing here knows an app's
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedops",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.9",
|
|
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": {
|
|
@@ -139,6 +139,12 @@
|
|
|
139
139
|
"bun": "./src/relayStage.ts",
|
|
140
140
|
"source": "./src/relayStage.ts",
|
|
141
141
|
"import": "./src/relayStage.ts"
|
|
142
|
+
},
|
|
143
|
+
"./public-client": {
|
|
144
|
+
"types": "./src/publicClient.ts",
|
|
145
|
+
"bun": "./src/publicClient.ts",
|
|
146
|
+
"source": "./src/publicClient.ts",
|
|
147
|
+
"import": "./src/publicClient.ts"
|
|
142
148
|
}
|
|
143
149
|
},
|
|
144
150
|
"bin": {
|
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
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cursedops/public-client` — does the PUBLIC hostname of a relay app hand people the client this
|
|
3
|
+
* release built? Lifted from roms for station (0.10.8); both are relay apps.
|
|
4
|
+
*
|
|
5
|
+
* ## Why this is its own question (2026-09-24, measured on roms)
|
|
6
|
+
*
|
|
7
|
+
* A relay app's shell — `/`, `/assets/*` — is the relay WORKER's static assets, uploaded only by
|
|
8
|
+
* `worker:deploy`. The Mac end still stages a client and reports it on `/healthz`, so a smoke's
|
|
9
|
+
* `client-commit` row went green for roms `7e13f8b5` (the 30 Hz fix) while the public `/` kept
|
|
10
|
+
* naming `/assets/index-BgukLK5o.js` — the build before it — even cache-busted. Nothing that asks
|
|
11
|
+
* the ORIGIN can see that; only the public shell can. station had the same gap, hidden only
|
|
12
|
+
* because `worker:deploy` was run by hand first.
|
|
13
|
+
*
|
|
14
|
+
* The comparison is by the built assets the shell NAMES (`cursedops/smoke` `builtAssetsIn`), never
|
|
15
|
+
* by body: the zone injects a beacon at the edge that loopback never carries.
|
|
16
|
+
*
|
|
17
|
+
* ```ts
|
|
18
|
+
* import { recordPublicClient, shipClientIfBehind, stagedShell } from "cursedops/public-client";
|
|
19
|
+
* // deploy.ts, between the origin probe and the public smoke:
|
|
20
|
+
* const shipped = await shipClientIfBehind({ base, staged: stagedShell(dataDir), root, headers });
|
|
21
|
+
* // smoke-deployed.ts — a mismatch is an EDGE fault (exit 2), never a rollback:
|
|
22
|
+
* await guarded(smoke, PUBLIC_CLIENT_CHECK, () => recordPublicClient(smoke, staged, { root, headers }));
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* `headers` is how an app behind Cloudflare Access (station) passes the fleet's service token; an
|
|
26
|
+
* anonymous `/` there is a 302 to the Access issuer, which names no built asset and never matches.
|
|
27
|
+
*/
|
|
28
|
+
import { spawnSync } from "node:child_process";
|
|
29
|
+
import { readFileSync } from "node:fs";
|
|
30
|
+
import { join } from "node:path";
|
|
31
|
+
import { builtAssetsIn, type Smoke } from "cursedops/smoke";
|
|
32
|
+
import { stagedClientDir } from "cursedops/staged-client";
|
|
33
|
+
|
|
34
|
+
/** The smoke row's name — one spelling for every relay app. */
|
|
35
|
+
export const PUBLIC_CLIENT_CHECK = "public-client";
|
|
36
|
+
|
|
37
|
+
export interface PublicClientVerdict {
|
|
38
|
+
matched: boolean;
|
|
39
|
+
/** The built files the public shell names. */
|
|
40
|
+
served: string[];
|
|
41
|
+
/** The built files this release's staged shell names. */
|
|
42
|
+
built: string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Pure: the two shells' built assets, compared. An empty list on either side never matches. */
|
|
46
|
+
export function comparePublicClient(publicHtml: string, stagedHtml: string, base: string): PublicClientVerdict {
|
|
47
|
+
const served = builtAssetsIn(publicHtml, base);
|
|
48
|
+
const built = builtAssetsIn(stagedHtml, base);
|
|
49
|
+
return { matched: built.length > 0 && served.join(",") === built.join(","), served, built };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The staged client's `index.html` under an app's data dir — the shell this release built — or `null` when nothing is staged. */
|
|
53
|
+
export function stagedShell(dataDir: string): string | null {
|
|
54
|
+
const dir = stagedClientDir(dataDir);
|
|
55
|
+
if (!dir) return null;
|
|
56
|
+
try {
|
|
57
|
+
return readFileSync(join(dir, "index.html"), "utf8");
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface PublicShellOptions {
|
|
64
|
+
/** Sent with the request — Cloudflare Access service-token headers for an app behind Access. */
|
|
65
|
+
headers?: Record<string, string>;
|
|
66
|
+
timeoutMs?: number;
|
|
67
|
+
/** Injected for tests. */
|
|
68
|
+
fetch?: (url: string, init: RequestInit) => Promise<Response>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Fetch the public `/`, cache-busted — the question is what the hostname HAS, not what a repeat visitor gets. */
|
|
72
|
+
export async function publicShell(base: string, options: PublicShellOptions = {}): Promise<{ status: number; html: string }> {
|
|
73
|
+
const url = new URL("/", base);
|
|
74
|
+
url.searchParams.set("cb", Math.random().toString(36).slice(2));
|
|
75
|
+
const ask = options.fetch ?? ((u: string, init: RequestInit) => fetch(u, init));
|
|
76
|
+
const response = await ask(url.toString(), {
|
|
77
|
+
redirect: "manual",
|
|
78
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? 20_000),
|
|
79
|
+
headers: { accept: "text/html", ...(options.headers ?? {}) },
|
|
80
|
+
});
|
|
81
|
+
return { status: response.status, html: await response.text().catch(() => "") };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The sentence both the smoke and the deploy print when the halves disagree. `root` is the app's checkout. */
|
|
85
|
+
export function workerBehind(verdict: PublicClientVerdict, base: string, root: string): string {
|
|
86
|
+
return (
|
|
87
|
+
`🔴 ${base} serves a client naming ${verdict.served.join(", ") || "(no built assets)"}, but this release built ` +
|
|
88
|
+
`${verdict.built.join(", ") || "(nothing staged)"}. The public shell is the relay Worker's static assets, which only ` +
|
|
89
|
+
`\`worker:deploy\` uploads — people are still on the previous client. \`cd ${root} && bun run worker:deploy\``
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface RecordPublicClientOptions extends PublicShellOptions {
|
|
94
|
+
/** The app's checkout, for the pasteable `worker:deploy` command. */
|
|
95
|
+
root: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The smoke's `public-client` row. A mismatch — or nothing staged to compare with — is recorded as
|
|
100
|
+
* a failure AND marked an edge fault, so the run settles to exit 2: the Mac end this deploy put up
|
|
101
|
+
* is correct, and reverting it cannot upload a Worker. Returns whether it matched.
|
|
102
|
+
*/
|
|
103
|
+
export async function recordPublicClient(smoke: Smoke, staged: string | null, options: RecordPublicClientOptions): Promise<boolean> {
|
|
104
|
+
if (staged === null) {
|
|
105
|
+
smoke.record(
|
|
106
|
+
PUBLIC_CLIENT_CHECK,
|
|
107
|
+
false,
|
|
108
|
+
"🔴 no staged client to compare with — `bun run deploy` stages one; nothing says which client people should have",
|
|
109
|
+
);
|
|
110
|
+
smoke.markEdgeFault();
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
const { html } = await publicShell(smoke.base, options);
|
|
114
|
+
const verdict = comparePublicClient(html, staged, smoke.base);
|
|
115
|
+
smoke.record(
|
|
116
|
+
PUBLIC_CLIENT_CHECK,
|
|
117
|
+
verdict.matched,
|
|
118
|
+
verdict.matched
|
|
119
|
+
? `the public shell names this release's build — ${verdict.built.join(", ")}`
|
|
120
|
+
: workerBehind(verdict, smoke.base, options.root),
|
|
121
|
+
);
|
|
122
|
+
if (!verdict.matched) smoke.markEdgeFault();
|
|
123
|
+
return verdict.matched;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface ShipClientOptions extends PublicShellOptions {
|
|
127
|
+
/** The public origin, e.g. `https://roms.cursedalchemy.com`. */
|
|
128
|
+
base: string;
|
|
129
|
+
/** The staged shell ({@link stagedShell}); `null` means nothing staged — the Worker is shipped anyway. */
|
|
130
|
+
staged: string | null;
|
|
131
|
+
/** The app's checkout — where `bun run worker:deploy` runs. */
|
|
132
|
+
root: string;
|
|
133
|
+
/** Runs `bun run worker:deploy` in `root`; returns its exit status. Injected for tests. */
|
|
134
|
+
runWorkerDeploy?: (root: string) => number | null;
|
|
135
|
+
log?: (line: string) => void;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface ShipClientResult {
|
|
139
|
+
/** `already` — the public shell named this build; `shipped` — worker:deploy ran and exited 0; `failed` — it did not. */
|
|
140
|
+
outcome: "already" | "shipped" | "failed";
|
|
141
|
+
/** worker:deploy's exit status when it ran. */
|
|
142
|
+
status?: number | null;
|
|
143
|
+
before: PublicClientVerdict | null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const spawnWorkerDeploy = (root: string): number | null =>
|
|
147
|
+
spawnSync("bun", ["run", "worker:deploy"], { cwd: root, stdio: ["ignore", "inherit", "inherit"] }).status;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The deploy step: when the public shell does not name this build, run `worker:deploy` (its own
|
|
151
|
+
* stage walk first) so ONE command ships the release. Never throws for a fetch that fails — an
|
|
152
|
+
* unreadable public shell is "behind", because shipping the Worker is idempotent and the smoke's
|
|
153
|
+
* `public-client` row is what proves it took.
|
|
154
|
+
*/
|
|
155
|
+
export async function shipClientIfBehind(options: ShipClientOptions): Promise<ShipClientResult> {
|
|
156
|
+
const log = options.log ?? console.log;
|
|
157
|
+
let before: PublicClientVerdict | null = null;
|
|
158
|
+
if (options.staged !== null) {
|
|
159
|
+
const html = await publicShell(options.base, options).then(
|
|
160
|
+
(shell) => shell.html,
|
|
161
|
+
() => "",
|
|
162
|
+
);
|
|
163
|
+
before = comparePublicClient(html, options.staged, options.base);
|
|
164
|
+
if (before.matched) {
|
|
165
|
+
log(` already serving it — ${before.built.join(", ")}`);
|
|
166
|
+
return { outcome: "already", before };
|
|
167
|
+
}
|
|
168
|
+
log(` ${workerBehind(before, options.base, options.root)}`);
|
|
169
|
+
} else {
|
|
170
|
+
log(" nothing staged to compare with — shipping the Worker's client anyway");
|
|
171
|
+
}
|
|
172
|
+
log(" running `bun run worker:deploy` — the stage walk, then production");
|
|
173
|
+
const status = (options.runWorkerDeploy ?? spawnWorkerDeploy)(options.root);
|
|
174
|
+
return { outcome: status === 0 ? "shipped" : "failed", status, before };
|
|
175
|
+
}
|
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
|
+
}
|