@treeseed/core 0.6.16 → 0.6.17

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.
@@ -0,0 +1,25 @@
1
+ export type TreeseedDevWatchEntry = {
2
+ kind: 'tenant' | 'package' | 'sdk';
3
+ root: string;
4
+ };
5
+ export type TreeseedDevWatchChange = {
6
+ changedPaths: string[];
7
+ tenantChanged: boolean;
8
+ tenantApiChanged: boolean;
9
+ packageChanged: boolean;
10
+ sdkChanged: boolean;
11
+ };
12
+ export type TreeseedDevWatchController = {
13
+ stop: () => void;
14
+ rebaseline: () => void;
15
+ };
16
+ export type TreeseedDevWatchStarter = (input: {
17
+ watchEntries: TreeseedDevWatchEntry[];
18
+ onChange: (change: TreeseedDevWatchChange) => void | Promise<void>;
19
+ }) => TreeseedDevWatchController;
20
+ export declare function shouldIgnoreWatchPath(filePath: string, rootPath: string): boolean;
21
+ export declare function classifyChanges(changedPaths: string[], watchEntries: TreeseedDevWatchEntry[]): TreeseedDevWatchChange;
22
+ export declare function startPollingWatch({ watchEntries, onChange }: Parameters<TreeseedDevWatchStarter>[0]): {
23
+ stop(): void;
24
+ rebaseline(): void;
25
+ };
@@ -0,0 +1,161 @@
1
+ import { existsSync, readdirSync, statSync } from "node:fs";
2
+ import { relative, resolve, sep } from "node:path";
3
+ const WATCH_INTERVAL_MS = 900;
4
+ const WATCH_DEBOUNCE_MS = 350;
5
+ function shouldIgnoreWatchPath(filePath, rootPath) {
6
+ const rel = relative(rootPath, filePath);
7
+ if (!rel || rel.startsWith(`..${sep}`) || rel === "..") {
8
+ return false;
9
+ }
10
+ const normalized = rel.split(sep).join("/");
11
+ const segments = normalized.split("/").filter(Boolean);
12
+ const basename = segments.at(-1) ?? normalized;
13
+ const ignoredSegments = /* @__PURE__ */ new Set([".git", "node_modules", ".astro", ".wrangler", ".local", ".treeseed", "dist", "coverage"]);
14
+ if (segments.some((segment) => ignoredSegments.has(segment))) {
15
+ return true;
16
+ }
17
+ if (normalized === "books" || normalized.startsWith("books/") || normalized === "__treeseed" || normalized.startsWith("__treeseed/") || normalized.startsWith("public/books/") || normalized.startsWith("public/__treeseed/")) {
18
+ return true;
19
+ }
20
+ return basename.startsWith(".ts-run-") && basename.endsWith(".mjs") || basename.endsWith(".log") || basename.endsWith(".pid") || basename.endsWith(".sock") || basename.endsWith(".tmp") || basename.endsWith(".temp") || basename.endsWith(".sqlite") || basename.includes(".sqlite-") || basename.endsWith(".db-journal") || basename.endsWith(".db-wal") || basename.endsWith(".db-shm");
21
+ }
22
+ function collectRootSnapshot(rootPath, snapshot) {
23
+ if (!existsSync(rootPath)) {
24
+ return;
25
+ }
26
+ const stats = statSync(rootPath);
27
+ if (stats.isFile()) {
28
+ snapshot.set(rootPath, `${stats.mtimeMs}:${stats.size}`);
29
+ return;
30
+ }
31
+ for (const entry of readdirSync(rootPath, { withFileTypes: true })) {
32
+ const fullPath = resolve(rootPath, entry.name);
33
+ if (shouldIgnoreWatchPath(fullPath, rootPath)) {
34
+ continue;
35
+ }
36
+ if (entry.isDirectory()) {
37
+ collectDirectorySnapshot(fullPath, rootPath, snapshot);
38
+ continue;
39
+ }
40
+ const entryStats = statSync(fullPath);
41
+ snapshot.set(fullPath, `${entryStats.mtimeMs}:${entryStats.size}`);
42
+ }
43
+ }
44
+ function collectDirectorySnapshot(directoryPath, rootPath, snapshot) {
45
+ if (shouldIgnoreWatchPath(directoryPath, rootPath)) {
46
+ return;
47
+ }
48
+ for (const entry of readdirSync(directoryPath, { withFileTypes: true })) {
49
+ const fullPath = resolve(directoryPath, entry.name);
50
+ if (shouldIgnoreWatchPath(fullPath, rootPath)) {
51
+ continue;
52
+ }
53
+ if (entry.isDirectory()) {
54
+ collectDirectorySnapshot(fullPath, rootPath, snapshot);
55
+ continue;
56
+ }
57
+ const stats = statSync(fullPath);
58
+ snapshot.set(fullPath, `${stats.mtimeMs}:${stats.size}`);
59
+ }
60
+ }
61
+ function collectSnapshot(entries) {
62
+ const snapshot = /* @__PURE__ */ new Map();
63
+ for (const entry of entries) {
64
+ collectRootSnapshot(entry.root, snapshot);
65
+ }
66
+ return snapshot;
67
+ }
68
+ function diffSnapshots(previousSnapshot, nextSnapshot) {
69
+ const changed = /* @__PURE__ */ new Set();
70
+ for (const [filePath, signature] of nextSnapshot.entries()) {
71
+ if (previousSnapshot.get(filePath) !== signature) {
72
+ changed.add(filePath);
73
+ }
74
+ }
75
+ for (const filePath of previousSnapshot.keys()) {
76
+ if (!nextSnapshot.has(filePath)) {
77
+ changed.add(filePath);
78
+ }
79
+ }
80
+ return [...changed];
81
+ }
82
+ function classifyChanges(changedPaths, watchEntries) {
83
+ function matchesEntry(filePath, entry) {
84
+ return filePath === entry.root || filePath.startsWith(`${entry.root}${sep}`);
85
+ }
86
+ function isTenantApiInput(filePath) {
87
+ const normalized = filePath.split(sep).join("/");
88
+ return normalized.includes("/src/api/") || normalized.endsWith("/src/api") || normalized.endsWith("/treeseed.site.yaml") || normalized.endsWith("/treeseed.config.ts") || normalized.endsWith("/package.json") || normalized.endsWith("/tsconfig.json");
89
+ }
90
+ const tenantChanged = changedPaths.some(
91
+ (filePath) => watchEntries.some((entry) => entry.kind === "tenant" && matchesEntry(filePath, entry))
92
+ );
93
+ return {
94
+ changedPaths,
95
+ sdkChanged: changedPaths.some(
96
+ (filePath) => watchEntries.some((entry) => entry.kind === "sdk" && matchesEntry(filePath, entry))
97
+ ),
98
+ packageChanged: changedPaths.some(
99
+ (filePath) => watchEntries.some((entry) => entry.kind === "package" && matchesEntry(filePath, entry))
100
+ ),
101
+ tenantChanged,
102
+ tenantApiChanged: tenantChanged && changedPaths.some(isTenantApiInput)
103
+ };
104
+ }
105
+ function startPollingWatch({ watchEntries, onChange }) {
106
+ let previousSnapshot = collectSnapshot(watchEntries);
107
+ let queuedPaths = [];
108
+ let debounceTimer = null;
109
+ let running = false;
110
+ const intervalId = setInterval(() => {
111
+ const nextSnapshot = collectSnapshot(watchEntries);
112
+ const changedPaths = diffSnapshots(previousSnapshot, nextSnapshot);
113
+ previousSnapshot = nextSnapshot;
114
+ if (changedPaths.length === 0) {
115
+ return;
116
+ }
117
+ queuedPaths.push(...changedPaths);
118
+ if (debounceTimer) {
119
+ clearTimeout(debounceTimer);
120
+ }
121
+ debounceTimer = setTimeout(() => {
122
+ void flush();
123
+ }, WATCH_DEBOUNCE_MS);
124
+ }, WATCH_INTERVAL_MS);
125
+ async function flush() {
126
+ if (running || queuedPaths.length === 0) {
127
+ return;
128
+ }
129
+ const changedPaths = [...new Set(queuedPaths)];
130
+ queuedPaths = [];
131
+ running = true;
132
+ try {
133
+ await onChange(classifyChanges(changedPaths, watchEntries));
134
+ } finally {
135
+ previousSnapshot = collectSnapshot(watchEntries);
136
+ running = false;
137
+ }
138
+ }
139
+ function clearDebounce() {
140
+ if (debounceTimer) {
141
+ clearTimeout(debounceTimer);
142
+ debounceTimer = null;
143
+ }
144
+ }
145
+ return {
146
+ stop() {
147
+ clearDebounce();
148
+ clearInterval(intervalId);
149
+ },
150
+ rebaseline() {
151
+ clearDebounce();
152
+ queuedPaths = [];
153
+ previousSnapshot = collectSnapshot(watchEntries);
154
+ }
155
+ };
156
+ }
157
+ export {
158
+ classifyChanges,
159
+ shouldIgnoreWatchPath,
160
+ startPollingWatch
161
+ };
package/dist/dev.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ChildProcess, SpawnOptions } from 'node:child_process';
2
2
  import { spawnSync } from 'node:child_process';
3
+ import { type TreeseedDevWatchEntry, type TreeseedDevWatchStarter } from './dev-watch';
3
4
  export declare const TREESEED_DEFAULT_WEB_HOST = "127.0.0.1";
4
5
  export declare const TREESEED_DEFAULT_WEB_PORT = 4321;
5
6
  export declare const TREESEED_DEFAULT_API_HOST = "127.0.0.1";
@@ -30,6 +31,7 @@ export type TreeseedIntegratedDevOptions = {
30
31
  teamId?: string;
31
32
  readinessTimeoutMs?: number;
32
33
  processReadyGraceMs?: number;
34
+ shutdownGraceMs?: number;
33
35
  };
34
36
  export type TreeseedIntegratedDevCommand = {
35
37
  id: 'web' | 'api' | 'manager' | 'worker';
@@ -39,10 +41,7 @@ export type TreeseedIntegratedDevCommand = {
39
41
  cwd: string;
40
42
  env: NodeJS.ProcessEnv;
41
43
  };
42
- export type TreeseedIntegratedDevWatchEntry = {
43
- kind: 'tenant' | 'package' | 'sdk';
44
- root: string;
45
- };
44
+ export type TreeseedIntegratedDevWatchEntry = TreeseedDevWatchEntry;
46
45
  export type TreeseedIntegratedDevSetupStep = {
47
46
  id: string;
48
47
  label: string;
@@ -77,22 +76,15 @@ type SpawnLike = (command: string, args: string[], options: SpawnOptions) => Chi
77
76
  type SpawnSyncLike = typeof spawnSync;
78
77
  type SignalRegistrar = (signal: NodeJS.Signals, handler: () => void) => () => void;
79
78
  type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
80
- type WatchChange = {
81
- changedPaths: string[];
82
- tenantChanged: boolean;
83
- packageChanged: boolean;
84
- sdkChanged: boolean;
85
- };
86
- type WatchStarter = (input: {
87
- watchEntries: TreeseedIntegratedDevWatchEntry[];
88
- onChange: (change: WatchChange) => void | Promise<void>;
89
- }) => () => void;
79
+ type ProcessKiller = (pid: number, signal: NodeJS.Signals) => void;
80
+ type WatchStarter = TreeseedDevWatchStarter;
90
81
  type TreeseedIntegratedDevDependencies = {
91
82
  spawn: SpawnLike;
92
83
  spawnSync: SpawnSyncLike;
93
84
  onSignal: SignalRegistrar;
94
85
  prepareEnvironment: (tenantRoot: string) => void;
95
86
  fetch: FetchLike;
87
+ killProcess: ProcessKiller;
96
88
  write: (line: string, stream: 'stdout' | 'stderr') => void;
97
89
  openBrowser: (url: string) => void | Promise<void>;
98
90
  startWatch: WatchStarter;
package/dist/dev.js CHANGED
@@ -1,7 +1,7 @@
1
- import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
2
  import { spawn, spawnSync } from "node:child_process";
3
3
  import { createRequire } from "node:module";
4
- import { dirname, relative, resolve, sep } from "node:path";
4
+ import { dirname, resolve, sep } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { setTimeout as delay } from "node:timers/promises";
7
7
  import {
@@ -11,6 +11,9 @@ import {
11
11
  findNearestTreeseedWorkspaceRoot,
12
12
  resolveTreeseedToolBinary
13
13
  } from "@treeseed/sdk/workflow-support";
14
+ import {
15
+ startPollingWatch
16
+ } from "./dev-watch.js";
14
17
  const require2 = createRequire(import.meta.url);
15
18
  const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
16
19
  const TREESEED_DEFAULT_WEB_HOST = "127.0.0.1";
@@ -19,10 +22,10 @@ const TREESEED_DEFAULT_API_HOST = "127.0.0.1";
19
22
  const TREESEED_DEFAULT_API_PORT = 3e3;
20
23
  const TREESEED_DEFAULT_MANAGER_PORT = 3100;
21
24
  const DEV_RELOAD_FILE = "public/__treeseed/dev-reload.json";
22
- const WATCH_INTERVAL_MS = 900;
23
- const WATCH_DEBOUNCE_MS = 350;
24
25
  const DEFAULT_READINESS_TIMEOUT_MS = 9e4;
25
26
  const DEFAULT_PROCESS_READY_GRACE_MS = 1200;
27
+ const DEFAULT_SHUTDOWN_GRACE_MS = 2500;
28
+ const DEFAULT_KILL_GRACE_MS = 500;
26
29
  function resolvePackageRoot(packageName, tenantRoot) {
27
30
  const resolvedPath = require2.resolve(packageName, {
28
31
  paths: [tenantRoot, packageRoot, process.cwd()]
@@ -91,9 +94,6 @@ function resolveTenantApiEntrypoint(tenantRoot, runTsPath) {
91
94
  }
92
95
  return null;
93
96
  }
94
- function withWatchArgs(args, watchPaths) {
95
- return watchPaths.flatMap((watchPath) => ["--watch-path", watchPath]).concat(args);
96
- }
97
97
  function normalizePort(value, fallback) {
98
98
  return Number.isInteger(value) && Number(value) > 0 ? Number(value) : fallback;
99
99
  }
@@ -115,21 +115,30 @@ function webUrlFor(host, port) {
115
115
  function createWatchEntries(tenantRoot, sdkPackageRoot) {
116
116
  const entries = [
117
117
  { kind: "tenant", root: resolve(tenantRoot, "src") },
118
+ { kind: "tenant", root: resolve(tenantRoot, "content") },
118
119
  { kind: "tenant", root: resolve(tenantRoot, "public") },
119
120
  { kind: "tenant", root: resolve(tenantRoot, "astro.config.ts") },
120
- { kind: "tenant", root: resolve(tenantRoot, "treeseed.site.yaml") }
121
+ { kind: "tenant", root: resolve(tenantRoot, "astro.config.mjs") },
122
+ { kind: "tenant", root: resolve(tenantRoot, "treeseed.site.yaml") },
123
+ { kind: "tenant", root: resolve(tenantRoot, "treeseed.config.ts") },
124
+ { kind: "tenant", root: resolve(tenantRoot, "package.json") },
125
+ { kind: "tenant", root: resolve(tenantRoot, "tsconfig.json") }
121
126
  ];
122
127
  if (!packageRoot.split(sep).includes("node_modules")) {
123
128
  entries.push(
124
129
  { kind: "package", root: resolve(packageRoot, "src") },
125
- { kind: "package", root: resolve(packageRoot, "scripts") },
130
+ { kind: "package", root: resolve(packageRoot, "scripts", "dev-platform.ts") },
131
+ { kind: "package", root: resolve(packageRoot, "scripts", "build-tenant-worker.ts") },
132
+ { kind: "package", root: resolve(packageRoot, "scripts", "run-ts.mjs") },
126
133
  { kind: "package", root: resolve(packageRoot, "package.json") }
127
134
  );
128
135
  }
129
136
  if (!sdkPackageRoot.split(sep).includes("node_modules")) {
130
137
  entries.push(
131
138
  { kind: "sdk", root: resolve(sdkPackageRoot, "src") },
132
- { kind: "sdk", root: resolve(sdkPackageRoot, "scripts") },
139
+ { kind: "sdk", root: resolve(sdkPackageRoot, "scripts", "tenant-astro-command.ts") },
140
+ { kind: "sdk", root: resolve(sdkPackageRoot, "scripts", "tenant-d1-migrate-local.ts") },
141
+ { kind: "sdk", root: resolve(sdkPackageRoot, "scripts", "run-ts.mjs") },
133
142
  { kind: "sdk", root: resolve(sdkPackageRoot, "package.json") }
134
143
  );
135
144
  }
@@ -247,13 +256,6 @@ function createTreeseedIntegratedDevPlan(options = {}) {
247
256
  "dist/services/worker.js"
248
257
  );
249
258
  const watchEntries = watch ? createWatchEntries(tenantRoot, sdkPackageRoot) : [];
250
- const watchPaths = [
251
- resolve(packageRoot, existsSync(resolve(packageRoot, "src")) ? "src" : "dist"),
252
- resolve(tenantRoot, "src"),
253
- resolve(tenantRoot, "public"),
254
- resolve(tenantRoot, "treeseed.site.yaml"),
255
- resolve(tenantRoot, "astro.config.ts")
256
- ];
257
259
  const sharedEnv = {
258
260
  ...mergedEnv,
259
261
  TREESEED_LOCAL_DEV_MODE: mergedEnv.TREESEED_LOCAL_DEV_MODE ?? "cloudflare",
@@ -284,7 +286,7 @@ function createTreeseedIntegratedDevPlan(options = {}) {
284
286
  id: "api",
285
287
  label: "Hono API",
286
288
  command: apiEntrypoint.command,
287
- args: watch ? withWatchArgs(apiEntrypoint.args, watchPaths) : apiEntrypoint.args,
289
+ args: apiEntrypoint.args,
288
290
  cwd: tenantRoot,
289
291
  env: {
290
292
  ...sharedEnv,
@@ -297,7 +299,7 @@ function createTreeseedIntegratedDevPlan(options = {}) {
297
299
  id: "manager",
298
300
  label: "Manager",
299
301
  command: managerEntrypoint.command,
300
- args: watch ? withWatchArgs(managerEntrypoint.args, watchPaths) : managerEntrypoint.args,
302
+ args: managerEntrypoint.args,
301
303
  cwd: tenantRoot,
302
304
  env: {
303
305
  ...sharedEnv,
@@ -311,7 +313,7 @@ function createTreeseedIntegratedDevPlan(options = {}) {
311
313
  id: "worker",
312
314
  label: "Worker",
313
315
  command: workerEntrypoint.command,
314
- args: watch ? withWatchArgs(workerEntrypoint.args, watchPaths) : workerEntrypoint.args,
316
+ args: workerEntrypoint.args,
315
317
  cwd: tenantRoot,
316
318
  env: sharedEnv
317
319
  });
@@ -367,15 +369,57 @@ function defaultPrepareEnvironment(tenantRoot) {
367
369
  applyTreeseedEnvironmentToProcess({ tenantRoot, scope: "local", override: true });
368
370
  assertTreeseedCommandEnvironment({ tenantRoot, scope: "local", purpose: "dev" });
369
371
  }
370
- function stopChildProcess(child, signal = "SIGTERM") {
371
- if (!child || typeof child.kill !== "function") {
372
+ function defaultKillProcess(pid, signal) {
373
+ process.kill(pid, signal);
374
+ }
375
+ function createManagedDevProcess(command, child) {
376
+ let resolveExit = () => {
377
+ };
378
+ const exitPromise = new Promise((resolvePromise) => {
379
+ resolveExit = resolvePromise;
380
+ });
381
+ return {
382
+ id: command.id,
383
+ command,
384
+ child,
385
+ pid: typeof child.pid === "number" ? child.pid : null,
386
+ exited: false,
387
+ intentionalStop: false,
388
+ exitCode: null,
389
+ exitSignal: null,
390
+ resolveExit,
391
+ exitPromise
392
+ };
393
+ }
394
+ function signalManagedProcess(managed, signal, killProcess) {
395
+ if (managed.pid != null && process.platform !== "win32") {
396
+ try {
397
+ killProcess(-managed.pid, signal);
398
+ return;
399
+ } catch {
400
+ }
401
+ }
402
+ if (typeof managed.child.kill !== "function") {
372
403
  return;
373
404
  }
374
405
  try {
375
- child.kill(signal);
406
+ managed.child.kill(signal);
376
407
  } catch {
377
408
  }
378
409
  }
410
+ async function stopManagedProcess(managed, signal, killProcess, graceMs) {
411
+ managed.intentionalStop = true;
412
+ signalManagedProcess(managed, signal, killProcess);
413
+ if (!managed.exited) {
414
+ await Promise.race([managed.exitPromise, delay(Math.max(0, graceMs))]);
415
+ }
416
+ if (signal !== "SIGKILL") {
417
+ signalManagedProcess(managed, "SIGKILL", killProcess);
418
+ if (!managed.exited) {
419
+ await Promise.race([managed.exitPromise, delay(DEFAULT_KILL_GRACE_MS)]);
420
+ }
421
+ }
422
+ }
379
423
  function writeDevReloadStamp(projectRoot) {
380
424
  const outputPath = resolve(projectRoot, DEV_RELOAD_FILE);
381
425
  mkdirSync(dirname(outputPath), { recursive: true });
@@ -393,131 +437,6 @@ function writeDevReloadStamp(projectRoot) {
393
437
  "utf8"
394
438
  );
395
439
  }
396
- function shouldIgnoreWatchPath(filePath, rootPath) {
397
- const rel = relative(rootPath, filePath);
398
- if (!rel || rel.startsWith(`..${sep}`) || rel === "..") {
399
- return false;
400
- }
401
- const normalized = rel.split(sep).join("/");
402
- return normalized === ".git" || normalized.startsWith(".git/") || normalized === "node_modules" || normalized.startsWith("node_modules/") || normalized === ".astro" || normalized.startsWith(".astro/") || normalized === ".wrangler" || normalized.startsWith(".wrangler/") || normalized === ".local" || normalized.startsWith(".local/") || normalized === ".treeseed" || normalized.startsWith(".treeseed/") || normalized === "dist" || normalized.startsWith("dist/") || normalized === "coverage" || normalized.startsWith("coverage/") || normalized.startsWith("public/books/") || normalized.startsWith("public/__treeseed/");
403
- }
404
- function collectRootSnapshot(rootPath, snapshot) {
405
- if (!existsSync(rootPath)) {
406
- return;
407
- }
408
- const stats = statSync(rootPath);
409
- if (stats.isFile()) {
410
- snapshot.set(rootPath, `${stats.mtimeMs}:${stats.size}`);
411
- return;
412
- }
413
- for (const entry of readdirSync(rootPath, { withFileTypes: true })) {
414
- const fullPath = resolve(rootPath, entry.name);
415
- if (shouldIgnoreWatchPath(fullPath, rootPath)) {
416
- continue;
417
- }
418
- if (entry.isDirectory()) {
419
- collectDirectorySnapshot(fullPath, rootPath, snapshot);
420
- continue;
421
- }
422
- const entryStats = statSync(fullPath);
423
- snapshot.set(fullPath, `${entryStats.mtimeMs}:${entryStats.size}`);
424
- }
425
- }
426
- function collectDirectorySnapshot(directoryPath, rootPath, snapshot) {
427
- if (shouldIgnoreWatchPath(directoryPath, rootPath)) {
428
- return;
429
- }
430
- for (const entry of readdirSync(directoryPath, { withFileTypes: true })) {
431
- const fullPath = resolve(directoryPath, entry.name);
432
- if (shouldIgnoreWatchPath(fullPath, rootPath)) {
433
- continue;
434
- }
435
- if (entry.isDirectory()) {
436
- collectDirectorySnapshot(fullPath, rootPath, snapshot);
437
- continue;
438
- }
439
- const stats = statSync(fullPath);
440
- snapshot.set(fullPath, `${stats.mtimeMs}:${stats.size}`);
441
- }
442
- }
443
- function collectSnapshot(entries) {
444
- const snapshot = /* @__PURE__ */ new Map();
445
- for (const entry of entries) {
446
- collectRootSnapshot(entry.root, snapshot);
447
- }
448
- return snapshot;
449
- }
450
- function diffSnapshots(previousSnapshot, nextSnapshot) {
451
- const changed = /* @__PURE__ */ new Set();
452
- for (const [filePath, signature] of nextSnapshot.entries()) {
453
- if (previousSnapshot.get(filePath) !== signature) {
454
- changed.add(filePath);
455
- }
456
- }
457
- for (const filePath of previousSnapshot.keys()) {
458
- if (!nextSnapshot.has(filePath)) {
459
- changed.add(filePath);
460
- }
461
- }
462
- return [...changed];
463
- }
464
- function classifyChanges(changedPaths, watchEntries) {
465
- function matchesEntry(filePath, entry) {
466
- return filePath === entry.root || filePath.startsWith(`${entry.root}${sep}`);
467
- }
468
- return {
469
- changedPaths,
470
- sdkChanged: changedPaths.some(
471
- (filePath) => watchEntries.some((entry) => entry.kind === "sdk" && matchesEntry(filePath, entry))
472
- ),
473
- packageChanged: changedPaths.some(
474
- (filePath) => watchEntries.some((entry) => entry.kind === "package" && matchesEntry(filePath, entry))
475
- ),
476
- tenantChanged: changedPaths.some(
477
- (filePath) => watchEntries.some((entry) => entry.kind === "tenant" && matchesEntry(filePath, entry))
478
- )
479
- };
480
- }
481
- function startPollingWatch({ watchEntries, onChange }) {
482
- let previousSnapshot = collectSnapshot(watchEntries);
483
- let queuedPaths = [];
484
- let debounceTimer = null;
485
- let running = false;
486
- const intervalId = setInterval(() => {
487
- const nextSnapshot = collectSnapshot(watchEntries);
488
- const changedPaths = diffSnapshots(previousSnapshot, nextSnapshot);
489
- previousSnapshot = nextSnapshot;
490
- if (changedPaths.length === 0) {
491
- return;
492
- }
493
- queuedPaths.push(...changedPaths);
494
- if (debounceTimer) {
495
- clearTimeout(debounceTimer);
496
- }
497
- debounceTimer = setTimeout(() => {
498
- void flush();
499
- }, WATCH_DEBOUNCE_MS);
500
- }, WATCH_INTERVAL_MS);
501
- async function flush() {
502
- if (running || queuedPaths.length === 0) {
503
- return;
504
- }
505
- const changedPaths = [...new Set(queuedPaths)];
506
- queuedPaths = [];
507
- running = true;
508
- try {
509
- await onChange(classifyChanges(changedPaths, watchEntries));
510
- } finally {
511
- running = false;
512
- }
513
- }
514
- return () => {
515
- if (debounceTimer) {
516
- clearTimeout(debounceTimer);
517
- }
518
- clearInterval(intervalId);
519
- };
520
- }
521
440
  function defaultWrite(line, stream) {
522
441
  const target = stream === "stderr" ? process.stderr : process.stdout;
523
442
  target.write(line);
@@ -738,6 +657,7 @@ async function runTreeseedIntegratedDev(options = {}, deps = {}) {
738
657
  const spawnSyncProcess = deps.spawnSync ?? spawnSync;
739
658
  const onSignal = deps.onSignal ?? defaultSignalRegistrar;
740
659
  const fetchFn = deps.fetch ?? globalThis.fetch.bind(globalThis);
660
+ const killProcess = deps.killProcess ?? defaultKillProcess;
741
661
  const openBrowser = deps.openBrowser ?? defaultOpenBrowser;
742
662
  const startWatch = deps.startWatch ?? startPollingWatch;
743
663
  const prepareEnvironment = deps.prepareEnvironment ?? defaultPrepareEnvironment;
@@ -762,33 +682,47 @@ async function runTreeseedIntegratedDev(options = {}, deps = {}) {
762
682
  }
763
683
  const children = /* @__PURE__ */ new Map();
764
684
  const commandsById = new Map(plan.commands.map((command) => [command.id, command]));
685
+ const requiredSurfaceIds = new Set(plan.readyChecks.filter((check) => check.required).map((check) => check.id));
765
686
  const exited = /* @__PURE__ */ new Map();
766
- let stopWatching = null;
687
+ let watchController = null;
767
688
  let settled = false;
768
- let restarting = false;
689
+ let readinessComplete = false;
690
+ let restartInProgress = false;
691
+ const shutdownGraceMs = options.shutdownGraceMs ?? DEFAULT_SHUTDOWN_GRACE_MS;
769
692
  return await new Promise((resolveExitCode) => {
770
693
  const disposers = [
771
694
  onSignal("SIGINT", () => finalize(130)),
772
695
  onSignal("SIGTERM", () => finalize(143))
773
696
  ];
774
- function finalize(exitCode, originId) {
697
+ function stopWatching() {
698
+ if (!watchController) {
699
+ return;
700
+ }
701
+ watchController.stop();
702
+ watchController = null;
703
+ }
704
+ function finalize(exitCode) {
775
705
  if (settled) {
776
706
  return;
777
707
  }
778
708
  settled = true;
779
- if (stopWatching) {
780
- stopWatching();
781
- stopWatching = null;
782
- }
783
- for (const [childId, child] of children.entries()) {
784
- if (childId !== originId) {
785
- stopChildProcess(child);
786
- }
787
- }
709
+ void finalizeAsync(exitCode);
710
+ }
711
+ async function finalizeAsync(exitCode) {
712
+ stopWatching();
713
+ await Promise.all(
714
+ [...children.values()].map((managed) => stopManagedProcess(managed, "SIGTERM", killProcess, shutdownGraceMs))
715
+ );
716
+ children.clear();
788
717
  for (const dispose of disposers) {
789
718
  dispose();
790
719
  }
791
- emitEvent(options, write, { type: "shutdown", exitCode, message: `Dev runtime stopped with exit code ${exitCode}.` }, exitCode === 0 ? "stdout" : "stderr");
720
+ emitEvent(
721
+ options,
722
+ write,
723
+ { type: "shutdown", exitCode, message: `Dev runtime stopped with exit code ${exitCode}.` },
724
+ exitCode === 0 ? "stdout" : "stderr"
725
+ );
792
726
  resolveExitCode(exitCode);
793
727
  }
794
728
  function spawnCommand(command) {
@@ -802,26 +736,45 @@ async function runTreeseedIntegratedDev(options = {}, deps = {}) {
802
736
  const child = spawnProcess(command.command, command.args, {
803
737
  cwd: command.cwd,
804
738
  env: command.env,
805
- stdio: options.stdio ?? ["inherit", "pipe", "pipe"],
806
- detached: false
739
+ stdio: options.stdio ?? ["ignore", "pipe", "pipe"],
740
+ detached: true
807
741
  });
808
- children.set(command.id, child);
742
+ const managed = createManagedDevProcess(command, child);
743
+ children.set(command.id, managed);
809
744
  attachPrefixedLogReader(child, command.id, options, write);
810
745
  child.on("exit", (code, signal) => {
811
- children.delete(command.id);
746
+ managed.exited = true;
747
+ managed.exitCode = code;
748
+ managed.exitSignal = signal;
749
+ managed.resolveExit();
812
750
  exited.set(command.id, { code, signal });
813
- if (restarting || settled) {
751
+ if (managed.intentionalStop || settled) {
814
752
  return;
815
753
  }
816
754
  const exitCode = signal === "SIGINT" ? 130 : signal === "SIGTERM" ? 143 : code ?? 0;
755
+ const required = requiredSurfaceIds.has(command.id);
756
+ if (!readinessComplete || required) {
757
+ emitEvent(options, write, {
758
+ type: "error",
759
+ surface: command.id,
760
+ exitCode,
761
+ signal,
762
+ message: `${command.label} exited unexpectedly during ${readinessComplete ? "supervision" : "startup"} with ${signal ?? exitCode}.`
763
+ });
764
+ finalize(exitCode === 0 ? 1 : exitCode);
765
+ return;
766
+ }
817
767
  emitEvent(options, write, {
818
- type: exitCode === 0 ? "shutdown" : "error",
768
+ type: "error",
819
769
  surface: command.id,
820
770
  exitCode,
821
771
  signal,
822
- message: `${command.label} exited with ${signal ?? exitCode}.`
823
- }, exitCode === 0 ? "stdout" : "stderr");
824
- finalize(exitCode, command.id);
772
+ status: "degraded",
773
+ message: `${command.label} exited with ${signal ?? exitCode}; continuing because it is not a required surface.`
774
+ }, "stderr");
775
+ void stopManagedProcess(managed, "SIGTERM", killProcess, 0).finally(() => {
776
+ children.delete(command.id);
777
+ });
825
778
  });
826
779
  return child;
827
780
  }
@@ -831,17 +784,64 @@ async function runTreeseedIntegratedDev(options = {}, deps = {}) {
831
784
  return;
832
785
  }
833
786
  const current = children.get(id);
834
- restarting = true;
835
787
  if (current) {
836
- stopChildProcess(current);
837
- await delay(350);
788
+ await stopManagedProcess(current, "SIGTERM", killProcess, Math.min(shutdownGraceMs, 500));
838
789
  }
839
790
  children.delete(id);
840
791
  exited.delete(id);
841
- restarting = false;
792
+ if (settled) {
793
+ return;
794
+ }
842
795
  spawnCommand(command);
843
796
  emitEvent(options, write, { type: "restart", surface: id, message: `Restarted ${command.label}.` });
844
797
  }
798
+ function startLiveWatch() {
799
+ if (watchController || plan.watchEntries.length === 0 || plan.feedbackMode === "off" || settled) {
800
+ return;
801
+ }
802
+ watchController = startWatch({
803
+ watchEntries: plan.watchEntries,
804
+ onChange: async (change) => {
805
+ if (settled) {
806
+ return;
807
+ }
808
+ if (restartInProgress) {
809
+ watchController?.rebaseline();
810
+ return;
811
+ }
812
+ restartInProgress = true;
813
+ try {
814
+ emitEvent(options, write, {
815
+ type: "restart",
816
+ message: `Detected ${change.changedPaths.length} development change${change.changedPaths.length === 1 ? "" : "s"}.`,
817
+ detail: {
818
+ tenantChanged: change.tenantChanged,
819
+ tenantApiChanged: change.tenantApiChanged,
820
+ packageChanged: change.packageChanged,
821
+ sdkChanged: change.sdkChanged
822
+ }
823
+ });
824
+ if (change.packageChanged || change.sdkChanged) {
825
+ await Promise.all([
826
+ restartCommand("api"),
827
+ restartCommand("manager"),
828
+ restartCommand("worker")
829
+ ]);
830
+ } else if (change.tenantApiChanged) {
831
+ await restartCommand("api");
832
+ }
833
+ if (plan.feedbackMode === "live") {
834
+ writeDevReloadStamp(plan.tenantRoot);
835
+ emitEvent(options, write, { type: "reload", message: "Wrote browser reload stamp." });
836
+ }
837
+ } finally {
838
+ watchController?.rebaseline();
839
+ restartInProgress = false;
840
+ }
841
+ }
842
+ });
843
+ watchController.rebaseline();
844
+ }
845
845
  async function waitForReadiness() {
846
846
  const readinessTimeoutMs = options.readinessTimeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS;
847
847
  const processReadyGraceMs = options.processReadyGraceMs ?? DEFAULT_PROCESS_READY_GRACE_MS;
@@ -854,7 +854,7 @@ async function runTreeseedIntegratedDev(options = {}, deps = {}) {
854
854
  ready = await waitForHttpReady(fetchFn, check.url, readinessTimeoutMs);
855
855
  } else {
856
856
  await delay(processReadyGraceMs);
857
- ready = !exited.has(check.id);
857
+ ready = !exited.has(check.id) && children.has(check.id);
858
858
  }
859
859
  if (settled) {
860
860
  return;
@@ -866,7 +866,7 @@ async function runTreeseedIntegratedDev(options = {}, deps = {}) {
866
866
  url: check.url,
867
867
  message: `${check.label} did not become ready${check.url ? ` at ${check.url}` : ""}.`
868
868
  });
869
- finalize(1, check.id);
869
+ finalize(1);
870
870
  return;
871
871
  }
872
872
  emitEvent(options, write, {
@@ -877,6 +877,7 @@ async function runTreeseedIntegratedDev(options = {}, deps = {}) {
877
877
  message: `${check.label} is ${ready ? "ready" : "degraded"}.`
878
878
  });
879
879
  }
880
+ readinessComplete = true;
880
881
  if (plan.webUrl) {
881
882
  emitEvent(options, write, { type: "ready", url: plan.webUrl, message: `Treeseed dev ready at ${plan.webUrl}.` });
882
883
  }
@@ -894,43 +895,19 @@ async function runTreeseedIntegratedDev(options = {}, deps = {}) {
894
895
  });
895
896
  }
896
897
  }
898
+ startLiveWatch();
897
899
  }
898
900
  for (const command of plan.commands) {
899
901
  spawnCommand(command);
900
902
  }
901
- if (plan.watchEntries.length > 0 && plan.feedbackMode !== "off") {
902
- stopWatching = startWatch({
903
- watchEntries: plan.watchEntries,
904
- onChange: async (change) => {
905
- if (settled) {
906
- return;
907
- }
908
- emitEvent(options, write, {
909
- type: "restart",
910
- message: `Detected ${change.changedPaths.length} change${change.changedPaths.length === 1 ? "" : "s"}.`,
911
- detail: {
912
- tenantChanged: change.tenantChanged,
913
- packageChanged: change.packageChanged,
914
- sdkChanged: change.sdkChanged
915
- }
916
- });
917
- if (change.packageChanged || change.sdkChanged) {
918
- await Promise.all([
919
- restartCommand("api"),
920
- restartCommand("manager"),
921
- restartCommand("worker")
922
- ]);
923
- } else if (change.tenantChanged) {
924
- await restartCommand("api");
925
- }
926
- if (plan.feedbackMode === "live") {
927
- writeDevReloadStamp(plan.tenantRoot);
928
- emitEvent(options, write, { type: "reload", message: "Wrote browser reload stamp." });
929
- }
930
- }
903
+ void waitForReadiness().catch((error) => {
904
+ emitEvent(options, write, {
905
+ type: "error",
906
+ message: "Dev readiness failed.",
907
+ detail: error instanceof Error ? error.message : String(error)
931
908
  });
932
- }
933
- void waitForReadiness();
909
+ finalize(1);
910
+ });
934
911
  });
935
912
  }
936
913
  export {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@treeseed/core",
3
- "version": "0.6.16",
3
+ "version": "0.6.17",
4
4
  "description": "Treeseed integrated platform starter for Astro/Starlight web runtimes and Hono API runtimes.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {
@@ -76,7 +76,7 @@
76
76
  "@astrojs/sitemap": "3.7.0",
77
77
  "@astrojs/starlight": "0.37.6",
78
78
  "@tailwindcss/vite": "^4.1.4",
79
- "@treeseed/sdk": "0.6.14",
79
+ "@treeseed/sdk": "0.6.15",
80
80
  "astro": "^5.6.1",
81
81
  "esbuild": "^0.28.0",
82
82
  "hono": "^4.8.2",