@prisma/composer-prisma-cloud 0.30.0-dev.2 → 0.30.0-dev.4

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.
@@ -1,17 +1,21 @@
1
- import { a as readJsonFile, i as isValidSegment, n as StateFile, o as readOwnVersion, t as SERVICE_READY_TIMEOUT_ENV } from "./segments-BW2SYdBf-CtmcRzwy.mjs";
1
+ import { a as readOwnVersion, i as readJsonFile, n as StateFile, t as SERVICE_READY_TIMEOUT_ENV } from "./daemon-D9BV062Q-ZTmWho_N.mjs";
2
2
  import { n as getPorts, r as portNumbers, t as clearLockedPorts } from "./get-port-CwLPc-ca.mjs";
3
3
  import * as fs from "node:fs";
4
4
  import { spawn } from "node:child_process";
5
5
  import * as path from "node:path";
6
6
  import * as net$1 from "node:net";
7
+ import { randomBytes } from "node:crypto";
7
8
  import * as http from "node:http";
8
9
  //#region ../../1-prisma-cloud/0-lowering/dev-emulators/dist/compute-main.mjs
9
10
  /**
10
11
  * The Compute emulator daemon (local-dev spec § 2 `compute-main.ts`): a small
11
- * local counterpart of the platform's compute service. Owns service child
12
- * processes (spawned `bun bootstrap.js` from a real packaged artifact),
13
- * supervises them (crash backoff, held after repeated fast crashes), and
14
- * serves a loopback JSON admin API plus per-service log streaming.
12
+ * local counterpart of the platform's compute service. Holds service
13
+ * instances under ids it generates, owns their child processes (spawned
14
+ * `bun bootstrap.js` from a real packaged artifact), supervises them (crash
15
+ * backoff, held after repeated fast crashes), and serves a loopback JSON
16
+ * admin API plus per-instance log streaming. An instance's labels are its
17
+ * creator's to read: they are stored and returned verbatim, never
18
+ * interpreted.
15
19
  *
16
20
  * Runs as its own OS process, started by `daemon.ts`'s `ensureDaemon` via
17
21
  * `process.execPath <this file> --port <n> --state-dir <dir>`. Everything it
@@ -29,43 +33,50 @@ const LOG_POLL_INTERVAL_MS = 100;
29
33
  const DEFAULT_READY_TIMEOUT_MS = 3e4;
30
34
  const DEFAULT_READY_POLL_MS = 50;
31
35
  const READY_PROBE_TIMEOUT_MS = 1e3;
32
- const APPS_STATE_MODE = 384;
36
+ const STATE_MODE = 384;
33
37
  const BUN_NOT_FOUND_MESSAGE = "local dev runs services under bun — the Prisma Compute runtime — and `bun` was not found on PATH. Install it: https://bun.sh.";
34
- function isServiceStatus(value) {
38
+ const INSTANCE_ID_RE = /^svc_[0-9a-f]{16}$/;
39
+ function newInstanceId() {
40
+ return `svc_${randomBytes(8).toString("hex")}`;
41
+ }
42
+ function isInstanceStatus(value) {
35
43
  return value === "running" || value === "backoff" || value === "held" || value === "stopped";
36
44
  }
37
45
  function isStringRecord(value) {
38
- return typeof value === "object" && value !== null && Object.values(value).every((v) => typeof v === "string");
46
+ return typeof value === "object" && value !== null && !Array.isArray(value) && Object.values(value).every((v) => typeof v === "string");
47
+ }
48
+ function isPort(value) {
49
+ return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= MAX_SERVICE_PORT;
50
+ }
51
+ function isOptional(value, key, check) {
52
+ return !(key in value) || Reflect.get(value, key) === void 0 || check(Reflect.get(value, key));
39
53
  }
40
- function isServiceRecord(value) {
54
+ function isInstanceRecord(value) {
41
55
  if (typeof value !== "object" || value === null) return false;
42
56
  if (!("id" in value) || typeof value.id !== "string") return false;
43
- if (!("address" in value) || typeof value.address !== "string") return false;
44
- if (!("port" in value) || typeof value.port !== "number") return false;
45
- if (!("status" in value) || !isServiceStatus(value.status)) return false;
57
+ if (!("labels" in value) || !isStringRecord(value.labels)) return false;
58
+ if (!("port" in value) || !isPort(value.port)) return false;
59
+ if (!("status" in value) || !isInstanceStatus(value.status)) return false;
46
60
  if (!("logPath" in value) || typeof value.logPath !== "string") return false;
47
- if ("pid" in value && value.pid !== void 0 && typeof value.pid !== "number") return false;
48
- if ("lastExitCode" in value && value.lastExitCode !== void 0 && typeof value.lastExitCode !== "number") return false;
49
- if ("artifactHash" in value && value.artifactHash !== void 0 && typeof value.artifactHash !== "string") return false;
50
- if ("artifactDir" in value && value.artifactDir !== void 0 && typeof value.artifactDir !== "string") return false;
51
- if ("env" in value && value.env !== void 0 && !isStringRecord(value.env)) return false;
52
- if ("listens" in value && value.listens !== void 0 && typeof value.listens !== "boolean") return false;
53
- return true;
61
+ return isOptional(value, "pid", (v) => typeof v === "number") && isOptional(value, "lastExitCode", (v) => typeof v === "number") && isOptional(value, "artifactHash", (v) => typeof v === "string") && isOptional(value, "artifactDir", (v) => typeof v === "string") && isOptional(value, "env", isStringRecord) && isOptional(value, "listens", (v) => typeof v === "boolean");
54
62
  }
55
- function isAppRecord(value) {
56
- return typeof value === "object" && value !== null && "services" in value && typeof value.services === "object" && value.services !== null && Object.values(value.services).every(isServiceRecord);
63
+ function isInstancesState(value) {
64
+ return typeof value === "object" && value !== null && "instances" in value && typeof value.instances === "object" && value.instances !== null && Object.values(value.instances).every(isInstanceRecord);
57
65
  }
58
- function isAppsState(value) {
59
- return typeof value === "object" && value !== null && Object.values(value).every(isAppRecord);
66
+ function isCreateBody(value) {
67
+ return typeof value === "object" && value !== null && "labels" in value && isStringRecord(value.labels) && isOptional(value, "port", isPort);
60
68
  }
61
69
  function isDeploymentBody(value) {
62
- return typeof value === "object" && value !== null && "address" in value && typeof value.address === "string" && "artifactDir" in value && typeof value.artifactDir === "string" && "artifactHash" in value && typeof value.artifactHash === "string" && "env" in value && isStringRecord(value.env) && "port" in value && typeof value.port === "number" && (!("listens" in value) || value.listens === void 0 || typeof value.listens === "boolean");
70
+ return typeof value === "object" && value !== null && "artifactDir" in value && typeof value.artifactDir === "string" && "artifactHash" in value && typeof value.artifactHash === "string" && "env" in value && isStringRecord(value.env) && (!("listens" in value) || value.listens === void 0 || typeof value.listens === "boolean");
71
+ }
72
+ function isIdsBody(value) {
73
+ return typeof value === "object" && value !== null && "ids" in value && Array.isArray(value.ids) && value.ids.every((id) => typeof id === "string");
63
74
  }
64
75
  function sleep(ms) {
65
76
  return new Promise((resolve) => setTimeout(resolve, ms));
66
77
  }
67
- /** Append-only per-service log file — child stdout/stderr plus `[emulator]` supervision lines. No rotation in v1. */
68
- var ServiceLog = class {
78
+ /** Append-only per-instance log file — child stdout/stderr plus `[emulator]` supervision lines. No rotation in v1. */
79
+ var InstanceLog = class {
69
80
  filePath;
70
81
  fd;
71
82
  constructor(filePath) {
@@ -84,7 +95,7 @@ var ServiceLog = class {
84
95
  writeLine(line) {
85
96
  fs.writeSync(this.ensureOpen(), `${line}\n`);
86
97
  }
87
- /** Empties the file — a fresh service start begins a clean log; past sessions' and past deployments' output is not kept. */
98
+ /** Empties the file — a fresh start begins a clean log; past sessions' and past deployments' output is not kept. */
88
99
  clear() {
89
100
  fs.ftruncateSync(this.ensureOpen(), 0);
90
101
  }
@@ -170,17 +181,14 @@ function resolveBunOnPath(env) {
170
181
  function main() {
171
182
  const { port, stateDir, readyTimeoutMs, readyPollMs } = parseArgs(process.argv.slice(2));
172
183
  const ownVersion = readOwnVersion();
173
- const appsJsonPath = path.join(stateDir, "apps.json");
174
- const stateFile = new StateFile(appsJsonPath, APPS_STATE_MODE);
175
- let state = {};
184
+ const statePath = path.join(stateDir, "instances.json");
185
+ const stateFile = new StateFile(statePath, STATE_MODE);
186
+ const logsDir = path.join(stateDir, "logs");
187
+ let state = { instances: {} };
176
188
  const runtimes = /* @__PURE__ */ new Map();
177
189
  const logGeneration = /* @__PURE__ */ new Map();
178
- function runtimeKey(app, id) {
179
- return `${app}/${id}`;
180
- }
181
- function getRuntime(app, id) {
182
- const key = runtimeKey(app, id);
183
- let rt = runtimes.get(key);
190
+ function getRuntime(id) {
191
+ let rt = runtimes.get(id);
184
192
  if (!rt) {
185
193
  rt = {
186
194
  child: void 0,
@@ -194,16 +202,13 @@ function main() {
194
202
  stopping: void 0,
195
203
  deleted: false
196
204
  };
197
- runtimes.set(key, rt);
205
+ runtimes.set(id, rt);
198
206
  }
199
207
  return rt;
200
208
  }
201
- function serviceLogPath(app, id) {
202
- return path.join(stateDir, "logs", app, `${id}.log`);
203
- }
204
- function getLog(app, id, logPath) {
205
- const rt = getRuntime(app, id);
206
- if (!rt.log) rt.log = new ServiceLog(logPath);
209
+ function getLog(inst) {
210
+ const rt = getRuntime(inst.id);
211
+ if (!rt.log) rt.log = new InstanceLog(inst.logPath);
207
212
  return rt.log;
208
213
  }
209
214
  function schedulePersist() {
@@ -217,41 +222,41 @@ function main() {
217
222
  if (rt.backoffTimer) clearTimeout(rt.backoffTimer);
218
223
  rt.backoffTimer = void 0;
219
224
  }
220
- function usedServicePorts() {
221
- const ports = /* @__PURE__ */ new Set();
222
- for (const app of Object.values(state)) for (const svc of Object.values(app.services)) ports.add(svc.port);
223
- return ports;
225
+ function heldPorts() {
226
+ return new Set(Object.values(state.instances).map((inst) => inst.port));
224
227
  }
225
228
  /**
226
- * The smallest genuinely free port at or above `MIN_SERVICE_PORT`,
227
- * excluding this daemon's own persisted allocations — persistence and
228
- * the range policy stay ours; whether a candidate is actually bindable
229
- * is `get-port`'s (spec § 2's dependency razor).
229
+ * The requested port when no instance holds it and it is bindable,
230
+ * otherwise the smallest such port at or above `MIN_SERVICE_PORT`. Which
231
+ * ports instances hold is ours; whether a candidate is actually bindable is
232
+ * `get-port`'s (spec § 2's dependency razor).
230
233
  */
231
- async function smallestUnusedServicePort() {
234
+ async function allocatePort(requested) {
235
+ const exclude = heldPorts();
236
+ if (requested !== void 0 && !exclude.has(requested)) {
237
+ const given = await getPorts({
238
+ port: requested,
239
+ exclude
240
+ });
241
+ if (given === requested) return given;
242
+ }
232
243
  return getPorts({
233
244
  port: portNumbers(MIN_SERVICE_PORT, MAX_SERVICE_PORT),
234
- exclude: usedServicePorts()
245
+ exclude
235
246
  });
236
247
  }
237
- async function getOrCreateService(app, id) {
238
- let appRec = state[app];
239
- if (!appRec) {
240
- appRec = { services: {} };
241
- state[app] = appRec;
242
- }
243
- let svc = appRec.services[id];
244
- if (svc) return svc;
245
- svc = {
248
+ async function createInstance(body) {
249
+ const id = newInstanceId();
250
+ const inst = {
246
251
  id,
247
- address: id,
248
- port: await smallestUnusedServicePort(),
252
+ labels: { ...body.labels },
253
+ port: await allocatePort(body.port),
249
254
  status: "stopped",
250
- logPath: serviceLogPath(app, id)
255
+ logPath: path.join(logsDir, `${id}.log`)
251
256
  };
252
- appRec.services[id] = svc;
257
+ state.instances[id] = inst;
253
258
  schedulePersist();
254
- return svc;
259
+ return inst;
255
260
  }
256
261
  function waitForExit(child) {
257
262
  return new Promise((resolve) => {
@@ -271,12 +276,12 @@ function main() {
271
276
  await exited;
272
277
  }
273
278
  }
274
- function handleChildExit(app, id, svc, rt, child, code) {
279
+ function handleChildExit(inst, rt, child, code) {
275
280
  if (child !== rt.child) return;
276
281
  clearStableTimer(rt);
277
282
  rt.child = void 0;
278
- delete svc.pid;
279
- if (code !== null) svc.lastExitCode = code;
283
+ delete inst.pid;
284
+ if (code !== null) inst.lastExitCode = code;
280
285
  if (rt.expectedExit) {
281
286
  rt.expectedExit = false;
282
287
  schedulePersist();
@@ -287,21 +292,21 @@ function main() {
287
292
  const attemptNumber = rt.consecutiveFastExits;
288
293
  if (uptime < STABLE_UPTIME_MS) rt.consecutiveFastExits += 1;
289
294
  if (rt.consecutiveFastExits >= MAX_CONSECUTIVE_FAST_EXITS) {
290
- svc.status = "held";
295
+ inst.status = "held";
291
296
  schedulePersist();
292
- getLog(app, id, svc.logPath).writeLine(`[emulator] held after ${String(MAX_CONSECUTIVE_FAST_EXITS)} consecutive fast exits — send a new deployment to resume`);
297
+ getLog(inst).writeLine(`[emulator] held after ${String(MAX_CONSECUTIVE_FAST_EXITS)} consecutive fast exits — send a new deployment to resume`);
293
298
  return;
294
299
  }
295
300
  const delayMs = Math.min(MAX_BACKOFF_MS, BASE_BACKOFF_MS * 2 ** attemptNumber);
296
- svc.status = "backoff";
301
+ inst.status = "backoff";
297
302
  schedulePersist();
298
- getLog(app, id, svc.logPath).writeLine(`[emulator] exited (code ${code === null ? "null" : String(code)}) — restarting in ${String(delayMs / 1e3)}s`);
303
+ getLog(inst).writeLine(`[emulator] exited (code ${code === null ? "null" : String(code)}) — restarting in ${String(delayMs / 1e3)}s`);
299
304
  rt.backoffTimer = setTimeout(() => {
300
305
  rt.backoffTimer = void 0;
301
- spawnService(app, id, svc, rt, false);
306
+ spawnInstance(inst, rt, false);
302
307
  }, delayMs);
303
308
  }
304
- /** Records `run` as the service's start in flight, replacing any earlier one. */
309
+ /** Records `run` as the instance's start in flight, replacing any earlier one. */
305
310
  function beginStart(rt, fresh, run) {
306
311
  const token = { processExited: false };
307
312
  const result = run(token).finally(() => {
@@ -318,41 +323,41 @@ function main() {
318
323
  function joinableStart(rt) {
319
324
  return rt.start?.token.processExited === false ? rt.start : void 0;
320
325
  }
321
- /** Undefined while `token` is still the service's start. Otherwise what that start reports: a newer start's result, or "stopped" after a stop or an app delete. */
322
- function supersededOutcome(svc, rt, token) {
326
+ /** Undefined while `token` is still the instance's start. Otherwise what that start reports: a newer start's result, or "stopped" after a stop or a delete. */
327
+ function supersededOutcome(inst, rt, token) {
323
328
  if (!rt.deleted && rt.start?.token === token) return void 0;
324
329
  if (!rt.deleted && rt.start) return rt.start.result;
325
330
  return Promise.resolve({
326
331
  ok: false,
327
- message: `service "${svc.address}" was stopped before it accepted connections`
332
+ message: `instance "${inst.id}" was stopped before it accepted connections`
328
333
  });
329
334
  }
330
335
  /**
331
- * Spawns the service's child and resolves once it accepts connections on
336
+ * Spawns the instance's child and resolves once it accepts connections on
332
337
  * its port — only then is it `running`. A child that exits first, or never
333
338
  * listens within the readiness timeout, fails the start with a message
334
- * naming the service and its log.
339
+ * naming the instance and its log.
335
340
  */
336
- function spawnService(app, id, svc, rt, fresh) {
337
- return beginStart(rt, fresh, (token) => spawnAndWaitUntilListening(app, id, svc, rt, fresh, token));
341
+ function spawnInstance(inst, rt, fresh) {
342
+ return beginStart(rt, fresh, (token) => spawnAndWaitUntilListening(inst, rt, fresh, token));
338
343
  }
339
- async function spawnAndWaitUntilListening(app, id, svc, rt, fresh, token) {
340
- const env = svc.env ?? {};
341
- const artifactDir = svc.artifactDir ?? ".";
344
+ async function spawnAndWaitUntilListening(inst, rt, fresh, token) {
345
+ const env = inst.env ?? {};
346
+ const artifactDir = inst.artifactDir ?? ".";
342
347
  const bunPath = resolveBunOnPath(env);
343
348
  if (!bunPath) {
344
- svc.status = "stopped";
345
- delete svc.pid;
349
+ inst.status = "stopped";
350
+ delete inst.pid;
346
351
  schedulePersist();
347
352
  return {
348
353
  ok: false,
349
354
  message: BUN_NOT_FOUND_MESSAGE
350
355
  };
351
356
  }
352
- const log = getLog(app, id, svc.logPath);
357
+ const log = getLog(inst);
353
358
  if (fresh) {
354
359
  log.clear();
355
- logGeneration.set(svc.logPath, (logGeneration.get(svc.logPath) ?? 0) + 1);
360
+ logGeneration.set(inst.logPath, (logGeneration.get(inst.logPath) ?? 0) + 1);
356
361
  }
357
362
  const child = spawn(bunPath, ["bootstrap.js"], {
358
363
  cwd: artifactDir,
@@ -372,52 +377,52 @@ function main() {
372
377
  }, STABLE_UPTIME_MS);
373
378
  child.stdout?.on("data", (chunk) => log.writeRaw(chunk));
374
379
  child.stderr?.on("data", (chunk) => log.writeRaw(chunk));
375
- child.on("exit", (code) => handleChildExit(app, id, svc, rt, child, code));
380
+ child.on("exit", (code) => handleChildExit(inst, rt, child, code));
376
381
  child.once("exit", () => {
377
382
  token.processExited = true;
378
383
  });
379
- if (child.pid !== void 0) svc.pid = child.pid;
380
- if (svc.listens === false) {
381
- svc.status = "running";
384
+ if (child.pid !== void 0) inst.pid = child.pid;
385
+ if (inst.listens === false) {
386
+ inst.status = "running";
382
387
  schedulePersist();
383
388
  return { ok: true };
384
389
  }
385
390
  schedulePersist();
386
- const port = String(svc.port);
387
- const outcome = await waitUntilListening(child, svc.port, readyTimeoutMs, readyPollMs);
388
- const superseded = supersededOutcome(svc, rt, token);
391
+ const port = String(inst.port);
392
+ const outcome = await waitUntilListening(child, inst.port, readyTimeoutMs, readyPollMs);
393
+ const superseded = supersededOutcome(inst, rt, token);
389
394
  if (superseded) return superseded;
390
- const service = `service "${svc.address}"`;
395
+ const instance = `instance "${inst.id}"`;
391
396
  if (outcome.kind === "exited") return {
392
397
  ok: false,
393
- message: `${service} exited (${outcome.code !== null ? `code ${String(outcome.code)}` : outcome.signal ?? "no exit code"}) before it accepted connections on its port ${port}. Its log: ${svc.logPath}`
398
+ message: `${instance} exited (${outcome.code !== null ? `code ${String(outcome.code)}` : outcome.signal ?? "no exit code"}) before it accepted connections on its port ${port}. Its log: ${inst.logPath}`
394
399
  };
395
400
  if (outcome.kind === "timed-out") {
396
401
  const seconds = String(readyTimeoutMs / 1e3);
397
- const message = `${service} did not accept connections on its port ${port} within ${seconds}s. Its log: ${svc.logPath}. If it needs longer to start, set ${SERVICE_READY_TIMEOUT_ENV} (milliseconds) and restart the compute emulator. If it never listens on a port, declare it with compute({ listens: false }).`;
402
+ const message = `${instance} did not accept connections on its port ${port} within ${seconds}s. Its log: ${inst.logPath}. If it needs longer to start, set ${SERVICE_READY_TIMEOUT_ENV} (milliseconds) and restart the compute emulator. If it never listens on its port, deploy it with listens: false.`;
398
403
  if (fresh) {
399
404
  log.writeLine(`[emulator] did not accept connections on port ${port} within ${seconds}s — stopped`);
400
- await stopService(svc, rt);
405
+ await stopInstance(inst, rt);
401
406
  } else {
402
407
  log.writeLine(`[emulator] restart did not accept connections on port ${port} within ${seconds}s — held; send a new deployment to resume`);
403
- await stopService(svc, rt, "held");
408
+ await stopInstance(inst, rt, "held");
404
409
  }
405
410
  return {
406
411
  ok: false,
407
412
  message
408
413
  };
409
414
  }
410
- svc.status = "running";
415
+ inst.status = "running";
411
416
  schedulePersist();
412
417
  return { ok: true };
413
418
  }
414
- async function stopService(svc, rt, finalStatus = "stopped") {
419
+ async function stopInstance(inst, rt, finalStatus = "stopped") {
415
420
  rt.start = void 0;
416
421
  clearBackoffTimer(rt);
417
422
  clearStableTimer(rt);
418
423
  const stopping = killChild(rt, TERMINATE_GRACE_MS).then(() => {
419
- svc.status = finalStatus;
420
- delete svc.pid;
424
+ inst.status = finalStatus;
425
+ delete inst.pid;
421
426
  schedulePersist();
422
427
  });
423
428
  rt.stopping = stopping;
@@ -432,28 +437,39 @@ function main() {
432
437
  }
433
438
  /**
434
439
  * The session-resume signal: Alchemy's no-op reconcile never fires a
435
- * deployment PUT, so nothing else restarts a service a previous session
440
+ * deployment PUT, so nothing else restarts an instance a previous session
436
441
  * stopped. Reuses the exact deployment-PUT start rules — same spawn
437
442
  * path, same supervision reset, and an explicit resume clears `held`
438
- * the same way a deployment PUT does — but from the service's already
439
- * STORED deployment spec, since no new one arrives with a start.
443
+ * the same way a deployment PUT does — but from the instance's already
444
+ * STORED deployment, since no new one arrives with a start.
440
445
  */
441
- async function startService(app, id, svc) {
442
- if (svc.artifactHash === void 0) return { ok: true };
443
- const rt = getRuntime(app, id);
446
+ async function startInstance(inst) {
447
+ if (inst.artifactHash === void 0) return { ok: true };
448
+ const rt = getRuntime(inst.id);
444
449
  const inFlight = joinableStart(rt);
445
450
  if (inFlight) return inFlight.result;
446
- if (svc.status === "running" && rt.stopping === void 0) return { ok: true };
451
+ if (inst.status === "running" && rt.stopping === void 0) return { ok: true };
447
452
  return beginStart(rt, true, async (token) => {
448
453
  clearBackoffTimer(rt);
449
454
  await stopsFinished(rt);
450
- const superseded = supersededOutcome(svc, rt, token);
455
+ const superseded = supersededOutcome(inst, rt, token);
451
456
  if (superseded) return superseded;
452
457
  clearStableTimer(rt);
453
458
  rt.consecutiveFastExits = 0;
454
- return spawnAndWaitUntilListening(app, id, svc, rt, true, token);
459
+ return spawnAndWaitUntilListening(inst, rt, true, token);
455
460
  });
456
461
  }
462
+ async function deleteInstance(inst) {
463
+ const rt = getRuntime(inst.id);
464
+ rt.deleted = true;
465
+ await stopInstance(inst, rt);
466
+ rt.log?.close();
467
+ runtimes.delete(inst.id);
468
+ delete state.instances[inst.id];
469
+ schedulePersist();
470
+ clearLockedPorts();
471
+ await fs.promises.rm(inst.logPath, { force: true });
472
+ }
457
473
  function json(res, status, body) {
458
474
  const text = JSON.stringify(body);
459
475
  res.writeHead(status, { "content-type": "application/json" });
@@ -463,8 +479,9 @@ function main() {
463
479
  res.writeHead(status, { "content-type": "text/plain; charset=utf-8" });
464
480
  res.end(body);
465
481
  }
466
- function badSegment(res, segment) {
467
- text(res, 400, `invalid path segment "${segment}": must match /^[a-z0-9][a-z0-9-]*$/ and be at most 63 characters`);
482
+ function noContent(res) {
483
+ res.writeHead(204);
484
+ res.end();
468
485
  }
469
486
  function readBody(req) {
470
487
  return new Promise((resolve, reject) => {
@@ -474,17 +491,25 @@ function main() {
474
491
  req.on("error", reject);
475
492
  });
476
493
  }
477
- function serviceView(svc) {
494
+ async function readJsonBody(req) {
495
+ const raw = await readBody(req);
496
+ try {
497
+ return JSON.parse(raw.toString("utf8"));
498
+ } catch {
499
+ return;
500
+ }
501
+ }
502
+ function instanceView(inst) {
478
503
  return {
479
- id: svc.id,
480
- address: svc.address,
481
- port: svc.port,
482
- url: `http://localhost:${String(svc.port)}`,
483
- status: svc.status,
484
- ...svc.pid !== void 0 ? { pid: svc.pid } : {},
485
- ...svc.lastExitCode !== void 0 ? { lastExitCode: svc.lastExitCode } : {},
486
- ...svc.artifactHash !== void 0 ? { artifactHash: svc.artifactHash } : {},
487
- logPath: svc.logPath
504
+ id: inst.id,
505
+ labels: inst.labels,
506
+ port: inst.port,
507
+ url: `http://localhost:${String(inst.port)}`,
508
+ status: inst.status,
509
+ ...inst.pid !== void 0 ? { pid: inst.pid } : {},
510
+ ...inst.lastExitCode !== void 0 ? { lastExitCode: inst.lastExitCode } : {},
511
+ ...inst.artifactHash !== void 0 ? { artifactHash: inst.artifactHash } : {},
512
+ logPath: inst.logPath
488
513
  };
489
514
  }
490
515
  function envEquals(a, b) {
@@ -494,47 +519,37 @@ function main() {
494
519
  if (aKeys.length !== bKeys.length) return false;
495
520
  return aKeys.every((k) => a[k] === b[k]);
496
521
  }
497
- async function handleDeployment(req, res, app, id) {
498
- const raw = await readBody(req);
499
- let parsed;
500
- try {
501
- parsed = JSON.parse(raw.toString("utf8"));
502
- } catch {
503
- return text(res, 400, "malformed JSON body");
504
- }
505
- if (!isDeploymentBody(parsed)) return text(res, 400, "malformed deployment body");
506
- const svc = await getOrCreateService(app, id);
507
- const rt = getRuntime(app, id);
508
- const listens = parsed.listens ?? true;
509
- const changed = svc.artifactHash !== parsed.artifactHash || !envEquals(svc.env, parsed.env) || (svc.listens ?? true) !== listens;
522
+ async function handleDeployment(req, res, inst) {
523
+ const body = await readJsonBody(req);
524
+ if (!isDeploymentBody(body)) return text(res, 400, "malformed deployment body");
525
+ const rt = getRuntime(inst.id);
526
+ const listens = body.listens ?? true;
527
+ const changed = inst.artifactHash !== body.artifactHash || !envEquals(inst.env, body.env) || (inst.listens ?? true) !== listens;
510
528
  let result;
511
529
  const inFlight = joinableStart(rt);
512
530
  if (!changed && inFlight?.fresh === true) result = await inFlight.result;
513
- else if (!changed && rt.start === void 0 && rt.stopping === void 0 && svc.status === "running") result = { ok: true };
531
+ else if (!changed && rt.start === void 0 && rt.stopping === void 0 && inst.status === "running") result = { ok: true };
514
532
  else {
515
- svc.address = parsed.address;
516
- svc.port = parsed.port;
517
- svc.artifactHash = parsed.artifactHash;
518
- svc.env = parsed.env;
519
- svc.artifactDir = parsed.artifactDir;
520
- svc.listens = listens;
533
+ inst.artifactHash = body.artifactHash;
534
+ inst.env = body.env;
535
+ inst.artifactDir = body.artifactDir;
536
+ inst.listens = listens;
521
537
  clearBackoffTimer(rt);
522
538
  schedulePersist();
523
539
  result = await beginStart(rt, true, async (token) => {
524
540
  await stopsFinished(rt);
525
541
  await killChild(rt, TERMINATE_GRACE_MS);
526
- const superseded = supersededOutcome(svc, rt, token);
542
+ const superseded = supersededOutcome(inst, rt, token);
527
543
  if (superseded) return superseded;
528
- svc.status = "stopped";
529
- delete svc.pid;
544
+ inst.status = "stopped";
545
+ delete inst.pid;
530
546
  clearStableTimer(rt);
531
547
  rt.consecutiveFastExits = 0;
532
- return spawnAndWaitUntilListening(app, id, svc, rt, true, token);
548
+ return spawnAndWaitUntilListening(inst, rt, true, token);
533
549
  });
534
550
  }
535
- if (!result.ok) return text(res, 500, result.message ?? "failed to start the service");
536
- res.writeHead(204);
537
- res.end();
551
+ if (!result.ok) return text(res, 500, result.message ?? "failed to start the instance");
552
+ noContent(res);
538
553
  }
539
554
  /** Byte offset a follow should start at to show the last `tailLines` complete lines; `<= 0` (or an empty file) starts at the current end, no backlog. */
540
555
  function startOffsetForTail(logPath, tailLines) {
@@ -600,86 +615,71 @@ function main() {
600
615
  };
601
616
  res.on("close", stop);
602
617
  }
618
+ function handleLogs(url, res, inst) {
619
+ if (url.searchParams.get("follow") === "1") {
620
+ const tailParam = url.searchParams.get("tail");
621
+ const tailLines = tailParam === null ? 0 : Math.max(0, Number.parseInt(tailParam, 10) || 0);
622
+ handleFollowLogs(res, inst.logPath, tailLines);
623
+ return;
624
+ }
625
+ const content = fs.existsSync(inst.logPath) ? fs.readFileSync(inst.logPath) : Buffer.alloc(0);
626
+ res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
627
+ res.end(content);
628
+ }
629
+ /** The instances `ids` name, or the ids no instance has. */
630
+ function instancesByIds(ids) {
631
+ const unknown = ids.filter((id) => state.instances[id] === void 0);
632
+ if (unknown.length > 0) return { unknown };
633
+ return { found: ids.flatMap((id) => state.instances[id] ?? []) };
634
+ }
635
+ function unknownInstances(res, ids) {
636
+ text(res, 404, `no such instance: ${ids.map((id) => `"${id}"`).join(", ")}`);
637
+ }
638
+ async function handleBulk(req, res, action) {
639
+ const body = await readJsonBody(req);
640
+ if (!isIdsBody(body)) return text(res, 400, "malformed body: expected { \"ids\": string[] }");
641
+ if (action === "stop") {
642
+ const known = body.ids.flatMap((id) => state.instances[id] ?? []);
643
+ await Promise.all(known.map((inst) => stopInstance(inst, getRuntime(inst.id))));
644
+ return noContent(res);
645
+ }
646
+ const selected = instancesByIds(body.ids);
647
+ if ("unknown" in selected) return unknownInstances(res, selected.unknown);
648
+ const failures = (await Promise.all(selected.found.map(startInstance))).flatMap((r) => r.ok ? [] : [r.message ?? "failed to start"]);
649
+ if (failures.length > 0) return text(res, 500, failures.join("\n"));
650
+ noContent(res);
651
+ }
603
652
  async function handleRequest(req, res) {
604
653
  const url = new URL(req.url ?? "/", `http://127.0.0.1:${String(port)}`);
605
654
  const method = req.method ?? "GET";
606
655
  const segments = url.pathname.split("/").filter((s) => s.length > 0).map((s) => decodeURIComponent(s));
607
656
  if (method === "GET" && segments.length === 1 && segments[0] === "health") return json(res, 200, { version: ownVersion });
608
- if (segments[0] === "apps" && segments.length >= 2) {
609
- const app = segments[1];
610
- if (app === void 0 || !isValidSegment(app)) return badSegment(res, app ?? "");
611
- if (method === "PUT" && segments.length === 4 && segments[2] === "services") {
612
- const id = segments[3];
613
- if (id === void 0 || !isValidSegment(id)) return badSegment(res, id ?? "");
614
- const svc = await getOrCreateService(app, id);
615
- return json(res, 200, {
616
- port: svc.port,
617
- url: `http://localhost:${String(svc.port)}`
618
- });
619
- }
620
- if (method === "PUT" && segments.length === 5 && segments[2] === "services" && segments[4] === "deployment") {
621
- const id = segments[3];
622
- if (id === void 0 || !isValidSegment(id)) return badSegment(res, id ?? "");
623
- return handleDeployment(req, res, app, id);
624
- }
625
- if (method === "GET" && segments.length === 3 && segments[2] === "services") {
626
- const appRec = state[app];
627
- return json(res, 200, appRec ? Object.values(appRec.services).map(serviceView) : []);
628
- }
629
- if (method === "GET" && segments.length === 5 && segments[2] === "services" && segments[4] === "logs") {
630
- const id = segments[3];
631
- if (id === void 0 || !isValidSegment(id)) return badSegment(res, id ?? "");
632
- const svc = state[app]?.services[id];
633
- if (!svc) return text(res, 404, `no such service "${id}" in app "${app}"`);
634
- if (url.searchParams.get("follow") === "1") {
635
- const tailParam = url.searchParams.get("tail");
636
- const tailLines = tailParam === null ? 0 : Math.max(0, Number.parseInt(tailParam, 10) || 0);
637
- handleFollowLogs(res, svc.logPath, tailLines);
638
- return;
639
- }
640
- const content = fs.existsSync(svc.logPath) ? fs.readFileSync(svc.logPath) : Buffer.alloc(0);
641
- res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
642
- res.end(content);
643
- return;
644
- }
645
- if (method === "POST" && segments.length === 3 && segments[2] === "stop") {
646
- const appRec = state[app];
647
- if (appRec) await Promise.all(Object.entries(appRec.services).map(([id, svc]) => stopService(svc, getRuntime(app, id))));
648
- res.writeHead(204);
649
- res.end();
650
- return;
651
- }
652
- if (method === "POST" && segments.length === 3 && segments[2] === "start") {
653
- const appRec = state[app];
654
- const failures = (appRec ? await Promise.all(Object.entries(appRec.services).map(([id, svc]) => startService(app, id, svc))) : []).flatMap((r) => r.ok ? [] : [r.message ?? "failed to start"]);
655
- if (failures.length > 0) return text(res, 500, failures.join("\n"));
656
- res.writeHead(204);
657
- res.end();
658
- return;
659
- }
660
- if (method === "DELETE" && segments.length === 2) {
661
- const appRec = state[app];
662
- if (appRec) {
663
- for (const id of Object.keys(appRec.services)) getRuntime(app, id).deleted = true;
664
- await Promise.all(Object.entries(appRec.services).map(([id, svc]) => stopService(svc, getRuntime(app, id))));
665
- for (const id of Object.keys(appRec.services)) {
666
- const key = runtimeKey(app, id);
667
- runtimes.get(key)?.log?.close();
668
- runtimes.delete(key);
669
- }
670
- delete state[app];
671
- schedulePersist();
672
- clearLockedPorts();
673
- }
674
- await fs.promises.rm(path.join(stateDir, "logs", app), {
675
- recursive: true,
676
- force: true
677
- });
678
- res.writeHead(204);
679
- res.end();
680
- return;
657
+ if (segments[0] !== "instances") {
658
+ res.writeHead(404);
659
+ res.end();
660
+ return;
661
+ }
662
+ if (segments.length === 1) {
663
+ if (method === "GET") return json(res, 200, Object.values(state.instances).map(instanceView));
664
+ if (method === "POST") {
665
+ const body = await readJsonBody(req);
666
+ if (!isCreateBody(body)) return text(res, 400, "malformed body: expected { \"labels\": Record<string, string>, \"port\"?: number }");
667
+ return json(res, 201, instanceView(await createInstance(body)));
681
668
  }
682
669
  }
670
+ if (method === "POST" && segments.length === 2 && segments[1] === "start") return handleBulk(req, res, "start");
671
+ if (method === "POST" && segments.length === 2 && segments[1] === "stop") return handleBulk(req, res, "stop");
672
+ const id = segments[1];
673
+ if (id !== void 0 && !INSTANCE_ID_RE.test(id)) return text(res, 400, `invalid instance id "${id}"`);
674
+ const inst = id === void 0 ? void 0 : state.instances[id];
675
+ if (method === "DELETE" && segments.length === 2) {
676
+ if (inst) await deleteInstance(inst);
677
+ return noContent(res);
678
+ }
679
+ if (!inst) return unknownInstances(res, id === void 0 ? [] : [id]);
680
+ if (method === "GET" && segments.length === 2) return json(res, 200, instanceView(inst));
681
+ if (method === "PUT" && segments.length === 3 && segments[2] === "deployment") return handleDeployment(req, res, inst);
682
+ if (method === "GET" && segments.length === 3 && segments[2] === "logs") return handleLogs(url, res, inst);
683
683
  res.writeHead(404);
684
684
  res.end();
685
685
  }
@@ -706,11 +706,11 @@ function main() {
706
706
  }
707
707
  process.on("SIGTERM", () => void shutdown());
708
708
  process.on("SIGINT", () => void shutdown());
709
- readJsonFile(appsJsonPath, isAppsState).then((loaded) => {
709
+ discardStateFromEarlierVersions(stateDir, statePath, logsDir).then(() => readJsonFile(statePath, isInstancesState)).then((loaded) => {
710
710
  if (loaded) {
711
- for (const appRec of Object.values(loaded)) for (const svc of Object.values(appRec.services)) if (svc.status === "running" || svc.status === "backoff") {
712
- svc.status = "stopped";
713
- delete svc.pid;
711
+ for (const inst of Object.values(loaded.instances)) if (inst.status === "running" || inst.status === "backoff") {
712
+ inst.status = "stopped";
713
+ delete inst.pid;
714
714
  }
715
715
  state = loaded;
716
716
  }
@@ -722,6 +722,18 @@ function main() {
722
722
  process.exit(1);
723
723
  });
724
724
  }
725
+ /**
726
+ * Removes state files this version does not write, and log directories: an
727
+ * earlier version kept its state in another JSON file and grouped its logs in
728
+ * directories. Local dev state is never migrated across versions; those
729
+ * instances are simply gone.
730
+ */
731
+ async function discardStateFromEarlierVersions(stateDir, statePath, logsDir) {
732
+ const stateFiles = await fs.promises.readdir(stateDir).catch(() => []);
733
+ await Promise.all(stateFiles.map((name) => path.join(stateDir, name)).filter((file) => file.endsWith(".json") && file !== statePath).map((file) => fs.promises.rm(file, { force: true })));
734
+ const logEntries = await fs.promises.readdir(logsDir, { withFileTypes: true }).catch(() => []);
735
+ await Promise.all(logEntries.filter((entry) => entry.isDirectory()).map((entry) => fs.promises.rm(path.join(logsDir, entry.name), { recursive: true })));
736
+ }
725
737
  main();
726
738
  //#endregion
727
739
  export {};