@smoothbricks/nx-cache-server 0.0.0-stage → 0.1.1

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 CHANGED
@@ -1,6 +1,105 @@
1
1
  {
2
2
  "name": "@smoothbricks/nx-cache-server",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "type": "module",
5
+ "sideEffects": false,
6
+ "description": "Nx-compatible task archive server with labelled principals, atomic never-replace publication, and filesystem SIEVE retention.",
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/smoothbricks/codebase.git",
11
+ "directory": "packages/nx-cache-server"
12
+ },
13
+ "main": "./dist/ts/index.js",
14
+ "module": "./dist/ts/index.js",
15
+ "types": "./dist/ts/index.d.ts",
16
+ "bin": {
17
+ "nx-cache-server": "./dist/ts/main.js"
18
+ },
19
+ "exports": {
20
+ "./package.json": "./package.json",
21
+ ".": {
22
+ "types": "./dist/ts/index.d.ts",
23
+ "import": "./dist/ts/index.js",
24
+ "default": "./dist/ts/index.js"
25
+ }
26
+ },
27
+ "files": [
28
+ "dist/ts",
29
+ "dist/server",
30
+ "src",
31
+ "!**/*.test.ts",
32
+ "!**/__tests__/**",
33
+ "!**/*.tsbuildinfo"
34
+ ],
35
+ "dependencies": {
36
+ "tslib": "^2.8.1"
37
+ },
38
+ "devDependencies": {
39
+ "@smoothbricks/lmao": "0.5.4",
40
+ "typia": "14.0.6"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "scripts": {
46
+ "test": "nx run nx-cache-server:test --outputStyle=stream"
47
+ },
48
+ "nx": {
49
+ "name": "nx-cache-server",
50
+ "tags": [
51
+ "npm:public"
52
+ ],
53
+ "targets": {
54
+ "lint": {},
55
+ "test-warmup": {
56
+ "executor": "@smoothbricks/nx-plugin:bounded-exec",
57
+ "cache": false,
58
+ "options": {
59
+ "comment": "Build prerequisite for test, not a test: each TypeScript project test loads through the compiler preload compiles its own typia native plugin key, which takes minutes on a cold Go cache and would blow test's 180s idle bound before any test finishes (canonical 39038263857: build still active at idle expiry, compiler at 116.8% CPU, no test named). The 600s budget here is a build budget, not a test timeout increase. cache:false is load-bearing — the ttsc plugin cache lives outside Nx outputs, so a cached skip on a fresh runner would run the test cold again. Follows wire-warmup in packages/cowshed/package.json; uses the same compiler preload specifier as bunfig.toml. The trace preload is absent on purpose (its setup calls afterAll(), which throws outside `bun test`), so the script imports the LMAO test harness it loads instead: that module routes through LMAO's own project, a second plugin key (publish 39079862820 built it cold inside test).",
60
+ "command": "bun --preload @smoothbricks/lmao/bun/preload scripts/warm-ttsc-plugins.mjs",
61
+ "cwd": "{projectRoot}",
62
+ "timeoutMs": 600000,
63
+ "killAfterMs": 10000
64
+ }
65
+ },
66
+ "test": {
67
+ "executor": "@smoothbricks/nx-plugin:bounded-exec",
68
+ "dependsOn": [
69
+ "test-warmup"
70
+ ],
71
+ "options": {
72
+ "command": "bun test --timeout=0",
73
+ "cwd": "{projectRoot}",
74
+ "idleTimeoutMs": 180000,
75
+ "killAfterMs": 10000
76
+ }
77
+ },
78
+ "build": {
79
+ "dependsOn": [
80
+ "...",
81
+ "bundle"
82
+ ]
83
+ },
84
+ "bundle": {
85
+ "executor": "nx:run-commands",
86
+ "cache": true,
87
+ "inputs": [
88
+ "{projectRoot}/src/**/*.ts",
89
+ "!{projectRoot}/src/**/__tests__/**",
90
+ "{projectRoot}/package.json",
91
+ {
92
+ "runtime": "sh node_modules/@smoothbricks/nx-plugin/runtime-input.sh bun -e 'console.log(Bun.version, Bun.revision)'"
93
+ }
94
+ ],
95
+ "outputs": [
96
+ "{projectRoot}/dist/server/nx-cache-server.js"
97
+ ],
98
+ "options": {
99
+ "cwd": "{projectRoot}",
100
+ "command": "bun build --target=bun src/main.ts --outfile dist/server/nx-cache-server.js"
101
+ }
102
+ }
103
+ }
104
+ }
105
+ }
@@ -0,0 +1,15 @@
1
+ import { fileURLToPath } from 'node:url';
2
+
3
+ const main = fileURLToPath(new URL('../main.ts', import.meta.url));
4
+ const flags = process.argv.includes('--exit-on-stdin-eof') ? ['--exit-on-stdin-eof'] : [];
5
+
6
+ const child = Bun.spawn([process.execPath, main, ...flags], {
7
+ env: process.env,
8
+ stdin: 'pipe',
9
+ stdout: 'pipe',
10
+ stderr: 'inherit',
11
+ });
12
+
13
+ await Bun.write(Bun.stdout, `${JSON.stringify({ backendPid: child.pid })}\n`);
14
+ await Bun.write(Bun.stdout, new Response(child.stdout));
15
+ process.exit(await child.exited);
package/src/config.ts ADDED
@@ -0,0 +1,201 @@
1
+ /// <reference types="node" />
2
+
3
+ /**
4
+ * Startup configuration, read once from the environment and refused loudly when it cannot describe a
5
+ * server that is safe to run.
6
+ *
7
+ * A token is a `label:secret` pair, not a bare secret, because every audit line names the principal
8
+ * that read or published an artifact and no line may ever carry the secret itself. Capability rides
9
+ * with the token: `NX_CACHE_SERVER_WRITE_TOKENS` may publish, `NX_CACHE_SERVER_READ_TOKENS` may only
10
+ * read. That split is the CREEP mitigation (see the module header of `server.ts`): an untrusted
11
+ * context gets a read token and cannot reach the write path at all.
12
+ *
13
+ * Every refusal below names one wrong thing and the fix, and the problems are collected rather than
14
+ * thrown one at a time so a fresh operator sees the whole list in one start attempt.
15
+ */
16
+
17
+ export type CacheCapability = 'read' | 'write';
18
+
19
+ export interface CachePrincipal {
20
+ /** Audit name for this token. Appears in every log line the token produces; the secret never does. */
21
+ readonly label: string;
22
+ /** The exact `Authorization` header value this principal must present, precomputed at startup. */
23
+ readonly bearer: string;
24
+ readonly capability: CacheCapability;
25
+ }
26
+
27
+ export interface CacheServerConfig {
28
+ /** Absolute directory holding one file per cache key. */
29
+ readonly directory: string;
30
+ readonly host: string;
31
+ /** `0` asks the OS for a free port; the bound port is reported by `startCacheServer`. */
32
+ readonly port: number;
33
+ /** Largest artifact this server accepts, in bytes. */
34
+ readonly maxBytes: number;
35
+ /** Published archive retention cap, independent of the per-upload limit. */
36
+ readonly capBytes: number;
37
+ readonly principals: readonly CachePrincipal[];
38
+ }
39
+
40
+ export type CacheServerConfigResult =
41
+ | { readonly ok: true; readonly config: CacheServerConfig }
42
+ | { readonly ok: false; readonly problems: readonly string[] };
43
+
44
+ /**
45
+ * Loopback by default: this server authenticates with a shared bearer token, so the address it binds
46
+ * is the difference between "the sheds on this host" and "the network". Widening it is an explicit
47
+ * act (`NX_CACHE_SERVER_HOST=0.0.0.0` or an interface address), never an accident of the default.
48
+ */
49
+ export const DEFAULT_HOST = '127.0.0.1';
50
+ export const DEFAULT_PORT = 8765;
51
+ /**
52
+ * 1 GiB. The nx client builds the whole gzipped tarball in memory before it sends
53
+ * (`packages/nx/src/native/cache/http_remote_cache.rs`, `store`), so artifacts are already bounded by
54
+ * client RAM; this bound protects the server's disk from a client that is not nx.
55
+ */
56
+ export const DEFAULT_MAX_BYTES = 1024 * 1024 * 1024;
57
+ export const DEFAULT_CAP_BYTES = 10 * 1024 * 1024 * 1024;
58
+
59
+ const MAX_PORT = 65_535;
60
+ /** Lowercase, digits and dashes: a label is read by humans in logs and typed into a systemd unit. */
61
+ const LABEL = /^[a-z0-9][a-z0-9-]{0,31}$/;
62
+
63
+ const TOKEN_SOURCES: readonly (readonly [envName: string, capability: CacheCapability])[] = [
64
+ ['NX_CACHE_SERVER_WRITE_TOKENS', 'write'],
65
+ ['NX_CACHE_SERVER_READ_TOKENS', 'read'],
66
+ ];
67
+
68
+ type Env = Readonly<Record<string, string | undefined>>;
69
+
70
+ export function cacheServerConfigFromEnv(env: Env): CacheServerConfigResult {
71
+ const problems: string[] = [];
72
+ const directory = readDirectory(env, problems);
73
+ const port = readInteger(env, 'NX_CACHE_SERVER_PORT', DEFAULT_PORT, 0, MAX_PORT, problems);
74
+ const maxBytes = readInteger(
75
+ env,
76
+ 'NX_CACHE_SERVER_MAX_BYTES',
77
+ DEFAULT_MAX_BYTES,
78
+ 1,
79
+ Number.MAX_SAFE_INTEGER,
80
+ problems,
81
+ );
82
+ const capBytes = readInteger(
83
+ env,
84
+ 'NX_CACHE_SERVER_CAP_BYTES',
85
+ DEFAULT_CAP_BYTES,
86
+ 1,
87
+ Number.MAX_SAFE_INTEGER,
88
+ problems,
89
+ );
90
+ if (maxBytes !== undefined && capBytes !== undefined && maxBytes > capBytes) {
91
+ problems.push('NX_CACHE_SERVER_MAX_BYTES must not exceed NX_CACHE_SERVER_CAP_BYTES.');
92
+ }
93
+ const principals = readPrincipals(env, problems);
94
+ const configuredHost = env.NX_CACHE_SERVER_HOST?.trim();
95
+ const host = configuredHost === undefined || configuredHost === '' ? DEFAULT_HOST : configuredHost;
96
+
97
+ if (
98
+ problems.length > 0 ||
99
+ directory === undefined ||
100
+ port === undefined ||
101
+ maxBytes === undefined ||
102
+ capBytes === undefined ||
103
+ principals === undefined
104
+ ) {
105
+ return { ok: false, problems };
106
+ }
107
+ return { ok: true, config: { directory, host, port, maxBytes, capBytes, principals } };
108
+ }
109
+
110
+ function readDirectory(env: Env, problems: string[]): string | undefined {
111
+ const raw = env.NX_CACHE_SERVER_DIR?.trim();
112
+ if (raw === undefined || raw === '') {
113
+ problems.push(
114
+ 'NX_CACHE_SERVER_DIR is not set. Name the directory that holds cache entries; there is no default because a wrong guess would start an empty cache that looks healthy.',
115
+ );
116
+ return undefined;
117
+ }
118
+ if (!raw.startsWith('/')) {
119
+ problems.push(
120
+ `NX_CACHE_SERVER_DIR=${JSON.stringify(raw)} must be an absolute path: a supervisor starts this process, and its working directory is not the operator's.`,
121
+ );
122
+ return undefined;
123
+ }
124
+ return raw;
125
+ }
126
+
127
+ function readInteger(
128
+ env: Env,
129
+ name: string,
130
+ fallback: number,
131
+ min: number,
132
+ max: number,
133
+ problems: string[],
134
+ ): number | undefined {
135
+ const raw = env[name]?.trim();
136
+ if (raw === undefined || raw === '') return fallback;
137
+ const value = Number(raw);
138
+ if (!Number.isInteger(value) || value < min || value > max) {
139
+ problems.push(`${name}=${JSON.stringify(raw)} must be an integer between ${min} and ${max}.`);
140
+ return undefined;
141
+ }
142
+ return value;
143
+ }
144
+
145
+ function readPrincipals(env: Env, problems: string[]): readonly CachePrincipal[] | undefined {
146
+ const principals: CachePrincipal[] = [];
147
+ const labels = new Set<string>();
148
+ const secrets = new Set<string>();
149
+
150
+ for (const [envName, capability] of TOKEN_SOURCES) {
151
+ const raw = env[envName];
152
+ if (raw === undefined) continue;
153
+ const entries = raw.split(',');
154
+ entries.forEach((rawEntry, index) => {
155
+ const entry = rawEntry.trim();
156
+ if (entry === '') return;
157
+ // The position, never the entry: an entry without a colon is entirely secret.
158
+ const at = `${envName} entry ${index + 1}`;
159
+ const colon = entry.indexOf(':');
160
+ if (colon < 1) {
161
+ problems.push(
162
+ `${at} is not "label:secret". Every token carries a label so an audit line can name the principal that published an artifact without printing the token.`,
163
+ );
164
+ return;
165
+ }
166
+ const label = entry.slice(0, colon);
167
+ const secret = entry.slice(colon + 1);
168
+ if (!LABEL.test(label)) {
169
+ problems.push(
170
+ `${at} label ${JSON.stringify(label)} must match ${LABEL.source} — lowercase, digits and dashes.`,
171
+ );
172
+ return;
173
+ }
174
+ if (secret === '') {
175
+ problems.push(`${at} (label ${label}) has an empty secret.`);
176
+ return;
177
+ }
178
+ if (labels.has(label)) {
179
+ problems.push(`${at} reuses label ${label}. One label names one principal, or the audit log lies.`);
180
+ return;
181
+ }
182
+ if (secrets.has(secret)) {
183
+ problems.push(
184
+ `${at} (label ${label}) repeats a secret already configured for another principal. One secret maps to exactly one label and one capability.`,
185
+ );
186
+ return;
187
+ }
188
+ labels.add(label);
189
+ secrets.add(secret);
190
+ principals.push({ label, bearer: `Bearer ${secret}`, capability });
191
+ });
192
+ }
193
+
194
+ if (principals.length === 0) {
195
+ problems.push(
196
+ 'No tokens configured. Set NX_CACHE_SERVER_WRITE_TOKENS and/or NX_CACHE_SERVER_READ_TOKENS to comma-separated "label:secret" entries; the write list may publish artifacts, the read list may only fetch them.',
197
+ );
198
+ return undefined;
199
+ }
200
+ return principals;
201
+ }
package/src/gc.ts ADDED
@@ -0,0 +1,164 @@
1
+ /// <reference types="bun" />
2
+ // SIEVE eviction over the entry store, with the filesystem as the only state.
3
+ //
4
+ // An entry is written once (its mtime). readEntry explicitly marks atime past
5
+ // mtime even on noatime mounts: `atime > mtime` is the visited bit. Clearing it is
6
+ // setting atime back to mtime. Insertion order is mtime, and the hand is one
7
+ // mtime cursor persisted beside the store. A sweep walks the ring from the hand:
8
+ // a visited entry is cleared and skipped, an unvisited one is evicted, until the
9
+ // store is under its cap. Scan resistance is the point — a burst of archives no
10
+ // gate ever reads again cannot flush the entries every gate reads, which LRU
11
+ // would let it do.
12
+ import { lstat, readdir, readFile, rename, rm, utimes } from 'node:fs/promises';
13
+ import { join } from 'node:path';
14
+ import { logLine, logValue } from './log.js';
15
+ import { type CacheStore, describe, entryPath, errnoOf, isCacheKey, openCacheStore } from './store.js';
16
+
17
+ export interface Entry {
18
+ readonly path: string;
19
+ readonly mtimeMs: number;
20
+ readonly atimeMs: number;
21
+ readonly size: number;
22
+ }
23
+
24
+ export interface SweepOutcome {
25
+ readonly usedBefore: number;
26
+ readonly usedAfter: number;
27
+ readonly deleted: number;
28
+ readonly cleared: number;
29
+ readonly hand: number;
30
+ }
31
+
32
+ export interface Store {
33
+ list(): Promise<Entry[]>;
34
+ clearVisited(entry: Entry): Promise<void>;
35
+ evict(entry: Entry): Promise<void>;
36
+ }
37
+
38
+ export function insertionOrder(entries: readonly Entry[]): Entry[] {
39
+ return [...entries].sort((a, b) => a.mtimeMs - b.mtimeMs || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
40
+ }
41
+
42
+ /** One SIEVE sweep; `hand` is the mtime cursor from the previous sweep (0 = start). */
43
+ export async function sweep(
44
+ store: Store,
45
+ capBytes: number,
46
+ hand: number,
47
+ protectedPath?: string,
48
+ ): Promise<SweepOutcome> {
49
+ const ring = insertionOrder(await store.list());
50
+ const usedBefore = ring.reduce((sum, e) => sum + e.size, 0);
51
+ let used = usedBefore;
52
+ let deleted = 0;
53
+ let cleared = 0;
54
+ let cursor = hand;
55
+ if (used > capBytes && ring.length > 0) {
56
+ // Walk from the hand, wrapping once; a full circuit clears every visited bit.
57
+ const start = ring.findIndex((e) => e.mtimeMs >= hand);
58
+ const order = start < 0 ? ring : [...ring.slice(start), ...ring.slice(0, start)];
59
+ for (let round = 0; round < 2 && used > capBytes; round++) {
60
+ for (const entry of order) {
61
+ if (used <= capBytes) break;
62
+ if (entry.path === protectedPath) continue;
63
+ const visited = entry.atimeMs > entry.mtimeMs;
64
+ // The second circuit visits only entries spared by the first, never already-deleted entries.
65
+ if (round === 1 && !visited) continue;
66
+ if (round === 0 && visited) {
67
+ await store.clearVisited(entry);
68
+ cleared++;
69
+ } else {
70
+ await store.evict(entry);
71
+ used -= entry.size;
72
+ deleted++;
73
+ cursor = entry.mtimeMs;
74
+ }
75
+ }
76
+ }
77
+ }
78
+ return { usedBefore, usedAfter: used, deleted, cleared, hand: cursor };
79
+ }
80
+
81
+ export function directoryStore(entries: string): Store {
82
+ return {
83
+ async list() {
84
+ const names = await readdir(entries);
85
+ const out: Entry[] = [];
86
+ for (const name of names) {
87
+ if (!isCacheKey(name)) continue;
88
+ const path = join(entries, name);
89
+ const s = await lstat(path);
90
+ if (s.isFile())
91
+ out.push({
92
+ path,
93
+ mtimeMs: s.mtimeMs,
94
+ atimeMs: s.atimeMs,
95
+ size: s.size,
96
+ });
97
+ }
98
+ return out;
99
+ },
100
+ async clearVisited(entry) {
101
+ await utimes(entry.path, entry.mtimeMs / 1000, entry.mtimeMs / 1000);
102
+ },
103
+ async evict(entry) {
104
+ await rm(entry.path, { force: true });
105
+ },
106
+ };
107
+ }
108
+
109
+ export type SweepResult =
110
+ | { readonly ok: true; readonly outcome: SweepOutcome }
111
+ | { readonly ok: false; readonly reason: string };
112
+
113
+ /**
114
+ * Caller holds the store lease and serializes sweeps; only ENOENT means an absent hand, not
115
+ * unreadable/corrupt state. `protectedKey` names an archive that must survive this sweep.
116
+ */
117
+ export async function sweepDirectory(store: CacheStore, capBytes: number, protectedKey?: string): Promise<SweepResult> {
118
+ const entries = store.directory;
119
+ const handFile = join(entries, '..', 'sieve.hand');
120
+ const temporary = `${handFile}.tmp`;
121
+ try {
122
+ let hand = 0;
123
+ try {
124
+ const text = (await readFile(handFile, 'utf8')).trim();
125
+ hand = Number(text);
126
+ if (text === '' || !Number.isFinite(hand) || hand < 0) {
127
+ return { ok: false, reason: `Invalid SIEVE cursor in ${handFile}.` };
128
+ }
129
+ } catch (cause) {
130
+ if (errnoOf(cause) !== 'ENOENT') return { ok: false, reason: describe(cause) };
131
+ }
132
+ const protectedPath = protectedKey === undefined ? undefined : entryPath(store, protectedKey);
133
+ const outcome = await sweep(directoryStore(entries), capBytes, hand, protectedPath);
134
+ await Bun.write(temporary, `${outcome.hand}\n`);
135
+ await rename(temporary, handFile);
136
+ logLine(
137
+ `ev=gc used_before=${outcome.usedBefore} used_after=${outcome.usedAfter} cap=${capBytes} deleted=${outcome.deleted} cleared=${outcome.cleared} hand=${outcome.hand}`,
138
+ );
139
+ return { ok: true, outcome };
140
+ } catch (cause) {
141
+ const reason = describe(cause);
142
+ logLine(`ev=gc.failed reason=${logValue(reason)}`);
143
+ return { ok: false, reason };
144
+ }
145
+ }
146
+
147
+ if (import.meta.main) {
148
+ const [entries, rawCap] = Bun.argv.slice(2);
149
+ const cap = Number(rawCap);
150
+ if (entries === undefined || !Number.isSafeInteger(cap) || cap < 1) {
151
+ console.error('usage: gc.ts <entries-dir> <positive-cap-bytes>');
152
+ process.exit(2);
153
+ }
154
+ const opened = await openCacheStore(entries);
155
+ if (!opened.ok) {
156
+ console.error(`nx-cache-gc refused: ${opened.path} is held by a running cache server (EWOULDBLOCK)`);
157
+ process.exit(1);
158
+ }
159
+ const result = await sweepDirectory(opened.store, cap).finally(() => opened.store.close());
160
+ if (!result.ok) {
161
+ console.error(`nx-cache-gc failed: ${result.reason}`);
162
+ process.exit(1);
163
+ }
164
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ /** Nx-compatible opaque task archives, atomic publication, and filesystem SIEVE retention. */
2
+ export * from './config.js';
3
+ export * from './gc.js';
4
+ export * from './server.js';
5
+ export * from './store.js';
package/src/log.ts ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * One line per event, logfmt, on stderr. stdout is reserved for the machine-readable readiness
3
+ * record consumed by the host supervisor. Every event names its key, principal label and outcome.
4
+ * The supervisor stamps and collects these events; the server owns no permanent text log.
5
+ *
6
+ * No secret ever appears here. A principal is named by its configured label (`config.ts`), never by
7
+ * its token, and every value that came from a request goes through `logValue` so a crafted cache key
8
+ * cannot forge a field or split one event across two lines.
9
+ */
10
+
11
+ const PROGRAM = 'nx-cache-server';
12
+
13
+ /** Values that need no quoting in logfmt: the cache-key charset plus the punctuation paths use. */
14
+ const BARE_VALUE = /^[\w.:@/+-]+$/;
15
+
16
+ export function logLine(fields: string): void {
17
+ console.error(`${PROGRAM} ${fields}`);
18
+ }
19
+
20
+ /**
21
+ * Render an untrusted string as one logfmt value. `JSON.stringify` is the escaping: it quotes,
22
+ * escapes inner quotes, and turns newlines into `\n` so an event stays on one line.
23
+ */
24
+ export function logValue(value: string): string {
25
+ return BARE_VALUE.test(value) ? value : JSON.stringify(value);
26
+ }
package/src/main.ts ADDED
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env bun
2
+ /// <reference types="bun" />
3
+
4
+ /**
5
+ * The daemon entry point: read the environment, refuse to start on anything it cannot describe, serve
6
+ * until a signal arrives.
7
+ *
8
+ * stderr carries event logs and guidance; stdout carries one JSON startup record for the supervisor.
9
+ * Exit 2 is configuration refusal; exit 1 is operational startup failure.
10
+ *
11
+ * NX_CACHE_SERVER_DIR=/srv/nx-cache \
12
+ * NX_CACHE_SERVER_WRITE_TOKENS=ci:$SECRET \
13
+ * NX_CACHE_SERVER_READ_TOKENS=shed:$OTHER \
14
+ * bun src/main.ts
15
+ */
16
+
17
+ import { cacheServerConfigFromEnv } from './config.js';
18
+ import { logLine, logValue } from './log.js';
19
+ import { type CacheServerReadyRecord, startCacheServer } from './server.js';
20
+ import { describe } from './store.js';
21
+
22
+ let exitOnStdinEof = false;
23
+ const argumentProblems: string[] = [];
24
+ for (let position = 2; position < Bun.argv.length; position++) {
25
+ if (Bun.argv[position] === '--exit-on-stdin-eof') {
26
+ exitOnStdinEof = true;
27
+ } else {
28
+ argumentProblems.push(
29
+ `argument ${position - 1} is unsupported; the only supported argument is --exit-on-stdin-eof`,
30
+ );
31
+ }
32
+ }
33
+ if (argumentProblems.length > 0) {
34
+ console.log(JSON.stringify({ kind: 'failed', stage: 'config', problems: argumentProblems }));
35
+ console.error('nx-cache-server refuses to start:');
36
+ for (const problem of argumentProblems) {
37
+ console.error(` - ${problem}`);
38
+ }
39
+ process.exit(2);
40
+ }
41
+
42
+ const configured = cacheServerConfigFromEnv(Bun.env);
43
+ if (!configured.ok) {
44
+ console.log(JSON.stringify({ kind: 'failed', stage: 'config', problems: configured.problems }));
45
+ console.error('nx-cache-server refuses to start:');
46
+ for (const problem of configured.problems) {
47
+ console.error(` - ${problem}`);
48
+ }
49
+ process.exit(2);
50
+ }
51
+
52
+ const started = await startCacheServer(configured.config);
53
+ if (!started.ok) {
54
+ console.log(JSON.stringify({ kind: 'failed', ...started }));
55
+ console.error(`nx-cache-server ${started.stage} failed in ${started.directory}: ${started.reason}`);
56
+ process.exit(1);
57
+ }
58
+ const running = started.server;
59
+ console.log(
60
+ JSON.stringify({
61
+ kind: 'ready',
62
+ origin: running.origin,
63
+ port: running.port,
64
+ reclaimedStagingFiles: running.reclaimedStagingFiles,
65
+ } satisfies CacheServerReadyRecord),
66
+ );
67
+
68
+ /**
69
+ * Standard mode stops on SIGINT or SIGTERM. With `--exit-on-stdin-eof`, the parent that holds stdin
70
+ * stops the daemon by closing it; signals still stop it in both modes. A stop lets in-flight requests
71
+ * finish. An upload killed mid-body leaves only a staged file, which the next start reclaims — the
72
+ * published entry is never partial, because it only ever comes into existence by `link(2)`.
73
+ */
74
+ let stopping = false;
75
+ function stop(fields: string, exitCode = 0): void {
76
+ if (stopping) return;
77
+ stopping = true;
78
+ logLine(`ev=stopping ${fields}`);
79
+ void running.stop().then(
80
+ () => process.exit(exitCode),
81
+ (failure: unknown) => {
82
+ logLine(`ev=stop.failed reason=${logValue(describe(failure))}`);
83
+ process.exit(1);
84
+ },
85
+ );
86
+ }
87
+ for (const signal of ['SIGINT', 'SIGTERM'] as const) {
88
+ process.on(signal, () => stop(`signal=${signal}`));
89
+ }
90
+ if (exitOnStdinEof) {
91
+ process.stdin.once('end', () => stop('reason=supervisor-eof'));
92
+ process.stdin.once('error', (failure) => {
93
+ logLine(`ev=supervisor.error reason=${logValue(describe(failure))}`);
94
+ stop('reason=supervisor-error', 1);
95
+ });
96
+ process.stdin.resume();
97
+ }