cursedops 0.10.10 → 0.10.12
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/relay.ts +24 -1
- package/src/relayLink.ts +83 -5
- package/src/workerDeploy.ts +65 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedops",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.12",
|
|
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/relay.ts
CHANGED
|
@@ -59,6 +59,16 @@ export interface ResponseFrame {
|
|
|
59
59
|
|
|
60
60
|
export type LinkFrame = RequestFrame | ResponseFrame;
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* The link's keepalive, as bare text (never JSON, so neither {@link parseFrame} nor
|
|
64
|
+
* {@link parseWarmFrame} can mistake one for a frame). The Mac sends {@link LINK_PING}; the
|
|
65
|
+
* object answers {@link LINK_PONG} through `setWebSocketAutoResponse`, which the runtime does
|
|
66
|
+
* WITHOUT waking a hibernated object — so the heartbeat bills nothing. `cursedops/relay-link`
|
|
67
|
+
* has why it exists: a half-open socket after the Mac slept.
|
|
68
|
+
*/
|
|
69
|
+
export const LINK_PING = "ping";
|
|
70
|
+
export const LINK_PONG = "pong";
|
|
71
|
+
|
|
62
72
|
/**
|
|
63
73
|
* Sent UP by the Mac once, right after it connects (station, task 089): the GETs every page
|
|
64
74
|
* performs, so a {@link RelayLink} with an offline cache can refresh it while the Mac is awake.
|
|
@@ -223,6 +233,8 @@ export interface LinkState {
|
|
|
223
233
|
acceptWebSocket(socket: LinkSocket, tags?: string[]): void;
|
|
224
234
|
getWebSockets(tag?: string): LinkSocket[];
|
|
225
235
|
storage: LinkStorage;
|
|
236
|
+
/** workerd's — answers {@link LINK_PING} without waking the object. Optional: a test has none. */
|
|
237
|
+
setWebSocketAutoResponse?(pair: unknown): void;
|
|
226
238
|
}
|
|
227
239
|
|
|
228
240
|
/** What `/healthz` and a smoke may know about the link. No content — the body is public. */
|
|
@@ -349,6 +361,9 @@ export class RelayLink {
|
|
|
349
361
|
this.timeoutMs = options.timeoutMs ?? DEFAULT_RELAY_TIMEOUT_MS;
|
|
350
362
|
this.maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
|
|
351
363
|
this.now = options.now ?? Date.now;
|
|
364
|
+
// The heartbeat's answer, given by the runtime while the object sleeps (see LINK_PING).
|
|
365
|
+
const Pair = (globalThis as { WebSocketRequestResponsePair?: new (request: string, response: string) => unknown }).WebSocketRequestResponsePair;
|
|
366
|
+
if (Pair && state.setWebSocketAutoResponse) state.setWebSocketAutoResponse(new Pair(LINK_PING, LINK_PONG));
|
|
352
367
|
}
|
|
353
368
|
|
|
354
369
|
async fetch(request: Request): Promise<Response> {
|
|
@@ -499,8 +514,16 @@ export class RelayLink {
|
|
|
499
514
|
});
|
|
500
515
|
}
|
|
501
516
|
|
|
502
|
-
webSocketMessage(
|
|
517
|
+
webSocketMessage(socket: LinkSocket, message: string | ArrayBuffer): void | Promise<void> {
|
|
503
518
|
if (typeof message !== "string") return;
|
|
519
|
+
// Only reached where the runtime's auto-response is not armed (a test, an old runtime): a
|
|
520
|
+
// Mac whose pings went unanswered would redial every minute.
|
|
521
|
+
if (message === LINK_PING) {
|
|
522
|
+
try {
|
|
523
|
+
socket.send(LINK_PONG);
|
|
524
|
+
} catch {}
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
504
527
|
const warm = parseWarmFrame(message);
|
|
505
528
|
if (warm) return this.warm(warm.paths);
|
|
506
529
|
const frame = parseFrame(message);
|
package/src/relayLink.ts
CHANGED
|
@@ -10,13 +10,26 @@
|
|
|
10
10
|
* 🔴 **The Mac listens on nothing for the relay.** What reaches the app arrives down a socket this
|
|
11
11
|
* process opened to a hostname it verified over TLS, holding a key only the Worker's digest matches.
|
|
12
12
|
*
|
|
13
|
-
* 🔴 **No timer asks
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* 🔴 **No timer asks for DATA** (owner, 2026-09-15: *"There should be no polling"*). One timer
|
|
14
|
+
* exists, and it is a keepalive on the idle socket, not a request for anything: {@link dialLink}'s
|
|
15
|
+
* heartbeat. It used to say "a sleeping Mac's socket dies, and the link is back within seconds of
|
|
16
|
+
* waking" — measured FALSE on 2026-09-25. The owner powered the Mac off and on; both `com.roms.link`
|
|
17
|
+
* and `com.station.link` came back up holding a socket the Worker had long since dropped. No `close`
|
|
18
|
+
* event ever fired on the Mac for a half-open TCP connection, so nothing redialed, and roms and
|
|
19
|
+
* station answered "the Mac is not connected" for every API call with the Mac awake, logging
|
|
20
|
+
* `✓ linked` the whole time. A `ping` every {@link LINK_HEARTBEAT_MS} that the Worker's object
|
|
21
|
+
* answers `pong` WITHOUT waking (`setWebSocketAutoResponse`, `cursedops/relay`) is what notices, and
|
|
22
|
+
* a tick that arrives far later than it was scheduled is the Mac having slept — redialed at once.
|
|
16
23
|
*/
|
|
17
24
|
import { spawnSync } from "node:child_process";
|
|
18
25
|
import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
19
|
-
import { frameToRequest, parseFrame, type ResponseFrame, responseFrame, sha256Hex, toBase64 } from "cursedops/relay";
|
|
26
|
+
import { frameToRequest, LINK_PING, LINK_PONG, parseFrame, type ResponseFrame, responseFrame, sha256Hex, toBase64 } from "cursedops/relay";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* How often an idle link proves the Worker still holds its other end. A dead socket is noticed
|
|
30
|
+
* within two of these (one ping unanswered), and a slept Mac on its first tick after waking.
|
|
31
|
+
*/
|
|
32
|
+
export const LINK_HEARTBEAT_MS = 30_000;
|
|
20
33
|
import { readEnvFile } from "cursedops/worker-deploy";
|
|
21
34
|
|
|
22
35
|
export interface LinkSettings {
|
|
@@ -129,11 +142,24 @@ export interface DialOptions {
|
|
|
129
142
|
/** Sent right after each connect — station's `warm` frame. */
|
|
130
143
|
onOpen?: (socket: DialSocket) => void;
|
|
131
144
|
log?: (line: string) => void;
|
|
145
|
+
/** Default {@link LINK_HEARTBEAT_MS}; 0 turns the heartbeat off (a test of the redial alone). */
|
|
146
|
+
heartbeatMs?: number;
|
|
132
147
|
/** Tests only. */
|
|
133
148
|
openSocket?: (url: string, headers: Record<string, string>) => DialSocket;
|
|
134
149
|
setTimer?: (fn: () => void, ms: number) => unknown;
|
|
150
|
+
/** Tests only — a repeating timer; returns its cancel. */
|
|
151
|
+
every?: (fn: () => void, ms: number) => () => void;
|
|
152
|
+
/** Tests only — the wall clock the sleep detector reads. */
|
|
153
|
+
now?: () => number;
|
|
135
154
|
}
|
|
136
155
|
|
|
156
|
+
const repeat = (fn: () => void, ms: number): (() => void) => {
|
|
157
|
+
const timer = setInterval(fn, ms);
|
|
158
|
+
// Never the reason the process stays up — the socket is.
|
|
159
|
+
(timer as { unref?: () => void }).unref?.();
|
|
160
|
+
return () => clearInterval(timer);
|
|
161
|
+
};
|
|
162
|
+
|
|
137
163
|
export interface DialedLink {
|
|
138
164
|
/** Stop reconnecting and close the socket. */
|
|
139
165
|
close(): void;
|
|
@@ -158,25 +184,75 @@ export function dialLink(options: DialOptions): DialedLink {
|
|
|
158
184
|
const log = options.log ?? ((line: string) => console.log(line));
|
|
159
185
|
const open = options.openSocket ?? bunSocket;
|
|
160
186
|
const later = options.setTimer ?? ((fn: () => void, ms: number) => setTimeout(fn, ms));
|
|
187
|
+
const every = options.every ?? repeat;
|
|
188
|
+
const now = options.now ?? Date.now;
|
|
189
|
+
const heartbeatMs = options.heartbeatMs ?? LINK_HEARTBEAT_MS;
|
|
161
190
|
let backoff = 1_000;
|
|
162
191
|
let stopped = false;
|
|
163
192
|
let current: DialSocket | null = null;
|
|
164
193
|
let isOpen = false;
|
|
194
|
+
let stopHeartbeat: (() => void) | null = null;
|
|
165
195
|
const connect = () => {
|
|
166
196
|
if (stopped) return;
|
|
167
197
|
const socket = open(options.settings.url, { ...(options.headers ?? {}), authorization: `Bearer ${options.settings.key}` });
|
|
168
198
|
current = socket;
|
|
199
|
+
/**
|
|
200
|
+
* Give up on THIS socket and dial a new one now. 🔴 Never by waiting for its `close`: a
|
|
201
|
+
* half-open socket's close handshake waits on a peer that is gone, which is the whole
|
|
202
|
+
* failure. Its late events are ignored because it is no longer `current`.
|
|
203
|
+
*/
|
|
204
|
+
const abandon = (why: string) => {
|
|
205
|
+
if (current !== socket) return;
|
|
206
|
+
stopHeartbeat?.();
|
|
207
|
+
stopHeartbeat = null;
|
|
208
|
+
isOpen = false;
|
|
209
|
+
current = null;
|
|
210
|
+
log(`[${options.app}-link] ✗ ${why}; redialing now`);
|
|
211
|
+
try {
|
|
212
|
+
socket.close(4001, why.slice(0, 120));
|
|
213
|
+
} catch {}
|
|
214
|
+
backoff = 1_000;
|
|
215
|
+
connect();
|
|
216
|
+
};
|
|
217
|
+
let awaitingPong = false;
|
|
169
218
|
socket.addEventListener("open", () => {
|
|
219
|
+
if (current !== socket) return;
|
|
170
220
|
backoff = 1_000;
|
|
171
221
|
isOpen = true;
|
|
172
222
|
log(`[${options.app}-link] ✓ linked to ${origin} — answering its requests`);
|
|
173
223
|
options.onOpen?.(socket);
|
|
224
|
+
if (heartbeatMs > 0) {
|
|
225
|
+
let lastTick = now();
|
|
226
|
+
stopHeartbeat = every(() => {
|
|
227
|
+
const at = now();
|
|
228
|
+
const late = at - lastTick;
|
|
229
|
+
lastTick = at;
|
|
230
|
+
// The timer could not fire because the machine was asleep — whatever the socket
|
|
231
|
+
// says, the Worker dropped its end long ago (the owner's power cycle, 2026-09-25).
|
|
232
|
+
if (late > heartbeatMs * 3) return abandon(`no tick for ${Math.round(late / 1000)}s — this Mac slept`);
|
|
233
|
+
if (awaitingPong) return abandon(`the Worker did not answer a ping within ${heartbeatMs / 1000}s — the socket is half-open`);
|
|
234
|
+
awaitingPong = true;
|
|
235
|
+
try {
|
|
236
|
+
socket.send(LINK_PING);
|
|
237
|
+
} catch (error) {
|
|
238
|
+
abandon(`a ping could not be sent (${(error as Error).message})`);
|
|
239
|
+
}
|
|
240
|
+
}, heartbeatMs);
|
|
241
|
+
}
|
|
174
242
|
});
|
|
175
243
|
socket.addEventListener("message", async (event) => {
|
|
176
|
-
|
|
244
|
+
if (current !== socket) return;
|
|
245
|
+
// ANY frame proves the far end is there; a pong carries nothing else.
|
|
246
|
+
awaitingPong = false;
|
|
247
|
+
const text = String(event.data);
|
|
248
|
+
if (text === LINK_PONG) return;
|
|
249
|
+
const reply = await answerFrame(options.fetch, text, origin, options.app);
|
|
177
250
|
if (reply) socket.send(JSON.stringify(reply));
|
|
178
251
|
});
|
|
179
252
|
socket.addEventListener("close", (event) => {
|
|
253
|
+
if (current !== socket) return;
|
|
254
|
+
stopHeartbeat?.();
|
|
255
|
+
stopHeartbeat = null;
|
|
180
256
|
isOpen = false;
|
|
181
257
|
if (stopped) return;
|
|
182
258
|
log(`[${options.app}-link] link closed (${event.code}${event.reason ? ` ${event.reason}` : ""}); reconnecting in ${backoff / 1000}s`);
|
|
@@ -191,6 +267,8 @@ export function dialLink(options: DialOptions): DialedLink {
|
|
|
191
267
|
return {
|
|
192
268
|
close() {
|
|
193
269
|
stopped = true;
|
|
270
|
+
stopHeartbeat?.();
|
|
271
|
+
stopHeartbeat = null;
|
|
194
272
|
current?.close(1000, "closed by the Mac");
|
|
195
273
|
},
|
|
196
274
|
connected: () => isOpen,
|
package/src/workerDeploy.ts
CHANGED
|
@@ -193,6 +193,61 @@ export function underCpuTail(wrapper: readonly string[], env: WorkerEnv, command
|
|
|
193
193
|
return [...wrapper, "--env", env, "--", ...command];
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
+
/** A zone name off a route: its `zone_name`, or the last two labels of a custom domain's host. */
|
|
197
|
+
const routeZones = (routes: unknown): { zones: Set<string>; hosts: Set<string> } => {
|
|
198
|
+
const zones = new Set<string>();
|
|
199
|
+
const hosts = new Set<string>();
|
|
200
|
+
for (const route of Array.isArray(routes) ? routes : []) {
|
|
201
|
+
const r = (typeof route === "string" ? { pattern: route } : route) as { pattern?: string; zone_name?: string };
|
|
202
|
+
const host = (r.pattern ?? "").replace(/^https?:\/\//, "").split("/")[0]?.replace(/^\*\.?/, "") ?? "";
|
|
203
|
+
if (host) hosts.add(host);
|
|
204
|
+
const zone = r.zone_name ?? host.split(".").slice(-2).join(".");
|
|
205
|
+
if (zone) zones.add(zone);
|
|
206
|
+
}
|
|
207
|
+
return { zones, hosts };
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* 🔴 Why a Worker must not `fetch` another hostname on its own zone — refused at deploy, for every
|
|
212
|
+
* Worker app and every relay, because this is the one seam all of them pass through.
|
|
213
|
+
*
|
|
214
|
+
* A subrequest from a Worker to a hostname on the SAME zone skips every other Worker's route and
|
|
215
|
+
* goes straight to the zone's ORIGIN. On `cursedalchemy.com` the origin of `auth.` and
|
|
216
|
+
* `binary-server.` is a tunnel into the owner's Mac, so the call works while the Mac is on and
|
|
217
|
+
* dies the moment it is off, with nothing in any suite able to tell. Met twice: auth's JWKS
|
|
218
|
+
* refresh (task 2150, fixed with an `AUTH` binding), then every server-side byte read — the owner
|
|
219
|
+
* turned the Mac off on 2026-09-25 and family's tree lost every face, although its bytes rest in
|
|
220
|
+
* R2. The cure is a service binding, which reaches the Worker itself.
|
|
221
|
+
*
|
|
222
|
+
* The rule, over the config wrangler deploys for `env` (a named env inherits none of `vars`,
|
|
223
|
+
* `routes` or `services`): every `vars` entry named `*_URL` whose https host is a DIFFERENT host on
|
|
224
|
+
* a zone this Worker is routed on needs a `services` binding named the var's stem or its last
|
|
225
|
+
* words — `BINARY_SERVER_URL` → `BINARY_SERVER`, `FAMILY_AUTH_URL` → `AUTH`. The Worker's own
|
|
226
|
+
* hostname is exempt; so is a Worker with no route on the zone (a `workers.dev`-only preview's
|
|
227
|
+
* subrequests DO run the target's route). Returns the refusal, or `null`.
|
|
228
|
+
*/
|
|
229
|
+
export function sameZoneFetchRefusal(config: Record<string, unknown>, env: WorkerEnv): string | null {
|
|
230
|
+
const scope = (env === "stage" ? ((config.env as Record<string, Record<string, unknown>> | undefined)?.stage ?? {}) : config) as Record<string, unknown>;
|
|
231
|
+
const { zones, hosts } = routeZones(scope.routes);
|
|
232
|
+
if (zones.size === 0) return null;
|
|
233
|
+
const bindings = (Array.isArray(scope.services) ? scope.services : []).map((s) => String((s as { binding?: string }).binding ?? ""));
|
|
234
|
+
const missing: string[] = [];
|
|
235
|
+
for (const [name, value] of Object.entries((scope.vars as Record<string, unknown> | undefined) ?? {})) {
|
|
236
|
+
if (!name.endsWith("_URL") || typeof value !== "string") continue;
|
|
237
|
+
const host = /^https:\/\/([^/:]+)/.exec(value)?.[1];
|
|
238
|
+
if (!host || hosts.has(host)) continue;
|
|
239
|
+
if (![...zones].some((zone) => host === zone || host.endsWith(`.${zone}`))) continue;
|
|
240
|
+
const stem = name.slice(0, -"_URL".length);
|
|
241
|
+
if (bindings.some((b) => b && (stem === b || stem.endsWith(`_${b}`)))) continue;
|
|
242
|
+
missing.push(`${name} (${host}) → a \`services\` binding named ${stem}`);
|
|
243
|
+
}
|
|
244
|
+
if (missing.length === 0) return null;
|
|
245
|
+
return (
|
|
246
|
+
`${env} would fetch another hostname on its own zone without a service binding, and a same-zone subrequest skips that hostname's Worker and lands on the zone's origin — the Mac's tunnel, so it fails whenever the Mac is off: ${missing.join("; ")}. ` +
|
|
247
|
+
"Add the binding to wrangler.jsonc (for THIS env — named envs inherit none) and send the calls through it (`cursedbelt-server/binary-store`'s `fetch` option, or `env.<BINDING>.fetch`)."
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
196
251
|
/** A refusal an app adds to the sequence: a sentence saying why not, or `null` to proceed. */
|
|
197
252
|
export type Refusal = () => string | null;
|
|
198
253
|
|
|
@@ -408,6 +463,16 @@ export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): Deplo
|
|
|
408
463
|
const why = refusal();
|
|
409
464
|
if (why) return stop("refusal", why);
|
|
410
465
|
}
|
|
466
|
+
// Built in, never an app's to remember: see `sameZoneFetchRefusal`. A deploy with no readable
|
|
467
|
+
// config would fail at wrangler anyway, so an unreadable file is not a pass we invent here.
|
|
468
|
+
let wrangler: Record<string, unknown> | null = null;
|
|
469
|
+
try {
|
|
470
|
+
wrangler = readWranglerJsonc((deps.read ?? ((path: string) => readFileSync(path, "utf8")))("wrangler.jsonc"));
|
|
471
|
+
} catch {}
|
|
472
|
+
for (const env of spec.env === "production" ? (["stage", "production"] as const) : (["stage"] as const)) {
|
|
473
|
+
const why = wrangler ? sameZoneFetchRefusal(wrangler, env) : null;
|
|
474
|
+
if (why) return stop("same-zone", `${why} Nothing was deployed.`);
|
|
475
|
+
}
|
|
411
476
|
if (spec.env === "production") {
|
|
412
477
|
for (const command of spec.stageFirst ?? []) {
|
|
413
478
|
step(`the stage first — production waits on it: ${command.join(" ")}`);
|