@treeseed/core 0.6.16 → 0.6.18

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,46 @@ 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 (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
+ }
767
+ const status = exitCode === 0 ? "idle" : "degraded";
817
768
  emitEvent(options, write, {
818
- type: exitCode === 0 ? "shutdown" : "error",
769
+ type: "error",
819
770
  surface: command.id,
820
771
  exitCode,
821
772
  signal,
822
- message: `${command.label} exited with ${signal ?? exitCode}.`
823
- }, exitCode === 0 ? "stdout" : "stderr");
824
- finalize(exitCode, command.id);
773
+ status,
774
+ message: readinessComplete ? `${command.label} exited with ${signal ?? exitCode}; continuing because it is not a required surface.` : `${command.label} exited during startup with ${signal ?? exitCode}; continuing because it is not a required surface.`
775
+ }, status === "idle" ? "stdout" : "stderr");
776
+ void stopManagedProcess(managed, "SIGTERM", killProcess, 0).finally(() => {
777
+ children.delete(command.id);
778
+ });
825
779
  });
826
780
  return child;
827
781
  }
@@ -831,17 +785,64 @@ async function runTreeseedIntegratedDev(options = {}, deps = {}) {
831
785
  return;
832
786
  }
833
787
  const current = children.get(id);
834
- restarting = true;
835
788
  if (current) {
836
- stopChildProcess(current);
837
- await delay(350);
789
+ await stopManagedProcess(current, "SIGTERM", killProcess, Math.min(shutdownGraceMs, 500));
838
790
  }
839
791
  children.delete(id);
840
792
  exited.delete(id);
841
- restarting = false;
793
+ if (settled) {
794
+ return;
795
+ }
842
796
  spawnCommand(command);
843
797
  emitEvent(options, write, { type: "restart", surface: id, message: `Restarted ${command.label}.` });
844
798
  }
799
+ function startLiveWatch() {
800
+ if (watchController || plan.watchEntries.length === 0 || plan.feedbackMode === "off" || settled) {
801
+ return;
802
+ }
803
+ watchController = startWatch({
804
+ watchEntries: plan.watchEntries,
805
+ onChange: async (change) => {
806
+ if (settled) {
807
+ return;
808
+ }
809
+ if (restartInProgress) {
810
+ watchController?.rebaseline();
811
+ return;
812
+ }
813
+ restartInProgress = true;
814
+ try {
815
+ emitEvent(options, write, {
816
+ type: "restart",
817
+ message: `Detected ${change.changedPaths.length} development change${change.changedPaths.length === 1 ? "" : "s"}.`,
818
+ detail: {
819
+ tenantChanged: change.tenantChanged,
820
+ tenantApiChanged: change.tenantApiChanged,
821
+ packageChanged: change.packageChanged,
822
+ sdkChanged: change.sdkChanged
823
+ }
824
+ });
825
+ if (change.packageChanged || change.sdkChanged) {
826
+ await Promise.all([
827
+ restartCommand("api"),
828
+ restartCommand("manager"),
829
+ restartCommand("worker")
830
+ ]);
831
+ } else if (change.tenantApiChanged) {
832
+ await restartCommand("api");
833
+ }
834
+ if (plan.feedbackMode === "live") {
835
+ writeDevReloadStamp(plan.tenantRoot);
836
+ emitEvent(options, write, { type: "reload", message: "Wrote browser reload stamp." });
837
+ }
838
+ } finally {
839
+ watchController?.rebaseline();
840
+ restartInProgress = false;
841
+ }
842
+ }
843
+ });
844
+ watchController.rebaseline();
845
+ }
845
846
  async function waitForReadiness() {
846
847
  const readinessTimeoutMs = options.readinessTimeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS;
847
848
  const processReadyGraceMs = options.processReadyGraceMs ?? DEFAULT_PROCESS_READY_GRACE_MS;
@@ -854,7 +855,7 @@ async function runTreeseedIntegratedDev(options = {}, deps = {}) {
854
855
  ready = await waitForHttpReady(fetchFn, check.url, readinessTimeoutMs);
855
856
  } else {
856
857
  await delay(processReadyGraceMs);
857
- ready = !exited.has(check.id);
858
+ ready = !exited.has(check.id) && children.has(check.id);
858
859
  }
859
860
  if (settled) {
860
861
  return;
@@ -866,7 +867,7 @@ async function runTreeseedIntegratedDev(options = {}, deps = {}) {
866
867
  url: check.url,
867
868
  message: `${check.label} did not become ready${check.url ? ` at ${check.url}` : ""}.`
868
869
  });
869
- finalize(1, check.id);
870
+ finalize(1);
870
871
  return;
871
872
  }
872
873
  emitEvent(options, write, {
@@ -877,6 +878,7 @@ async function runTreeseedIntegratedDev(options = {}, deps = {}) {
877
878
  message: `${check.label} is ${ready ? "ready" : "degraded"}.`
878
879
  });
879
880
  }
881
+ readinessComplete = true;
880
882
  if (plan.webUrl) {
881
883
  emitEvent(options, write, { type: "ready", url: plan.webUrl, message: `Treeseed dev ready at ${plan.webUrl}.` });
882
884
  }
@@ -894,43 +896,19 @@ async function runTreeseedIntegratedDev(options = {}, deps = {}) {
894
896
  });
895
897
  }
896
898
  }
899
+ startLiveWatch();
897
900
  }
898
901
  for (const command of plan.commands) {
899
902
  spawnCommand(command);
900
903
  }
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
- }
904
+ void waitForReadiness().catch((error) => {
905
+ emitEvent(options, write, {
906
+ type: "error",
907
+ message: "Dev readiness failed.",
908
+ detail: error instanceof Error ? error.message : String(error)
931
909
  });
932
- }
933
- void waitForReadiness();
910
+ finalize(1);
911
+ });
934
912
  });
935
913
  }
936
914
  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.18",
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.16",
80
80
  "astro": "^5.6.1",
81
81
  "esbuild": "^0.28.0",
82
82
  "hono": "^4.8.2",