@astralarium/repo-dev 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +17 -2
- package/dist/allocation.d.ts +15 -0
- package/dist/allocation.js +28 -0
- package/dist/bin/db-image.d.ts +2 -0
- package/dist/bin/db-image.js +29 -0
- package/dist/bin/run-dev.d.ts +2 -0
- package/dist/bin/run-dev.js +15 -0
- package/dist/bin/siblings-inputs.d.ts +2 -0
- package/dist/bin/siblings-inputs.js +20 -0
- package/dist/config.d.ts +240 -0
- package/dist/config.js +90 -0
- package/dist/environment.d.ts +19 -0
- package/dist/environment.js +45 -0
- package/dist/exec.d.ts +4 -0
- package/dist/exec.js +14 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +12 -0
- package/dist/launcher/hosts.d.ts +26 -0
- package/dist/launcher/hosts.js +152 -0
- package/dist/launcher/launcher.d.ts +23 -0
- package/dist/launcher/launcher.js +452 -0
- package/dist/launcher/servers.d.ts +39 -0
- package/dist/launcher/servers.js +154 -0
- package/dist/load-config.d.ts +3 -0
- package/dist/load-config.js +6 -0
- package/dist/ports.d.ts +3 -0
- package/dist/ports.js +32 -0
- package/dist/process.d.ts +43 -0
- package/dist/process.js +172 -0
- package/dist/resources.d.ts +23 -0
- package/dist/resources.js +74 -0
- package/dist/sibling-inputs.d.ts +5 -0
- package/dist/sibling-inputs.js +32 -0
- package/dist/source-hash.d.ts +2 -0
- package/dist/source-hash.js +29 -0
- package/dist/testkit/built-worker.d.ts +29 -0
- package/dist/testkit/built-worker.js +48 -0
- package/dist/testkit/env-template.d.ts +8 -0
- package/dist/testkit/env-template.js +14 -0
- package/dist/testkit/index.d.ts +8 -0
- package/dist/testkit/index.js +8 -0
- package/dist/testkit/ports.d.ts +8 -0
- package/dist/testkit/ports.js +20 -0
- package/dist/testkit/temp.d.ts +2 -0
- package/dist/testkit/temp.js +10 -0
- package/dist/testkit/test-cache.d.ts +14 -0
- package/dist/testkit/test-cache.js +174 -0
- package/dist/testkit/worker-build.d.ts +26 -0
- package/dist/testkit/worker-build.js +46 -0
- package/dist/vite.d.ts +96 -0
- package/dist/vite.js +254 -0
- package/dist/wait.d.ts +9 -0
- package/dist/wait.js +38 -0
- package/package.json +71 -3
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import { ExpectedError, log } from "@astralarium/repo-telemetry";
|
|
6
|
+
import { Agent, fetch } from "undici";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { PORT } from "../config.js";
|
|
9
|
+
import { appProcessEnv } from "../environment.js";
|
|
10
|
+
import { output } from "../exec.js";
|
|
11
|
+
import { isPortOpen, waitFor, waitForPortClose } from "../wait.js";
|
|
12
|
+
/** `<stateDir>/<name>.json`: the live session a launch started, its config
|
|
13
|
+
* hash and the ports it started on. */
|
|
14
|
+
const sessionState = z.object({
|
|
15
|
+
id: z.string(),
|
|
16
|
+
fingerprint: z.string(),
|
|
17
|
+
port: PORT,
|
|
18
|
+
inspectorPort: PORT.optional(),
|
|
19
|
+
});
|
|
20
|
+
/** Hashes the effective app config and the contents of `files`; stores no
|
|
21
|
+
* credential values. */
|
|
22
|
+
export function configFingerprint(env, files = []) {
|
|
23
|
+
const hash = createHash("sha256").update(JSON.stringify(Object.entries(env).sort(([a], [b]) => a.localeCompare(b))));
|
|
24
|
+
for (const file of files)
|
|
25
|
+
hash.update(readFileSync(file));
|
|
26
|
+
return hash.digest("hex");
|
|
27
|
+
}
|
|
28
|
+
/** Running session `name`, scoped to `appDir`, where `live run -C` put it. */
|
|
29
|
+
export function runningSession(name, appDir) {
|
|
30
|
+
const lines = output("live", ["ps", "--json", "-C", appDir, name]);
|
|
31
|
+
if (!lines)
|
|
32
|
+
return undefined;
|
|
33
|
+
const session = JSON.parse(lines.split("\n")[0]);
|
|
34
|
+
if (!session ||
|
|
35
|
+
typeof session !== "object" ||
|
|
36
|
+
!("id" in session) ||
|
|
37
|
+
typeof session.id !== "string")
|
|
38
|
+
throw new Error(`Invalid live session: ${name}`);
|
|
39
|
+
return { id: session.id };
|
|
40
|
+
}
|
|
41
|
+
/** Session state from a previous launch; unreadable content counts as none
|
|
42
|
+
* and forces a restart. */
|
|
43
|
+
function savedSession(path) {
|
|
44
|
+
try {
|
|
45
|
+
const result = sessionState.safeParse(JSON.parse(readFileSync(path, "utf8")));
|
|
46
|
+
return result.success ? result.data : undefined;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Ports session `id` of `name` started on, per its state in `stateDir`;
|
|
53
|
+
* undefined when the state records another session. */
|
|
54
|
+
export function sessionPorts(stateDir, name, id) {
|
|
55
|
+
const saved = savedSession(resolve(stateDir, `${name}.json`));
|
|
56
|
+
if (saved?.id !== id)
|
|
57
|
+
return undefined;
|
|
58
|
+
return {
|
|
59
|
+
port: saved.port,
|
|
60
|
+
...(saved.inspectorPort !== undefined && {
|
|
61
|
+
inspectorPort: saved.inspectorPort,
|
|
62
|
+
}),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/** Stops session `id` of `name`, then waits for each of its ports to close. */
|
|
66
|
+
export async function stopSession(name, id, { port, inspectorPort }, signal) {
|
|
67
|
+
execFileSync("live", ["stop", id], { stdio: "inherit" });
|
|
68
|
+
await Promise.all([port, inspectorPort].flatMap((held) => held === undefined
|
|
69
|
+
? []
|
|
70
|
+
: [
|
|
71
|
+
waitForPortClose(held, `${name}: :${held} still held after stopping`, signal),
|
|
72
|
+
]));
|
|
73
|
+
}
|
|
74
|
+
/** HTTP readiness through the actual listener, including TLS and optional body checks. */
|
|
75
|
+
export async function httpReady(url, agent, options = {}) {
|
|
76
|
+
try {
|
|
77
|
+
const response = await fetch(url, {
|
|
78
|
+
dispatcher: agent,
|
|
79
|
+
redirect: "manual",
|
|
80
|
+
signal: AbortSignal.any([
|
|
81
|
+
AbortSignal.timeout(10_000),
|
|
82
|
+
...(options.signal ? [options.signal] : []),
|
|
83
|
+
]),
|
|
84
|
+
});
|
|
85
|
+
const body = await response.text();
|
|
86
|
+
return (response.status === (options.status ?? 200) &&
|
|
87
|
+
(options.body === undefined || body === options.body));
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** Starts or reconciles one dev service; rollback owns only the new session. */
|
|
94
|
+
export async function startDevServer(resources, options) {
|
|
95
|
+
const { name, appDir, stateDir, port, inspectorPort, env, inputs, probe } = options;
|
|
96
|
+
resources.signal.throwIfAborted();
|
|
97
|
+
if (existsSync(resolve(appDir, ".dev.vars")))
|
|
98
|
+
throw new ExpectedError(`${appDir}/.dev.vars exists; remove it, the launcher supplies the dev config`);
|
|
99
|
+
const fingerprint = configFingerprint(env, inputs);
|
|
100
|
+
const statePath = resolve(stateDir, `${name}.json`);
|
|
101
|
+
const current = runningSession(name, appDir);
|
|
102
|
+
const saved = savedSession(statePath);
|
|
103
|
+
const reused = current !== undefined &&
|
|
104
|
+
saved?.id === current.id &&
|
|
105
|
+
saved.fingerprint === fingerprint;
|
|
106
|
+
const ports = [port, ...(inspectorPort === undefined ? [] : [inspectorPort])];
|
|
107
|
+
let id;
|
|
108
|
+
if (reused) {
|
|
109
|
+
id = current.id;
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
if (current) {
|
|
113
|
+
log.info("dev.server_restarting", "{name}: config changed; restarting", { name });
|
|
114
|
+
await stopSession(name, current.id, { port, inspectorPort }, resources.signal);
|
|
115
|
+
}
|
|
116
|
+
for (const held of ports)
|
|
117
|
+
if (await isPortOpen(held, "localhost"))
|
|
118
|
+
throw new ExpectedError(`:${held} is held by another process; ${name} cannot start`);
|
|
119
|
+
resources.signal.throwIfAborted();
|
|
120
|
+
id = execFileSync("live", ["run", "-dn", name, "-C", appDir, "pnpm", "dev", "--port", String(port)], {
|
|
121
|
+
encoding: "utf8",
|
|
122
|
+
env: appProcessEnv(env, {
|
|
123
|
+
mode: "dev",
|
|
124
|
+
nodeOptions: "--unhandled-rejections=warn",
|
|
125
|
+
}),
|
|
126
|
+
}).trim();
|
|
127
|
+
const started = id;
|
|
128
|
+
resources.own({
|
|
129
|
+
dispose: () => {
|
|
130
|
+
execFileSync("live", ["stop", started], { stdio: "inherit" });
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
const agent = new Agent({ connect: { ca: options.ca } });
|
|
135
|
+
try {
|
|
136
|
+
await waitFor(async () => {
|
|
137
|
+
if (runningSession(name, appDir)?.id !== id)
|
|
138
|
+
throw new ExpectedError(`${name} exited during startup. Inspect: live tail ${id}`);
|
|
139
|
+
return probe(agent);
|
|
140
|
+
}, `${name} failed HTTP readiness after 60s. Inspect: live tail ${id}`, 60_000, resources.signal);
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
await agent.close();
|
|
144
|
+
}
|
|
145
|
+
mkdirSync(stateDir, { recursive: true });
|
|
146
|
+
const state = {
|
|
147
|
+
id,
|
|
148
|
+
fingerprint,
|
|
149
|
+
port,
|
|
150
|
+
...(inspectorPort !== undefined && { inspectorPort }),
|
|
151
|
+
};
|
|
152
|
+
writeFileSync(statePath, JSON.stringify(state), { mode: 0o600 });
|
|
153
|
+
log.info("dev.server_ready", "{name}: HTTP ready on :{port}{reused, select, true { (reused)} other {}}", { name, port, reused });
|
|
154
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { loadApp } from "@astralarium/repo-deploy-info/server";
|
|
2
|
+
import { defineDevConfig } from "./config.js";
|
|
3
|
+
/** The app module's `dev` config at `path`, validated (`defineDevConfig`). */
|
|
4
|
+
export async function loadDevConfig(path) {
|
|
5
|
+
return loadApp(path, "dev", (value) => defineDevConfig(value));
|
|
6
|
+
}
|
package/dist/ports.d.ts
ADDED
package/dist/ports.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Dev server ports: free-port search from a preferred port. */
|
|
2
|
+
import { createServer } from "node:net";
|
|
3
|
+
import { ExpectedError } from "@astralarium/repo-telemetry";
|
|
4
|
+
import { isPortOpen } from "./wait.js";
|
|
5
|
+
/** Ports tried above the preferred one before giving up. */
|
|
6
|
+
const PORT_RANGE = 100;
|
|
7
|
+
/** Both loopback addresses refuse connections and a loopback bind
|
|
8
|
+
* succeeds. */
|
|
9
|
+
async function isPortFree(port) {
|
|
10
|
+
if ((await isPortOpen(port, "127.0.0.1")) || (await isPortOpen(port, "::1")))
|
|
11
|
+
return false;
|
|
12
|
+
return new Promise((done) => {
|
|
13
|
+
const server = createServer();
|
|
14
|
+
server.once("error", () => done(false));
|
|
15
|
+
server.listen({ port, host: "127.0.0.1", exclusive: true }, () => server.close(() => done(true)));
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
/** First free port from `preferred` upward, outside `taken`; throws
|
|
19
|
+
* `ExpectedError` past `PORT_RANGE` ports. */
|
|
20
|
+
async function findFreePort(preferred, taken) {
|
|
21
|
+
for (let port = preferred; port < preferred + PORT_RANGE; port++)
|
|
22
|
+
if (!taken.has(port) && (await isPortFree(port)))
|
|
23
|
+
return port;
|
|
24
|
+
throw new ExpectedError(`no free port in ${preferred}-${preferred + PORT_RANGE - 1}`);
|
|
25
|
+
}
|
|
26
|
+
/** `saved` while free and outside `taken` (ports this allocation already
|
|
27
|
+
* holds), else the first free port from `preferred`. */
|
|
28
|
+
export async function keepOrFindPort(saved, preferred, taken = new Set()) {
|
|
29
|
+
return saved !== undefined && !taken.has(saved) && (await isPortFree(saved))
|
|
30
|
+
? saved
|
|
31
|
+
: findFreePort(preferred, taken);
|
|
32
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** Forked children with IPC readiness: the parent side and the child side. */
|
|
2
|
+
import { type ForkOptions } from "node:child_process";
|
|
3
|
+
import type { Resource } from "./resources.ts";
|
|
4
|
+
/** Exit status for an interrupted child operation. */
|
|
5
|
+
export declare const STOP_EXIT_CODE = 130;
|
|
6
|
+
/** Runs the child cleanup once when its parent stops or disconnects. */
|
|
7
|
+
export declare function onParentStop(stop: () => void): () => void;
|
|
8
|
+
/** A service a child process runs for its parent. */
|
|
9
|
+
export interface ChildService {
|
|
10
|
+
/** Value the child reports to its parent once the service is up. */
|
|
11
|
+
ready: unknown;
|
|
12
|
+
/** Brings the service down; runs once. */
|
|
13
|
+
stop(): void | Promise<void>;
|
|
14
|
+
/** The service completes at readiness: the child stops and disconnects
|
|
15
|
+
* right after reporting. */
|
|
16
|
+
oneShot?: boolean;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Runs `name` as the child side of `forkProcess`: `start` brings the
|
|
20
|
+
* service up and its ready value reports to the parent; the service stops
|
|
21
|
+
* once, on parent stop, a failure or (`oneShot`) the report, and the child
|
|
22
|
+
* disconnects after it. `start`'s signal aborts on parent stop or, with a
|
|
23
|
+
* readiness timeout as its reason, when the startup budget runs out; a
|
|
24
|
+
* half-started service closes itself on abort. A failure logs under
|
|
25
|
+
* `child.failed`, a stop failure under `child.stop_failed`; both set the
|
|
26
|
+
* exit code.
|
|
27
|
+
*/
|
|
28
|
+
export declare function serveChild(name: string, start: (signal: AbortSignal) => Promise<ChildService>): void;
|
|
29
|
+
/** Child process with IPC readiness and bounded graceful shutdown. */
|
|
30
|
+
export interface ProcessResource extends Resource {
|
|
31
|
+
/** The child's first IPC message; rejects on an earlier exit, abort or
|
|
32
|
+
* `READY_TIMEOUT_MS`. */
|
|
33
|
+
ready: Promise<unknown>;
|
|
34
|
+
/** Settles at exit: resolves on status 0 or a stop this side requested. */
|
|
35
|
+
exited: Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Forks a child; the first IPC message declares readiness, `close` requests
|
|
39
|
+
* shutdown. The child's env carries the readiness deadline `serveChild`
|
|
40
|
+
* budgets startup against. A stop this side initiated counts as a clean
|
|
41
|
+
* exit, including the SIGKILL sent after the 10s grace period.
|
|
42
|
+
*/
|
|
43
|
+
export declare function forkProcess(module: URL, args: string[], options: ForkOptions, signal: AbortSignal): ProcessResource;
|
package/dist/process.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/** Forked children with IPC readiness: the parent side and the child side. */
|
|
2
|
+
import { fork } from "node:child_process";
|
|
3
|
+
import { once } from "node:events";
|
|
4
|
+
import { log } from "@astralarium/repo-telemetry";
|
|
5
|
+
import { READY_TIMEOUT_MS } from "./wait.js";
|
|
6
|
+
/** Exit status for an interrupted child operation. */
|
|
7
|
+
export const STOP_EXIT_CODE = 130;
|
|
8
|
+
const STOP_MESSAGE = "close";
|
|
9
|
+
/** Child env var: the parent's readiness deadline, epoch milliseconds. */
|
|
10
|
+
const READY_DEADLINE_ENV = "CHILD_READY_DEADLINE";
|
|
11
|
+
/** Time a child keeps before its parent's deadline to fail, close and exit. */
|
|
12
|
+
const STOP_MARGIN_MS = 5_000;
|
|
13
|
+
/** Runs the child cleanup once when its parent stops or disconnects. */
|
|
14
|
+
export function onParentStop(stop) {
|
|
15
|
+
let stopped = false;
|
|
16
|
+
const run = () => {
|
|
17
|
+
if (!stopped) {
|
|
18
|
+
stopped = true;
|
|
19
|
+
stop();
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
const onMessage = (message) => {
|
|
23
|
+
if (message === STOP_MESSAGE)
|
|
24
|
+
run();
|
|
25
|
+
};
|
|
26
|
+
process.once("SIGTERM", run);
|
|
27
|
+
process.once("disconnect", run);
|
|
28
|
+
process.on("message", onMessage);
|
|
29
|
+
return () => {
|
|
30
|
+
process.off("SIGTERM", run);
|
|
31
|
+
process.off("disconnect", run);
|
|
32
|
+
process.off("message", onMessage);
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/** Reports a child's ready value over its parent's IPC channel. */
|
|
36
|
+
function reportReady(value) {
|
|
37
|
+
if (!process.connected)
|
|
38
|
+
throw new Error("Parent disconnected before readiness");
|
|
39
|
+
process.send(value);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Startup budget of a `serveChild` child: the parent's readiness deadline
|
|
43
|
+
* minus `STOP_MARGIN_MS`, so a child timeout reports before the parent's.
|
|
44
|
+
* `READY_TIMEOUT_MS` without a parent deadline.
|
|
45
|
+
*/
|
|
46
|
+
function startupBudget() {
|
|
47
|
+
const deadline = Number(process.env[READY_DEADLINE_ENV]);
|
|
48
|
+
return Number.isFinite(deadline)
|
|
49
|
+
? Math.max(0, deadline - Date.now() - STOP_MARGIN_MS)
|
|
50
|
+
: READY_TIMEOUT_MS;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Runs `name` as the child side of `forkProcess`: `start` brings the
|
|
54
|
+
* service up and its ready value reports to the parent; the service stops
|
|
55
|
+
* once, on parent stop, a failure or (`oneShot`) the report, and the child
|
|
56
|
+
* disconnects after it. `start`'s signal aborts on parent stop or, with a
|
|
57
|
+
* readiness timeout as its reason, when the startup budget runs out; a
|
|
58
|
+
* half-started service closes itself on abort. A failure logs under
|
|
59
|
+
* `child.failed`, a stop failure under `child.stop_failed`; both set the
|
|
60
|
+
* exit code.
|
|
61
|
+
*/
|
|
62
|
+
export function serveChild(name, start) {
|
|
63
|
+
const parentStop = new AbortController();
|
|
64
|
+
const timeout = new AbortController();
|
|
65
|
+
const budget = startupBudget();
|
|
66
|
+
const timer = setTimeout(() => timeout.abort(new Error(`${name} did not become ready in ${Math.round(budget / 1000)}s`)), budget);
|
|
67
|
+
const started = start(AbortSignal.any([parentStop.signal, timeout.signal]));
|
|
68
|
+
void started.then(() => clearTimeout(timer), () => clearTimeout(timer));
|
|
69
|
+
let stopping;
|
|
70
|
+
const stop = () => (stopping ??= started
|
|
71
|
+
.then((service) => service.stop(), () => undefined)
|
|
72
|
+
.catch((error) => {
|
|
73
|
+
log.error("child.stop_failed", "{name} failed to stop", { name, exception: error });
|
|
74
|
+
process.exitCode = 1;
|
|
75
|
+
})
|
|
76
|
+
.finally(() => {
|
|
77
|
+
if (process.connected)
|
|
78
|
+
process.disconnect?.();
|
|
79
|
+
}));
|
|
80
|
+
onParentStop(() => {
|
|
81
|
+
parentStop.abort();
|
|
82
|
+
void stop();
|
|
83
|
+
});
|
|
84
|
+
void started
|
|
85
|
+
.then((service) => {
|
|
86
|
+
if (stopping || !process.connected)
|
|
87
|
+
return stop();
|
|
88
|
+
reportReady(service.ready);
|
|
89
|
+
if (service.oneShot)
|
|
90
|
+
return stop();
|
|
91
|
+
})
|
|
92
|
+
.catch((error) => {
|
|
93
|
+
if (!parentStop.signal.aborted) {
|
|
94
|
+
log.error("child.failed", "{name} failed", {
|
|
95
|
+
name,
|
|
96
|
+
exception: error,
|
|
97
|
+
});
|
|
98
|
+
process.exitCode = 1;
|
|
99
|
+
}
|
|
100
|
+
return stop();
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Forks a child; the first IPC message declares readiness, `close` requests
|
|
105
|
+
* shutdown. The child's env carries the readiness deadline `serveChild`
|
|
106
|
+
* budgets startup against. A stop this side initiated counts as a clean
|
|
107
|
+
* exit, including the SIGKILL sent after the 10s grace period.
|
|
108
|
+
*/
|
|
109
|
+
export function forkProcess(module, args, options, signal) {
|
|
110
|
+
signal.throwIfAborted();
|
|
111
|
+
const deadline = Date.now() + READY_TIMEOUT_MS;
|
|
112
|
+
const child = fork(module, args, {
|
|
113
|
+
execArgv: [],
|
|
114
|
+
stdio: ["ignore", "inherit", "inherit", "ipc"],
|
|
115
|
+
...options,
|
|
116
|
+
env: {
|
|
117
|
+
...(options.env ?? process.env),
|
|
118
|
+
[READY_DEADLINE_ENV]: String(deadline),
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
let stopping = false;
|
|
122
|
+
let ended = false;
|
|
123
|
+
const exited = new Promise((resolve, reject) => {
|
|
124
|
+
child.once("error", reject);
|
|
125
|
+
child.once("exit", (code, exitSignal) => {
|
|
126
|
+
ended = true;
|
|
127
|
+
if (code === 0 ||
|
|
128
|
+
(stopping &&
|
|
129
|
+
(exitSignal === "SIGTERM" ||
|
|
130
|
+
exitSignal === "SIGKILL" ||
|
|
131
|
+
code === STOP_EXIT_CODE)))
|
|
132
|
+
resolve();
|
|
133
|
+
else
|
|
134
|
+
reject(new Error(`Child ${args.join(" ")} exited (${exitSignal ?? code})`));
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
const ready = Promise.race([
|
|
138
|
+
once(child, "message", {
|
|
139
|
+
signal: AbortSignal.any([signal, AbortSignal.timeout(READY_TIMEOUT_MS)]),
|
|
140
|
+
}).then(([message]) => message),
|
|
141
|
+
exited.then(() => {
|
|
142
|
+
throw new Error("Child exited before readiness");
|
|
143
|
+
}),
|
|
144
|
+
]);
|
|
145
|
+
// Either promise may settle unobserved.
|
|
146
|
+
void ready.catch(() => undefined);
|
|
147
|
+
void exited.catch(() => undefined);
|
|
148
|
+
let closing;
|
|
149
|
+
return {
|
|
150
|
+
ready,
|
|
151
|
+
exited,
|
|
152
|
+
dispose: () => (closing ??= (async () => {
|
|
153
|
+
if (ended)
|
|
154
|
+
return;
|
|
155
|
+
stopping = true;
|
|
156
|
+
if (child.connected)
|
|
157
|
+
child.send(STOP_MESSAGE, (error) => {
|
|
158
|
+
if (error)
|
|
159
|
+
child.kill("SIGTERM");
|
|
160
|
+
});
|
|
161
|
+
else
|
|
162
|
+
child.kill("SIGTERM");
|
|
163
|
+
const timeout = setTimeout(() => child.kill("SIGKILL"), 10_000);
|
|
164
|
+
try {
|
|
165
|
+
await exited;
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
clearTimeout(timeout);
|
|
169
|
+
}
|
|
170
|
+
})()),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Owned resource released by its startup caller. */
|
|
2
|
+
export interface Resource {
|
|
3
|
+
dispose(): void | Promise<void>;
|
|
4
|
+
}
|
|
5
|
+
/** Cancellable ownership: reverse-order cleanup on an `AsyncDisposableStack`. */
|
|
6
|
+
export declare class ResourceScope {
|
|
7
|
+
#private;
|
|
8
|
+
/** Cancellation shared by acquisition and readiness checks; its reason
|
|
9
|
+
* is an `ExpectedError`. */
|
|
10
|
+
get signal(): AbortSignal;
|
|
11
|
+
/** Registers a resource before awaiting its readiness. */
|
|
12
|
+
own<T extends Resource>(resource: T): T;
|
|
13
|
+
/** Releases owned resources on SIGINT/SIGTERM while keeping normal teardown explicit. */
|
|
14
|
+
listenForSignals(): void;
|
|
15
|
+
/** Transfers successful detached dev services to the user. */
|
|
16
|
+
release(): void;
|
|
17
|
+
/** Idempotent cleanup; every resource runs even when another fails. */
|
|
18
|
+
dispose(): Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
/** Owns setup resources until the returned teardown, including failed
|
|
21
|
+
* setup. Setup failing after the scope closed rejects with the close
|
|
22
|
+
* reason. */
|
|
23
|
+
export declare function setupResources(setup: (resources: ResourceScope) => Promise<void>): Promise<() => Promise<void>>;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { ExpectedError, log } from "@astralarium/repo-telemetry";
|
|
2
|
+
/** Cancellable ownership: reverse-order cleanup on an `AsyncDisposableStack`. */
|
|
3
|
+
export class ResourceScope {
|
|
4
|
+
#controller = new AbortController();
|
|
5
|
+
#stack = new AsyncDisposableStack();
|
|
6
|
+
#closing;
|
|
7
|
+
#unlisten;
|
|
8
|
+
/** Cancellation shared by acquisition and readiness checks; its reason
|
|
9
|
+
* is an `ExpectedError`. */
|
|
10
|
+
get signal() {
|
|
11
|
+
return this.#controller.signal;
|
|
12
|
+
}
|
|
13
|
+
/** Registers a resource before awaiting its readiness. */
|
|
14
|
+
own(resource) {
|
|
15
|
+
this.signal.throwIfAborted();
|
|
16
|
+
return this.#stack.adopt(resource, (owned) => owned.dispose());
|
|
17
|
+
}
|
|
18
|
+
/** Releases owned resources on SIGINT/SIGTERM while keeping normal teardown explicit. */
|
|
19
|
+
listenForSignals() {
|
|
20
|
+
if (this.#unlisten)
|
|
21
|
+
return;
|
|
22
|
+
const stop = (signal) => {
|
|
23
|
+
process.exitCode = signal === "SIGINT" ? 130 : 143;
|
|
24
|
+
void this.dispose().catch((e) => {
|
|
25
|
+
log.error("dev.dispose_failed", "dispose failed", { exception: e });
|
|
26
|
+
});
|
|
27
|
+
};
|
|
28
|
+
process.on("SIGINT", stop);
|
|
29
|
+
process.on("SIGTERM", stop);
|
|
30
|
+
this.#unlisten = () => {
|
|
31
|
+
process.off("SIGINT", stop);
|
|
32
|
+
process.off("SIGTERM", stop);
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/** Transfers successful detached dev services to the user. */
|
|
36
|
+
release() {
|
|
37
|
+
this.signal.throwIfAborted();
|
|
38
|
+
this.#unlisten?.();
|
|
39
|
+
this.#stack.move();
|
|
40
|
+
}
|
|
41
|
+
/** Idempotent cleanup; every resource runs even when another fails. */
|
|
42
|
+
dispose() {
|
|
43
|
+
return (this.#closing ??= this.#dispose());
|
|
44
|
+
}
|
|
45
|
+
async #dispose() {
|
|
46
|
+
this.#controller.abort(new ExpectedError("Resource scope closed"));
|
|
47
|
+
this.#unlisten?.();
|
|
48
|
+
await this.#stack.disposeAsync();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** Owns setup resources until the returned teardown, including failed
|
|
52
|
+
* setup. Setup failing after the scope closed rejects with the close
|
|
53
|
+
* reason. */
|
|
54
|
+
export async function setupResources(setup) {
|
|
55
|
+
const resources = new ResourceScope();
|
|
56
|
+
resources.listenForSignals();
|
|
57
|
+
try {
|
|
58
|
+
await setup(resources);
|
|
59
|
+
resources.signal.throwIfAborted();
|
|
60
|
+
return () => resources.dispose();
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
const failure = resources.signal.aborted
|
|
64
|
+
? resources.signal.reason
|
|
65
|
+
: error;
|
|
66
|
+
try {
|
|
67
|
+
await resources.dispose();
|
|
68
|
+
}
|
|
69
|
+
catch (cleanup) {
|
|
70
|
+
throw new SuppressedError(cleanup, failure, "Setup and cleanup failed");
|
|
71
|
+
}
|
|
72
|
+
throw failure;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { SiblingCheckout } from "@astralarium/repo-deploy-info/server";
|
|
2
|
+
/** Writes package-local fingerprints (`.turbo/siblings.json`) of each
|
|
3
|
+
* consumed sibling, keyed by `package`; `null` for siblings a package
|
|
4
|
+
* leaves unread. */
|
|
5
|
+
export declare function writeSiblingInputs(workspace: string, siblings: readonly SiblingCheckout[]): void;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { DEPENDENCY_FIELDS, workspacePackages, } from "@astralarium/repo-package-files";
|
|
4
|
+
import { sourceHash } from "./source-hash.js";
|
|
5
|
+
/** Fingerprint of a missing checkout. */
|
|
6
|
+
const ABSENT = "absent";
|
|
7
|
+
/** Writes package-local fingerprints (`.turbo/siblings.json`) of each
|
|
8
|
+
* consumed sibling, keyed by `package`; `null` for siblings a package
|
|
9
|
+
* leaves unread. */
|
|
10
|
+
export function writeSiblingInputs(workspace, siblings) {
|
|
11
|
+
const hashes = new Map();
|
|
12
|
+
for (const { dir, manifest } of workspacePackages(workspace)) {
|
|
13
|
+
const fingerprints = Object.fromEntries(siblings.map((sibling) => {
|
|
14
|
+
const consumed = sibling.consumes
|
|
15
|
+
? sibling.consumes({ dir, manifest })
|
|
16
|
+
: DEPENDENCY_FIELDS.some((field) => manifest[field]?.[sibling.package] !== undefined);
|
|
17
|
+
if (!consumed)
|
|
18
|
+
return [sibling.package, null];
|
|
19
|
+
let hash = hashes.get(sibling);
|
|
20
|
+
if (!hash) {
|
|
21
|
+
hash = existsSync(sibling.dir)
|
|
22
|
+
? sourceHash(sibling.dir, [...sibling.sources])
|
|
23
|
+
: ABSENT;
|
|
24
|
+
hashes.set(sibling, hash);
|
|
25
|
+
}
|
|
26
|
+
return [sibling.package, hash];
|
|
27
|
+
}));
|
|
28
|
+
const output = join(dir, ".turbo");
|
|
29
|
+
mkdirSync(output, { recursive: true });
|
|
30
|
+
writeFileSync(join(output, "siblings.json"), JSON.stringify(fingerprints) + "\n");
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
/** SHA-256 over sorted Git-visible source paths and contents, excluding ignored files. */
|
|
6
|
+
export function sourceHash(directory, sources) {
|
|
7
|
+
const hash = createHash("sha256");
|
|
8
|
+
const listed = execFileSync("git", [
|
|
9
|
+
"-C",
|
|
10
|
+
directory,
|
|
11
|
+
"ls-files",
|
|
12
|
+
"-z",
|
|
13
|
+
"--cached",
|
|
14
|
+
"--others",
|
|
15
|
+
"--exclude-standard",
|
|
16
|
+
"--",
|
|
17
|
+
...sources,
|
|
18
|
+
], { encoding: "utf8" });
|
|
19
|
+
const files = listed
|
|
20
|
+
.split("\0")
|
|
21
|
+
.filter((file) => file && existsSync(join(directory, file)))
|
|
22
|
+
.sort();
|
|
23
|
+
for (const file of files) {
|
|
24
|
+
hash.update(`${file}\0`);
|
|
25
|
+
hash.update(readFileSync(join(directory, file)));
|
|
26
|
+
hash.update("\0");
|
|
27
|
+
}
|
|
28
|
+
return hash.digest("hex");
|
|
29
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/** The fields of a built `wrangler.json` deploys depend on. */
|
|
3
|
+
export declare const BuiltWorkerConfig: z.ZodObject<{
|
|
4
|
+
hyperdrive: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
5
|
+
binding: z.ZodString;
|
|
6
|
+
}, z.core.$strip>>>;
|
|
7
|
+
secrets: z.ZodDefault<z.ZodObject<{
|
|
8
|
+
required: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
9
|
+
}, z.core.$strip>>;
|
|
10
|
+
vars: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
11
|
+
}, z.core.$strip>;
|
|
12
|
+
/** A built worker's Hyperdrive bindings, required secrets and vars. */
|
|
13
|
+
export type BuiltWorkerConfig = z.infer<typeof BuiltWorkerConfig>;
|
|
14
|
+
/** `dir/wrangler.json` of a built worker (e.g. `dist/server`); absent
|
|
15
|
+
* fields read empty. */
|
|
16
|
+
export declare function readBuiltWorkerConfig(dir: string): Promise<BuiltWorkerConfig>;
|
|
17
|
+
/** `dir`'s built worker's required secrets, valued from `environment`. */
|
|
18
|
+
export declare function requiredSecrets(dir: string, environment: Readonly<Record<string, string | undefined>>): Promise<Record<string, string | undefined>>;
|
|
19
|
+
/** `findInBundle` scope. */
|
|
20
|
+
export interface FindInBundleOptions {
|
|
21
|
+
/** File extensions read, e.g. `[".js", ".css"]`. Default: scripts. */
|
|
22
|
+
extensions?: readonly string[];
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* `<file>: <needle>` for each of `needles` a file under `dir` contains,
|
|
26
|
+
* paths relative to `dir`: empty for a bundle free of them. Rejects when
|
|
27
|
+
* `dir` holds no file of `extensions`.
|
|
28
|
+
*/
|
|
29
|
+
export declare function findInBundle(dir: string, needles: string | readonly string[], { extensions }?: FindInBundleOptions): Promise<string[]>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** Built-worker checks: the `wrangler.json` a Cloudflare Vite build writes,
|
|
2
|
+
* and text a bundle's scripts carry. */
|
|
3
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
4
|
+
import { extname, join, relative } from "node:path";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
/** The fields of a built `wrangler.json` deploys depend on. */
|
|
7
|
+
export const BuiltWorkerConfig = z.object({
|
|
8
|
+
hyperdrive: z.array(z.object({ binding: z.string() })).default([]),
|
|
9
|
+
secrets: z
|
|
10
|
+
.object({ required: z.array(z.string()).default([]) })
|
|
11
|
+
.default({ required: [] }),
|
|
12
|
+
vars: z.record(z.string(), z.unknown()).default({}),
|
|
13
|
+
});
|
|
14
|
+
/** `dir/wrangler.json` of a built worker (e.g. `dist/server`); absent
|
|
15
|
+
* fields read empty. */
|
|
16
|
+
export async function readBuiltWorkerConfig(dir) {
|
|
17
|
+
const text = await readFile(join(dir, "wrangler.json"), "utf8");
|
|
18
|
+
return BuiltWorkerConfig.parse(JSON.parse(text));
|
|
19
|
+
}
|
|
20
|
+
/** `dir`'s built worker's required secrets, valued from `environment`. */
|
|
21
|
+
export async function requiredSecrets(dir, environment) {
|
|
22
|
+
const { required } = (await readBuiltWorkerConfig(dir)).secrets;
|
|
23
|
+
return Object.fromEntries(required.map((key) => [key, environment[key]]));
|
|
24
|
+
}
|
|
25
|
+
/** Script extensions `findInBundle` reads by default. */
|
|
26
|
+
const SCRIPTS = [".js", ".mjs", ".cjs"];
|
|
27
|
+
/**
|
|
28
|
+
* `<file>: <needle>` for each of `needles` a file under `dir` contains,
|
|
29
|
+
* paths relative to `dir`: empty for a bundle free of them. Rejects when
|
|
30
|
+
* `dir` holds no file of `extensions`.
|
|
31
|
+
*/
|
|
32
|
+
export async function findInBundle(dir, needles, { extensions = SCRIPTS } = {}) {
|
|
33
|
+
const entries = await readdir(dir, { recursive: true, withFileTypes: true });
|
|
34
|
+
const files = entries
|
|
35
|
+
.filter((entry) => entry.isFile() && extensions.includes(extname(entry.name)))
|
|
36
|
+
.map((entry) => relative(dir, join(entry.parentPath, entry.name)))
|
|
37
|
+
.sort();
|
|
38
|
+
if (files.length === 0)
|
|
39
|
+
throw new Error(`no ${extensions.join(", ")} files under ${dir}`);
|
|
40
|
+
const found = [];
|
|
41
|
+
for (const file of files) {
|
|
42
|
+
const text = await readFile(join(dir, file), "utf8");
|
|
43
|
+
for (const needle of typeof needles === "string" ? [needles] : needles)
|
|
44
|
+
if (text.includes(needle))
|
|
45
|
+
found.push(`${file}: ${needle}`);
|
|
46
|
+
}
|
|
47
|
+
return found;
|
|
48
|
+
}
|