@noego/app 0.0.11 → 0.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -12,37 +12,43 @@ Objective
12
12
 
13
13
  Entry Points
14
14
 
15
- - Server entry: `noblelaw/index.ts` (spawns/hosts Express + @noego/dinner API + @noego/forge SSR UI)
16
- - Page entry: `noblelaw/ui/index.html` (loads `noblelaw/ui/client.ts` to hydrate in browser)
15
+ - Server entry: `{project}/index.ts` (spawns/hosts Express + @noego/dinner API + @noego/forge SSR UI)
16
+ - Page entry: `{project}/ui/index.html` (loads `{project}/ui/client.ts` to hydrate in browser)
17
17
 
18
18
  Repository Roots and Path Referencing
19
19
 
20
- - Unless explicitly prefixed, relative paths in this document refer to the application project root (`--root`), which in our environment is `noblelaw` at `/Users/shavauhngabay/dev/noblelaw`.
20
+ - Unless explicitly prefixed, relative paths in this document refer to the application project root (`--root`).
21
+ - Available website projects:
22
+ - bashly: `/Users/shavauhngabay/dev/noego_manager/websites/bashly`
23
+ - mindful_essence: `/Users/shavauhngabay/dev/noego_manager/websites/mindful_essence`
24
+ - noego_landing: `/Users/shavauhngabay/dev/noego_manager/websites/noego_landing`
25
+ - {project}: `/Users/shavauhngabay/dev/noego_manager/websites/{project}`
21
26
  - Source checkouts for frameworks/tools:
22
- - Forge: `/Users/shavauhngabay/dev/ego/forge`
23
- - Dinner: `/Users/shavauhngabay/dev/noego/dinner`
24
- - Sqlstack: `/Users/shavauhngabay/dev/ego/sqlstack`
25
- - App (this CLI): `/Users/shavauhngabay/dev/app`
26
- - Example dist outputs in this doc are under the application project: `noblelaw/dist/...`.
27
+ - Forge: `/Users/shavauhngabay/dev/noego_manager/noego/forge`
28
+ - Dinner: `/Users/shavauhngabay/dev/noego_manager/noego/dinner`
29
+ - Sqlstack: `/Users/shavauhngabay/dev/noego_manager/noego/sqlstack`
30
+ - App (this CLI): `/Users/shavauhngabay/dev/noego_manager/noego/app`
31
+ - Example dist outputs in this doc are under the application project: `{project}/dist/...`.
32
+ - In examples below, `{project}` refers to any of the website projects listed above.
27
33
 
28
34
  Current Architecture
29
35
 
30
36
  - API
31
- - @noego/dinner (source: `/Users/shavauhngabay/dev/noego/dinner`) loads OpenAPI (default noblelaw uses `noblelaw/server/stitch.yaml`), dynamically wires controllers (`noblelaw/server/controller/**`) and middleware (`noblelaw/middleware/**`) based on configured base dirs
37
+ - @noego/dinner (source: `/Users/shavauhngabay/dev/noego_manager/noego/dinner`) loads OpenAPI (uses `{project}/server/stitch.yaml`), dynamically wires controllers (`{project}/server/controller/**`) and middleware (`{project}/middleware/**`) based on configured base dirs
32
38
  - Paths are resolved with process.cwd() and are runtime file-based; both controllers base and middleware base are configurable
33
39
  - UI SSR
34
- - @noego/forge/server (source: `/Users/shavauhngabay/dev/ego/forge`) uses options from `noblelaw/ui/options.ts`
40
+ - @noego/forge/server (source: `/Users/shavauhngabay/dev/noego_manager/noego/forge`) uses options from `{project}/ui/options.ts`
35
41
  - In dev, Vite middleware compiles .svelte on demand
36
42
  - In prod, must be precompiled (no loaders)
37
43
  - Client
38
- - Hydration bootstrap at `noblelaw/ui/client.ts`, page template `noblelaw/ui/index.html`
44
+ - Hydration bootstrap at `{project}/ui/client.ts`, page template `{project}/ui/index.html`
39
45
  - DB
40
- - Repositories (server/repo/**) use sqlstack decorators (source: `/Users/shavauhngabay/dev/ego/sqlstack`)
46
+ - Repositories (server/repo/**) use sqlstack decorators (source: `/Users/shavauhngabay/dev/noego_manager/noego/sqlstack`)
41
47
  - sqlstack reads .sql from filesystem at runtime, adjacent to transpiled repo JS
42
48
  - Tests/CI
43
49
  - Jest + Python runner; not directly relevant to build targets, but artifacts must be runnable in CI
44
50
  - Config files
45
- - `noblelaw/tsconfig.json`, `noblelaw/vite.config.js`, `noblelaw/proper.json`, `noblelaw/server/stitch.yaml`, `noblelaw/ui/stitch.yaml`
51
+ - `{project}/tsconfig.json`, `{project}/vite.config.js`, `{project}/proper.json`, `{project}/server/stitch.yaml`, `{project}/ui/stitch.yaml`
46
52
 
47
53
  Constraints and Implications
48
54
 
@@ -60,7 +66,7 @@ Repository Roots and Path Referencing
60
66
  CLI Contract
61
67
 
62
68
  - Command: app build --server index.ts --page ui/index.html
63
- - Options (all optional; default values assume noblelaw layout)
69
+ - Options (all optional; default values assume {project} layout)
64
70
  - --root . project root
65
71
  - --out dist output directory root
66
72
  - --server-root . where to resolve server entry from (default --root)
@@ -138,24 +144,24 @@ YAML Configuration (Preferred)
138
144
  - While every option is available via CLI, the preferred way to configure App is a YAML config file committed to the repo. This provides a single, reviewable source of truth. CLI flags remain available for ad‑hoc overrides.
139
145
 
140
146
  - Location and discovery
141
- - Default filenames searched at the application root (`noblelaw`): `app.yaml`, `app.yml`, `app.config.yaml`, `app.config.yml`, or `app.config.json`.
147
+ - Default filenames searched at the application root (`{project}`): `app.yaml`, `app.yml`, `app.config.yaml`, `app.config.yml`, or `app.config.json`.
142
148
  - A future `--config <path>` CLI flag may explicitly point to a YAML file. Paths inside the YAML resolve relative to the YAML file itself unless absolute.
143
149
 
144
150
  - Schema (high‑level)
145
151
  - Server
146
- - `entry`: e.g., `noblelaw/index.ts`
147
- - `rootDir`: e.g., `noblelaw`
148
- - `controllers`: e.g., `noblelaw/server/controller`
149
- - `middleware`: e.g., `noblelaw/middleware`
150
- - `openapi`: e.g., `noblelaw/server/stitch.yaml`
151
- - `sqlGlobs`: list of globs, e.g., `['noblelaw/server/repo/**/*.sql']`
152
+ - `entry`: e.g., `{project}/index.ts`
153
+ - `rootDir`: e.g., `{project}`
154
+ - `controllers`: e.g., `{project}/server/controller`
155
+ - `middleware`: e.g., `{project}/middleware`
156
+ - `openapi`: e.g., `{project}/server/stitch.yaml`
157
+ - `sqlGlobs`: list of globs, e.g., `['{project}/server/repo/**/*.sql']`
152
158
  - UI
153
- - `page`: e.g., `noblelaw/ui/index.html`
154
- - `options`: e.g., `noblelaw/ui/options.ts` (Forge server options entry)
155
- - `rootDir`: e.g., `noblelaw/ui`
156
- - `openapi`: e.g., `noblelaw/ui/stitch.yaml`
157
- - `assets`: list of globs, e.g., `['noblelaw/ui/resources/**']`
158
- - `clientExclude`: list of globs for CSR exclusions, e.g., `['noblelaw/server/**', 'noblelaw/middleware/**']`
159
+ - `page`: e.g., `{project}/ui/index.html`
160
+ - `options`: e.g., `{project}/ui/options.ts` (Forge server options entry)
161
+ - `rootDir`: e.g., `{project}/ui`
162
+ - `openapi`: e.g., `{project}/ui/stitch.yaml`
163
+ - `assets`: list of globs, e.g., `['{project}/ui/resources/**']`
164
+ - `clientExclude`: list of globs for CSR exclusions, e.g., `['{project}/server/**', '{project}/middleware/**']`
159
165
  - Dev
160
166
  - `watch`: boolean
161
167
  - `watchPaths`: list of globs (preferred)
@@ -169,51 +175,51 @@ YAML Configuration (Preferred)
169
175
  - `configFile`: path to a Vite config for SSR build
170
176
  - `override`: object or path to a JSON file with deep‑merge overrides
171
177
  - Output
172
- - `outDir`: e.g., `noblelaw/dist`
178
+ - `outDir`: e.g., `{project}/dist`
173
179
 
174
180
  - Referencing JSON files
175
181
  - Any `override` field under `vite.client` or `vite.ssr` may be either an inline object or a string path to a JSON file. App will load JSON and deep‑merge it into the Vite config (after the file’s own `configFile` is applied, before enforcing invariants like `outDir`).
176
182
  - This pattern can be extended to other future sections where JSON is a convenient format.
177
183
 
178
- - Example: `noblelaw/app.yaml`
184
+ - Example: `{project}/app.yaml`
179
185
  - server:
180
- entry: noblelaw/index.ts
181
- rootDir: noblelaw
182
- controllers: noblelaw/server/controller
183
- middleware: noblelaw/middleware
184
- openapi: noblelaw/server/stitch.yaml
186
+ entry: {project}/index.ts
187
+ rootDir: {project}
188
+ controllers: {project}/server/controller
189
+ middleware: {project}/middleware
190
+ openapi: {project}/server/stitch.yaml
185
191
  sqlGlobs:
186
- - noblelaw/server/repo/**/*.sql
192
+ - {project}/server/repo/**/*.sql
187
193
  - ui:
188
- page: noblelaw/ui/index.html
189
- options: noblelaw/ui/options.ts
190
- rootDir: noblelaw/ui
191
- openapi: noblelaw/ui/stitch.yaml
194
+ page: {project}/ui/index.html
195
+ options: {project}/ui/options.ts
196
+ rootDir: {project}/ui
197
+ openapi: {project}/ui/stitch.yaml
192
198
  assets:
193
- - noblelaw/ui/resources/**
194
- - noblelaw/ui/styles/**
199
+ - {project}/ui/resources/**
200
+ - {project}/ui/styles/**
195
201
  clientExclude:
196
- - noblelaw/server/**
197
- - noblelaw/middleware/**
202
+ - {project}/server/**
203
+ - {project}/middleware/**
198
204
  - dev:
199
205
  watch: true
200
206
  watchPaths:
201
- - noblelaw/server/**/*.ts
202
- - noblelaw/ui/openapi/**/*.yaml
207
+ - {project}/server/**/*.ts
208
+ - {project}/ui/openapi/**/*.yaml
203
209
  splitServe: true
204
210
  frontendCmd: vite
205
211
  - vite:
206
212
  client:
207
- configFile: noblelaw/vite.config.js
208
- override: noblelaw/vite.client.override.json
213
+ configFile: {project}/vite.config.js
214
+ override: {project}/vite.client.override.json
209
215
  ssr:
210
- configFile: noblelaw/vite.config.js
216
+ configFile: {project}/vite.config.js
211
217
  override:
212
218
  build:
213
219
  ssr: true
214
220
  rollupOptions:
215
221
  preserveModules: true
216
- - outDir: noblelaw/dist
222
+ - outDir: {project}/dist
217
223
 
218
224
  - Precedence and merging
219
225
  - Defaults → YAML → CLI: App starts from sane defaults, applies YAML config, then applies CLI flags as the final layer. Required invariants for production builds still apply (e.g., outDir, manifest, SSR flags), unless explicitly documented otherwise.
@@ -224,21 +230,21 @@ OpenAPI‑Driven Discovery (Build/Serve Optimization)
224
230
  - The OpenAPI specs (server and UI stitch YAML) already declare every API route, controller reference, middleware reference, and UI view/layout. App can parse these ahead of time to optimize build and reduce required options.
225
231
 
226
232
  - What App derives
227
- - Server OpenAPI (`noblelaw/server/stitch.yaml`):
233
+ - Server OpenAPI (`{project}/server/stitch.yaml`):
228
234
  - All controller identifiers from `x-controller` across routes.
229
235
  - All middleware identifiers from `x-middleware`.
230
- - UI OpenAPI (`noblelaw/ui/stitch.yaml`):
236
+ - UI OpenAPI (`{project}/ui/stitch.yaml`):
231
237
  - All pages, layouts, and component paths that need SSR precompilation.
232
238
 
233
239
  - How we use it
234
240
  - SSR entry set: feed the exact list of Svelte components (views/layouts) into the SSR Vite build, avoiding filesystem globs.
235
- - Validation: after transpile/copy, verify all referenced controllers/middleware resolve under `noblelaw/dist` and fail fast with actionable errors.
241
+ - Validation: after transpile/copy, verify all referenced controllers/middleware resolve under `{project}/dist` and fail fast with actionable errors.
236
242
  - Manifest: emit a debug manifest including discovered controllers, middleware, and SSR components for troubleshooting.
237
243
 
238
244
  - Option minimization (sane defaults with override)
239
- - If `--controllers` or `--middleware` are omitted, App attempts to infer base directories by computing the longest common directory prefix from discovered identifiers. Example: if `x-controller` uses `controllers/*.controller`, infer `noblelaw/server/controller`.
240
- - If inference fails or is ambiguous, App falls back to NobleLaw defaults and warns. Users retain full control by setting flags explicitly.
241
- - `--ui-openapi` remains required for SSR discovery unless the project embeds its location in code; App defaults to `noblelaw/ui/stitch.yaml`.
245
+ - If `--controllers` or `--middleware` are omitted, App attempts to infer base directories by computing the longest common directory prefix from discovered identifiers. Example: if `x-controller` uses `controllers/*.controller`, infer `{project}/server/controller`.
246
+ - If inference fails or is ambiguous, App falls back to {project} defaults and warns. Users retain full control by setting flags explicitly.
247
+ - `--ui-openapi` remains required for SSR discovery unless the project embeds its location in code; App defaults to `{project}/ui/stitch.yaml`.
242
248
 
243
249
  - Serve improvements
244
250
  - During `app serve`, App may parse OpenAPI once at startup to log missing controllers/middleware early, while still delegating runtime wiring to Dinner/Forge.
@@ -250,16 +256,16 @@ OpenAPI‑Driven Discovery (Build/Serve Optimization)
250
256
  - Treat CLI values as relative to --root unless absolute
251
257
  - --page determines --ui-root when not provided (dirname of --page)
252
258
  - Output layout (example)
253
- - `noblelaw/dist/server/**` transpiled JS, mirrored structure of `noblelaw/server/**` and `noblelaw/middleware/**`, plus copied `.sql` and `noblelaw/server/openapi/**`, `noblelaw/server/stitch.yaml`
254
- - `noblelaw/dist/client/**` bundled CSR assets + `manifest.json`
255
- - `noblelaw/dist/ssr/**` SSR bundle/modules + `ssr-manifest.json` (or embed SSR output under `noblelaw/dist/server/ssr/**`)
259
+ - `{project}/dist/server/**` transpiled JS, mirrored structure of `{project}/server/**` and `{project}/middleware/**`, plus copied `.sql` and `{project}/server/openapi/**`, `{project}/server/stitch.yaml`
260
+ - `{project}/dist/client/**` bundled CSR assets + `manifest.json`
261
+ - `{project}/dist/ssr/**` SSR bundle/modules + `ssr-manifest.json` (or embed SSR output under `{project}/dist/server/ssr/**`)
256
262
  - Runtime path resolution
257
263
  - All server code that used process.cwd() should continue to work if server is launched with cwd at dist root OR the dist structure mirrors
258
264
  source-tree paths
259
265
  - App must ensure:
260
- - Dinner’s `openapi_path` points to a real YAML under `noblelaw/dist/server/...`
261
- - Forge’s `open_api_path` resolves correctly under `noblelaw/dist` (either copy UI YAML or generate a stitched snapshot consumed by SSR)
262
- - sqlstack finds `.sql` files next to the transpiled repo JS under `noblelaw/dist/server/repo/**`
266
+ - Dinner’s `openapi_path` points to a real YAML under `{project}/dist/server/...`
267
+ - Forge’s `open_api_path` resolves correctly under `{project}/dist` (either copy UI YAML or generate a stitched snapshot consumed by SSR)
268
+ - sqlstack finds `.sql` files next to the transpiled repo JS under `{project}/dist/server/repo/**`
263
269
  - If any path cannot be made to resolve from cwd reliably, App provides a tiny generated bootstrap wrapper that rewrites those path
264
270
  strings to __dirname-based equivalents at build time (no change to public interfaces)
265
271
 
@@ -285,8 +291,8 @@ Path Customization and Framework Mapping
285
291
  - Root directory for SQL resolution is inferred from decorator location or explicitly passed via @Query(). No bundling or inlining — files must exist at runtime.
286
292
 
287
293
  - App CLI flags → runtime mapping
288
- - --controllers: the source directory to mirror into dist for runtime controller discovery. Must correspond to the app’s controllers_base_path at runtime. Defaults to server/controller for NobleLaw, but any path is valid.
289
- - --middleware: the source directory to mirror into dist for runtime middleware discovery. Must correspond to the app’s middleware_path (or controllers_base_path fallback). Defaults to middleware for NobleLaw.
294
+ - --controllers: the source directory to mirror into dist for runtime controller discovery. Must correspond to the app’s controllers_base_path at runtime. Defaults to server/controller for {project}, but any path is valid.
295
+ - --middleware: the source directory to mirror into dist for runtime middleware discovery. Must correspond to the app’s middleware_path (or controllers_base_path fallback). Defaults to middleware for {project}.
290
296
  - --openapi / --ui-openapi: OpenAPI stitch YAML locations for API and UI. App copies these into dist/server and dist/ui respectively.
291
297
  - --sql-glob: one or more glob patterns for SQL files to copy next to compiled JS outputs. Use this to support custom repo locations.
292
298
  - --page / --ui-root: determines client entry and build root used by Vite; paths are normalized relative to --root unless absolute.
@@ -332,7 +338,7 @@ Client Bundle Exclusions
332
338
  - Prevent accidental inclusion of server-only code in the browser bundle while allowing projects to keep shared TypeScript without extensions.
333
339
 
334
340
  - CLI
335
- - `--client-exclude <glob>`: may be specified multiple times. Paths are resolved relative to `--root` unless absolute. Example defaults for NobleLaw: `server/**`, `middleware/**`.
341
+ - `--client-exclude <glob>`: may be specified multiple times. Paths are resolved relative to `--root` unless absolute. Example defaults for {project}: `server/**`, `middleware/**`.
336
342
 
337
343
  - Behavior
338
344
  - During the Vite/Rollup client build, App installs a plugin that intercepts resolved module IDs. If the absolute file path matches any exclusion pattern, the loader returns a virtual stub module instead of the real source.
@@ -371,7 +377,7 @@ Non‑Default Layout Examples
371
377
  - `--watch` enables restart on server‑side changes
372
378
  - `--watch-path <glob>` adds additional watch patterns (repeatable). Preferred: use globs to cover directories rather than listing many files.
373
379
  - Defaults watch: controllers, middleware, server OpenAPI (and openapi/**), UI OpenAPI (and openapi/**), and SQL globs
374
- - UI Svelte/TS/CSS/HTML under `noblelaw/ui/**` are excluded from restart and continue to reload via Vite HMR
380
+ - UI Svelte/TS/CSS/HTML under `{project}/ui/**` are excluded from restart and continue to reload via Vite HMR
375
381
  - Optional split processes in watch mode:
376
382
  - `--split-serve` starts the frontend dev server (vite) as a separate process so API restarts don’t disrupt HMR
377
383
  - `--frontend-cmd vite` selects the frontend runner (currently only vite supported)
@@ -412,9 +418,9 @@ Non‑Default Layout Examples
412
418
  Acceptance Criteria
413
419
 
414
420
  - Running app build --server index.ts --page ui/index.html produces:
415
- - `noblelaw/dist/server` transpiled ESM with mirrored structure and copied `.sql`, OpenAPI YAML
416
- - `noblelaw/dist/client` minified assets + client manifest
417
- - `noblelaw/dist/ssr` precompiled SSR output + SSR manifest (or nested under `noblelaw/dist/server/ssr`)
421
+ - `{project}/dist/server` transpiled ESM with mirrored structure and copied `.sql`, OpenAPI YAML
422
+ - `{project}/dist/client` minified assets + client manifest
423
+ - `{project}/dist/ssr` precompiled SSR output + SSR manifest (or nested under `{project}/dist/server/ssr`)
418
424
  - Starting node dist/server/index.js:
419
425
  - API endpoints function unchanged
420
426
  - GET / returns SSR HTML; hydration works
@@ -433,7 +439,7 @@ Non‑Default Layout Examples
433
439
  - Record both source and output absolute paths inside a build manifest for troubleshooting
434
440
  - If the app is started from outside dist, require cwd to be dist (documented), or generate a wrapper that sets process.chdir(__dirname +
435
441
  '/../') at startup
436
- - Defaults for noblelaw
442
+ - Defaults for {project}
437
443
  - --root .
438
444
  - --server index.ts
439
445
  - --page ui/index.html
@@ -448,10 +454,13 @@ Non‑Default Layout Examples
448
454
 
449
455
 
450
456
  Framework Reference
451
- `/Users/shavauhngabay/dev/ego/forge`
452
- `/Users/shavauhngabay/dev/noego/dinner`
453
- `/Users/shavauhngabay/dev/ego/sqlstack`
457
+ `/Users/shavauhngabay/dev/noego_manager/noego/forge`
458
+ `/Users/shavauhngabay/dev/noego_manager/noego/dinner`
459
+ `/Users/shavauhngabay/dev/noego_manager/noego/sqlstack`
454
460
 
455
461
 
456
- Project Reference
457
- `/Users/shavauhngabay/dev/noblelaw`
462
+ Project References
463
+ `/Users/shavauhngabay/dev/noego_manager/websites/bashly`
464
+ `/Users/shavauhngabay/dev/noego_manager/websites/mindful_essence`
465
+ `/Users/shavauhngabay/dev/noego_manager/websites/noego_landing`
466
+ `/Users/shavauhngabay/dev/noego_manager/websites/noblelaw`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noego/app",
3
- "version": "0.0.11",
3
+ "version": "0.0.13",
4
4
  "description": "Production build tool for Dinner/Forge apps.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -33,6 +33,7 @@
33
33
  "glob-parent": "^6.0.2",
34
34
  "http-proxy": "^1.18.1",
35
35
  "picomatch": "^2.3.1",
36
+ "rxjs": "^7.8.1",
36
37
  "yaml": "^2.6.0"
37
38
  },
38
39
  "peerDependencies": {
@@ -5,6 +5,11 @@ import { createBuildContext } from '../build/context.js';
5
5
  import { findConfigFile } from '../runtime/index.js';
6
6
  import { loadConfig } from '../runtime/config-loader.js';
7
7
  import globParent from 'glob-parent';
8
+ import { Subject, concat, of, EMPTY } from 'rxjs';
9
+ import { debounceTime, filter, exhaustMap, tap, catchError, takeUntil, finalize, map, switchMap } from 'rxjs/operators';
10
+ import { waitForPortFree } from '../utils/port.js';
11
+ import { stopProcess, killProcessTree as killProcessTreeUtil } from '../utils/process-observable.js';
12
+ import { watcherToObservable, FileEventType } from '../utils/file-watcher-observable.js';
8
13
 
9
14
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
15
 
@@ -444,61 +449,191 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
444
449
 
445
450
  let backendProc = null;
446
451
  let frontendProc = null;
447
- let pending = false;
448
452
  let backendRestartCount = 0;
449
453
  let frontendRestartCount = 0;
450
454
  let isShuttingDown = false;
451
455
 
456
+ // RxJS subjects for managing restart streams
457
+ const shutdownSubject = new Subject();
458
+ const backendRestartSubject = new Subject();
459
+ const frontendRestartSubject = new Subject();
460
+ const subscriptions = [];
461
+
462
+ // Crash restart tracking
463
+ const MAX_CRASH_RESTARTS = 3;
464
+ const CRASH_RESTART_DELAY = 2000; // 2 seconds between crash restarts
465
+ const STABILITY_THRESHOLD = 30000; // Reset crash counter if running 30+ seconds
466
+ let backendCrashRestarts = 0;
467
+ let frontendCrashRestarts = 0;
468
+ let backendStartTime = 0;
469
+ let frontendStartTime = 0;
470
+
452
471
  const startBackend = () => {
453
472
  backendRestartCount++;
473
+ backendStartTime = Date.now();
454
474
  logger.info(`🚀 [RESTART #${backendRestartCount}] Starting backend on port ${backendPort}...`);
455
475
  const backendEnv = {
456
476
  ...baseEnv,
457
477
  NOEGO_SERVICE: 'backend',
458
478
  NOEGO_PORT: String(backendPort)
459
479
  };
460
-
480
+
461
481
  backendProc = spawn(tsxExecutable, tsxArgs, {
462
482
  cwd: context.config.rootDir,
463
483
  env: backendEnv,
464
484
  stdio: 'inherit',
465
485
  detached: false
466
486
  });
467
-
468
- backendProc.on('exit', (code) => {
487
+
488
+ backendProc.on('exit', (code, signal) => {
489
+ if (isShuttingDown) return;
490
+
491
+ // Check if process was stable (ran for a while) - reset crash counter
492
+ const runDuration = Date.now() - backendStartTime;
493
+ if (runDuration > STABILITY_THRESHOLD) {
494
+ backendCrashRestarts = 0;
495
+ }
496
+
469
497
  if (code !== null && code !== 0) {
470
- logger.error(`Backend exited with code ${code}`);
498
+ logger.error(`[BACKEND] Exited with code ${code}, signal ${signal}`);
499
+
500
+ // Auto-restart on crash if under limit
501
+ if (backendCrashRestarts < MAX_CRASH_RESTARTS) {
502
+ backendCrashRestarts++;
503
+ logger.warn(`[BACKEND] Crash detected. Auto-restart ${backendCrashRestarts}/${MAX_CRASH_RESTARTS} in ${CRASH_RESTART_DELAY}ms...`);
504
+ setTimeout(() => {
505
+ if (!isShuttingDown) {
506
+ startBackend();
507
+ }
508
+ }, CRASH_RESTART_DELAY);
509
+ } else {
510
+ logger.error(`[BACKEND] Exceeded max crash restarts (${MAX_CRASH_RESTARTS}). Shutting down...`);
511
+ shutdown('backend-exceeded-restarts', 1, 'backend-crash');
512
+ }
471
513
  }
472
514
  });
515
+
516
+ backendProc.on('error', (err) => {
517
+ logger.error(`[BACKEND] Spawn error: ${err.message}`);
518
+ });
473
519
  };
474
-
520
+
475
521
  const startFrontend = () => {
476
522
  frontendRestartCount++;
523
+ frontendStartTime = Date.now();
477
524
  logger.info(`🚀 [RESTART #${frontendRestartCount}] Starting frontend on port ${frontendPort}...`);
478
525
  const frontendEnv = {
479
526
  ...baseEnv,
480
527
  NOEGO_SERVICE: 'frontend',
481
528
  NOEGO_PORT: String(frontendPort)
482
529
  };
483
-
530
+
484
531
  frontendProc = spawn(tsxExecutable, tsxArgs, {
485
532
  cwd: context.config.rootDir,
486
533
  env: frontendEnv,
487
534
  stdio: 'inherit',
488
535
  detached: false
489
536
  });
490
-
491
- frontendProc.on('exit', (code) => {
537
+
538
+ frontendProc.on('exit', (code, signal) => {
539
+ if (isShuttingDown) return;
540
+
541
+ // Check if process was stable (ran for a while) - reset crash counter
542
+ const runDuration = Date.now() - frontendStartTime;
543
+ if (runDuration > STABILITY_THRESHOLD) {
544
+ frontendCrashRestarts = 0;
545
+ }
546
+
492
547
  if (code !== null && code !== 0) {
493
- logger.error(`Frontend exited with code ${code}`);
548
+ logger.error(`[FRONTEND] Exited with code ${code}, signal ${signal}`);
549
+
550
+ // Auto-restart on crash if under limit
551
+ if (frontendCrashRestarts < MAX_CRASH_RESTARTS) {
552
+ frontendCrashRestarts++;
553
+ logger.warn(`[FRONTEND] Crash detected. Auto-restart ${frontendCrashRestarts}/${MAX_CRASH_RESTARTS} in ${CRASH_RESTART_DELAY}ms...`);
554
+ setTimeout(() => {
555
+ if (!isShuttingDown) {
556
+ startFrontend();
557
+ }
558
+ }, CRASH_RESTART_DELAY);
559
+ } else {
560
+ logger.error(`[FRONTEND] Exceeded max crash restarts (${MAX_CRASH_RESTARTS}). Shutting down...`);
561
+ shutdown('frontend-exceeded-restarts', 1, 'frontend-crash');
562
+ }
494
563
  }
495
564
  });
565
+
566
+ frontendProc.on('error', (err) => {
567
+ logger.error(`[FRONTEND] Spawn error: ${err.message}`);
568
+ });
496
569
  };
497
-
570
+
571
+ // RxJS-based stop functions that VERIFY port release before resolving
572
+ // This is the KEY FIX for the race condition bug
573
+
574
+ /**
575
+ * Stop backend and wait for port to be verified free
576
+ * Returns an Observable that completes only when the port is confirmed available
577
+ */
578
+ const stopBackendAndWait$ = () => {
579
+ if (!backendProc) {
580
+ return of(undefined);
581
+ }
582
+
583
+ logger.info(`⏹️ Stopping backend (preparing for restart #${backendRestartCount + 1})...`);
584
+ const proc = backendProc;
585
+
586
+ return concat(
587
+ // First: Stop the process and wait for exit
588
+ stopProcess(proc, 2000, killProcessTree),
589
+ // Then: VERIFY port is actually free (THIS IS THE KEY FIX)
590
+ waitForPortFree(backendPort, 5000, 50).pipe(
591
+ tap(() => logger.debug(`[BACKEND] Port ${backendPort} verified free`))
592
+ )
593
+ ).pipe(
594
+ tap(() => { backendProc = null; }),
595
+ catchError((err) => {
596
+ logger.warn(`[BACKEND] Stop warning: ${err.message}. Proceeding anyway.`);
597
+ backendProc = null;
598
+ return of(undefined);
599
+ })
600
+ );
601
+ };
602
+
603
+ /**
604
+ * Stop frontend and wait for port to be verified free
605
+ * Returns an Observable that completes only when the port is confirmed available
606
+ */
607
+ const stopFrontendAndWait$ = () => {
608
+ if (!frontendProc) {
609
+ return of(undefined);
610
+ }
611
+
612
+ logger.info(`⏹️ Stopping frontend (preparing for restart #${frontendRestartCount + 1})...`);
613
+ const proc = frontendProc;
614
+
615
+ return concat(
616
+ // First: Stop the process and wait for exit
617
+ stopProcess(proc, 2000, killProcessTree),
618
+ // Then: VERIFY port is actually free (THIS IS THE KEY FIX)
619
+ waitForPortFree(frontendPort, 5000, 50).pipe(
620
+ tap(() => logger.debug(`[FRONTEND] Port ${frontendPort} verified free`))
621
+ )
622
+ ).pipe(
623
+ tap(() => { frontendProc = null; }),
624
+ catchError((err) => {
625
+ logger.warn(`[FRONTEND] Stop warning: ${err.message}. Proceeding anyway.`);
626
+ frontendProc = null;
627
+ return of(undefined);
628
+ })
629
+ );
630
+ };
631
+
632
+ // Legacy Promise-based stop functions for shutdown (non-RxJS paths)
498
633
  const stopBackend = () =>
499
634
  new Promise((resolve) => {
500
635
  if (!backendProc) return resolve();
501
- logger.info(`⏹️ Stopping backend (preparing for restart #${backendRestartCount + 1})...`);
636
+ logger.info(`⏹️ Stopping backend...`);
502
637
  const pid = backendProc.pid;
503
638
  let exited = false;
504
639
 
@@ -521,11 +656,11 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
521
656
  resolve();
522
657
  }
523
658
  });
524
-
659
+
525
660
  const stopFrontend = () =>
526
661
  new Promise((resolve) => {
527
662
  if (!frontendProc) return resolve();
528
- logger.info(`⏹️ Stopping frontend (preparing for restart #${frontendRestartCount + 1})...`);
663
+ logger.info(`⏹️ Stopping frontend...`);
529
664
  const pid = frontendProc.pid;
530
665
  let exited = false;
531
666
 
@@ -549,13 +684,27 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
549
684
  }
550
685
  });
551
686
 
552
- async function shutdown(signal = 'SIGTERM', exitCode = 0) {
687
+ async function shutdown(signal = 'SIGTERM', exitCode = 0, source = 'unknown') {
553
688
  if (isShuttingDown) return;
554
689
  isShuttingDown = true;
555
690
 
691
+ logger.info(`[SHUTDOWN] source=${source}, signal=${signal}, exitCode=${exitCode}`);
556
692
  logger.info(`Shutting down split-serve processes (signal: ${signal})...`);
557
693
 
558
694
  try {
695
+ // Signal RxJS streams to stop
696
+ shutdownSubject.next();
697
+ shutdownSubject.complete();
698
+
699
+ // Unsubscribe from all RxJS subscriptions
700
+ for (const sub of subscriptions) {
701
+ try {
702
+ sub.unsubscribe();
703
+ } catch {
704
+ // Ignore unsubscribe errors
705
+ }
706
+ }
707
+
559
708
  if (watcher) {
560
709
  await watcher.close();
561
710
  }
@@ -577,63 +726,128 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
577
726
  }
578
727
  logger.error('Watcher error:', error);
579
728
  logger.error('File watching failed; shutting down dev server.');
580
- await shutdown('watcherError', 1);
729
+ await shutdown('watcherError', 1, 'watcher-error');
581
730
  }
582
731
 
583
- const handleFileChange = async (reason, file) => {
584
- if (pending) return;
585
- pending = true;
586
-
732
+ /**
733
+ * Classify a file change event to determine which services need restarting
734
+ */
735
+ const classifyFileChange = (file, reason) => {
587
736
  // With cwd set, chokidar reports relative paths
588
- // Our patterns are now relative, so match against the relative path
589
737
  const relativePath = path.isAbsolute(file) ? path.relative(root, file) : file;
590
738
 
591
- // Determine which service(s) to restart
592
739
  let restartBackend = false;
593
740
  let restartFrontend = false;
741
+ let type = 'UNKNOWN';
594
742
 
595
743
  if (sharedMatcher && sharedMatcher(relativePath)) {
596
- // Shared file changed - restart both
597
- logger.info(`\n${'='.repeat(80)}`);
598
- logger.info(`🔄 FILE CHANGE DETECTED: ${relativePath} (${reason})`);
599
- logger.info(` Type: SHARED - Will restart BOTH backend and frontend`);
600
- logger.info('='.repeat(80));
601
744
  restartBackend = true;
602
745
  restartFrontend = true;
746
+ type = 'SHARED';
603
747
  } else if (backendMatcher && backendMatcher(relativePath)) {
604
- // Backend file changed
605
- logger.info(`\n${'='.repeat(80)}`);
606
- logger.info(`🔄 FILE CHANGE DETECTED: ${relativePath} (${reason})`);
607
- logger.info(` Type: BACKEND - Will restart backend only`);
608
- logger.info('='.repeat(80));
609
748
  restartBackend = true;
749
+ type = 'BACKEND';
610
750
  } else if (frontendMatcher && frontendMatcher(relativePath)) {
611
- // Frontend file changed
612
- logger.info(`\n${'='.repeat(80)}`);
613
- logger.info(`🔄 FILE CHANGE DETECTED: ${relativePath} (${reason})`);
614
- logger.info(` Type: FRONTEND - Will restart frontend only`);
615
- logger.info('='.repeat(80));
616
751
  restartFrontend = true;
752
+ type = 'FRONTEND';
617
753
  }
618
754
 
619
- // Restart appropriate service(s)
620
- if (restartBackend) {
621
- await stopBackend();
622
- await new Promise(r => setTimeout(r, 100)); // Small delay to ensure port release
623
- startBackend();
624
- }
625
- if (restartFrontend) {
626
- await stopFrontend();
627
- await new Promise(r => setTimeout(r, 100)); // Small delay to ensure port release
628
- startFrontend();
629
- }
755
+ return { relativePath, reason, restartBackend, restartFrontend, type };
756
+ };
630
757
 
631
- if (restartBackend || restartFrontend) {
632
- logger.info(`✅ Restart complete\n`);
633
- }
758
+ /**
759
+ * RxJS-based restart pipeline for backend
760
+ * Uses exhaustMap to ignore new requests while restarting
761
+ * Uses port verification to ensure clean restart (THE KEY FIX)
762
+ */
763
+ const setupBackendRestartPipeline = () => {
764
+ return backendRestartSubject.pipe(
765
+ takeUntil(shutdownSubject),
766
+ // exhaustMap ignores new restart requests while one is in progress
767
+ // This prevents restart-during-restart chaos
768
+ exhaustMap((change) => {
769
+ logger.info(`\n${'='.repeat(80)}`);
770
+ logger.info(`🔄 FILE CHANGE DETECTED: ${change.relativePath} (${change.reason})`);
771
+ logger.info(` Type: ${change.type} - Restarting backend`);
772
+ logger.info('='.repeat(80));
773
+
774
+ return concat(
775
+ // Stop backend and WAIT for port to be verified free
776
+ stopBackendAndWait$(),
777
+ // Then start the new backend
778
+ of(undefined).pipe(
779
+ tap(() => {
780
+ startBackend();
781
+ logger.info(`✅ Backend restart complete\n`);
782
+ })
783
+ )
784
+ ).pipe(
785
+ catchError((err) => {
786
+ logger.error(`[BACKEND] Restart failed: ${err.message}`);
787
+ return of(undefined);
788
+ })
789
+ );
790
+ })
791
+ );
792
+ };
634
793
 
635
- pending = false;
794
+ /**
795
+ * RxJS-based restart pipeline for frontend
796
+ * Uses exhaustMap to ignore new requests while restarting
797
+ * Uses port verification to ensure clean restart (THE KEY FIX)
798
+ */
799
+ const setupFrontendRestartPipeline = () => {
800
+ return frontendRestartSubject.pipe(
801
+ takeUntil(shutdownSubject),
802
+ // exhaustMap ignores new restart requests while one is in progress
803
+ exhaustMap((change) => {
804
+ logger.info(`\n${'='.repeat(80)}`);
805
+ logger.info(`🔄 FILE CHANGE DETECTED: ${change.relativePath} (${change.reason})`);
806
+ logger.info(` Type: ${change.type} - Restarting frontend`);
807
+ logger.info('='.repeat(80));
808
+
809
+ return concat(
810
+ // Stop frontend and WAIT for port to be verified free
811
+ stopFrontendAndWait$(),
812
+ // Then start the new frontend
813
+ of(undefined).pipe(
814
+ tap(() => {
815
+ startFrontend();
816
+ logger.info(`✅ Frontend restart complete\n`);
817
+ })
818
+ )
819
+ ).pipe(
820
+ catchError((err) => {
821
+ logger.error(`[FRONTEND] Restart failed: ${err.message}`);
822
+ return of(undefined);
823
+ })
824
+ );
825
+ })
826
+ );
827
+ };
828
+
829
+ /**
830
+ * Handle file change event by routing to appropriate restart subject
831
+ */
832
+ const handleFileChange = (reason, file) => {
833
+ if (isShuttingDown) return;
834
+
835
+ const change = classifyFileChange(file, reason);
836
+
837
+ // Route to appropriate restart subject(s)
838
+ // The RxJS pipelines handle deduplication via exhaustMap
839
+ if (change.restartBackend) {
840
+ backendRestartSubject.next(change);
841
+ }
842
+ if (change.restartFrontend) {
843
+ frontendRestartSubject.next(change);
844
+ }
636
845
  };
846
+
847
+ // Set up RxJS restart pipelines
848
+ const backendRestartSub = setupBackendRestartPipeline().subscribe();
849
+ const frontendRestartSub = setupFrontendRestartPipeline().subscribe();
850
+ subscriptions.push(backendRestartSub, frontendRestartSub);
637
851
 
638
852
  // Create watcher
639
853
  // Use cwd with relative patterns for proper glob support
@@ -699,8 +913,8 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
699
913
  await import(runtimeEntryPath);
700
914
 
701
915
  // Handle graceful shutdown signals
702
- process.on('SIGINT', () => shutdown('SIGINT', 0));
703
- process.on('SIGTERM', () => shutdown('SIGTERM', 0));
916
+ process.on('SIGINT', () => shutdown('SIGINT', 0, 'signal-handler'));
917
+ process.on('SIGTERM', () => shutdown('SIGTERM', 0, 'signal-handler'));
704
918
 
705
919
  // Handle process exit - this ensures children are killed even if parent crashes
706
920
  process.on('exit', () => {
@@ -717,13 +931,13 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
717
931
  // Handle uncaught exceptions
718
932
  process.on('uncaughtException', async (error) => {
719
933
  logger.error('Uncaught exception:', error);
720
- await shutdown('uncaughtException', 1);
934
+ await shutdown('uncaughtException', 1, 'uncaught-exception');
721
935
  });
722
936
 
723
937
  // Handle unhandled promise rejections
724
938
  process.on('unhandledRejection', async (reason, promise) => {
725
939
  logger.error('Unhandled rejection at:', promise, 'reason:', reason);
726
- await shutdown('unhandledRejection', 1);
940
+ await shutdown('unhandledRejection', 1, 'unhandled-rejection');
727
941
  });
728
942
 
729
943
  // Keep process alive for file watching
@@ -97,12 +97,15 @@ async function setupProxyFirst(app, backendPort, config) {
97
97
  return resolve(cached.canHandle);
98
98
  }
99
99
 
100
+ // Configurable timeout - default 500ms is enough for GC pauses and startup
101
+ const checkTimeout = parseInt(process.env.NOEGO_BACKEND_CHECK_TIMEOUT) || 500;
102
+
100
103
  const options = {
101
104
  hostname: 'localhost',
102
105
  port: backendPort,
103
106
  path: pathname,
104
107
  method: 'GET', // Use GET instead of HEAD since some backends don't support HEAD
105
- timeout: 50, // Very short timeout for dev mode
108
+ timeout: checkTimeout,
106
109
  headers: {
107
110
  'X-Proxy-Check': 'true' // Indicate this is just a check
108
111
  }
@@ -0,0 +1,124 @@
1
+ import { Observable, Subject, merge, fromEvent } from 'rxjs';
2
+ import { map, takeUntil, share, finalize, debounceTime } from 'rxjs/operators';
3
+
4
+ /**
5
+ * File event types emitted by the watcher observable
6
+ */
7
+ export const FileEventType = {
8
+ ADD: 'add',
9
+ CHANGE: 'change',
10
+ UNLINK: 'unlink',
11
+ ADD_DIR: 'addDir',
12
+ UNLINK_DIR: 'unlinkDir',
13
+ READY: 'ready',
14
+ ERROR: 'error'
15
+ };
16
+
17
+ /**
18
+ * Convert a chokidar watcher instance to an RxJS Observable stream
19
+ * @param {FSWatcher} watcher - A chokidar watcher instance
20
+ * @returns {Observable<{type: string, path: string}>} - Observable of file events
21
+ */
22
+ export function watcherToObservable(watcher) {
23
+ return new Observable((subscriber) => {
24
+ const handlers = {
25
+ add: (path) => subscriber.next({ type: FileEventType.ADD, path }),
26
+ change: (path) => subscriber.next({ type: FileEventType.CHANGE, path }),
27
+ unlink: (path) => subscriber.next({ type: FileEventType.UNLINK, path }),
28
+ addDir: (path) => subscriber.next({ type: FileEventType.ADD_DIR, path }),
29
+ unlinkDir: (path) => subscriber.next({ type: FileEventType.UNLINK_DIR, path }),
30
+ ready: () => subscriber.next({ type: FileEventType.READY, path: '' }),
31
+ error: (error) => subscriber.error(error)
32
+ };
33
+
34
+ // Attach event handlers
35
+ Object.entries(handlers).forEach(([event, handler]) => {
36
+ watcher.on(event, handler);
37
+ });
38
+
39
+ // Cleanup on unsubscription
40
+ return () => {
41
+ Object.entries(handlers).forEach(([event, handler]) => {
42
+ watcher.off(event, handler);
43
+ });
44
+ };
45
+ }).pipe(
46
+ // Share the observable so multiple subscribers don't create duplicate handlers
47
+ share()
48
+ );
49
+ }
50
+
51
+ /**
52
+ * Create a file change stream with classification capabilities
53
+ * @param {FSWatcher} watcher - A chokidar watcher instance
54
+ * @param {object} matchers - Object containing picomatch matchers for classification
55
+ * @param {Function} matchers.shared - Matcher for shared files (restart both)
56
+ * @param {Function} matchers.backend - Matcher for backend files
57
+ * @param {Function} matchers.frontend - Matcher for frontend files
58
+ * @returns {Observable<{type: string, path: string, restartBackend: boolean, restartFrontend: boolean}>}
59
+ */
60
+ export function createClassifiedFileStream(watcher, matchers) {
61
+ return watcherToObservable(watcher).pipe(
62
+ // Filter to only file change events (not directories or ready)
63
+ map((event) => {
64
+ if (!event.path || event.type === FileEventType.READY ||
65
+ event.type === FileEventType.ADD_DIR || event.type === FileEventType.UNLINK_DIR) {
66
+ return null;
67
+ }
68
+
69
+ const { path } = event;
70
+ let restartBackend = false;
71
+ let restartFrontend = false;
72
+
73
+ // Check shared patterns first (restart both)
74
+ if (matchers.shared && matchers.shared(path)) {
75
+ restartBackend = true;
76
+ restartFrontend = true;
77
+ } else {
78
+ // Check individual patterns
79
+ if (matchers.backend && matchers.backend(path)) {
80
+ restartBackend = true;
81
+ }
82
+ if (matchers.frontend && matchers.frontend(path)) {
83
+ restartFrontend = true;
84
+ }
85
+ }
86
+
87
+ // Only return events that trigger restarts
88
+ if (!restartBackend && !restartFrontend) {
89
+ return null;
90
+ }
91
+
92
+ return {
93
+ ...event,
94
+ restartBackend,
95
+ restartFrontend
96
+ };
97
+ }),
98
+ // Filter out null events
99
+ map((event) => event) // This will pass through the filter in the pipe where it's used
100
+ );
101
+ }
102
+
103
+ /**
104
+ * Create a debounced file change stream
105
+ * Useful for batching rapid file changes
106
+ * @param {FSWatcher} watcher - A chokidar watcher instance
107
+ * @param {number} debounceMs - Debounce time in milliseconds (default: 50)
108
+ * @returns {Observable<{type: string, path: string}>}
109
+ */
110
+ export function createDebouncedFileStream(watcher, debounceMs = 50) {
111
+ return watcherToObservable(watcher).pipe(
112
+ // Filter to only actual file events
113
+ map((event) => {
114
+ if (event.type === FileEventType.READY ||
115
+ event.type === FileEventType.ADD_DIR ||
116
+ event.type === FileEventType.UNLINK_DIR) {
117
+ return null;
118
+ }
119
+ return event;
120
+ }),
121
+ // Filter out nulls - this happens in the subscription
122
+ debounceTime(debounceMs)
123
+ );
124
+ }
@@ -0,0 +1,104 @@
1
+ import { Observable, interval, of, throwError } from 'rxjs';
2
+ import { map, switchMap, filter, take, timeout, catchError } from 'rxjs/operators';
3
+ import net from 'node:net';
4
+
5
+ /**
6
+ * Check if a port is currently free (available for binding)
7
+ * @param {number} port - The port number to check
8
+ * @returns {Observable<boolean>} - Observable that emits true if port is free, false if occupied
9
+ */
10
+ export function checkPort(port) {
11
+ return new Observable((subscriber) => {
12
+ const server = net.createServer();
13
+
14
+ server.once('error', (err) => {
15
+ if (err.code === 'EADDRINUSE') {
16
+ // Port is in use
17
+ subscriber.next(false);
18
+ subscriber.complete();
19
+ } else {
20
+ subscriber.error(err);
21
+ }
22
+ });
23
+
24
+ server.once('listening', () => {
25
+ // Port is free - close the server and report success
26
+ server.close(() => {
27
+ subscriber.next(true);
28
+ subscriber.complete();
29
+ });
30
+ });
31
+
32
+ // Attempt to bind to the port
33
+ server.listen(port, '127.0.0.1');
34
+
35
+ // Cleanup on unsubscription
36
+ return () => {
37
+ try {
38
+ server.close();
39
+ } catch {
40
+ // Server may already be closed
41
+ }
42
+ };
43
+ });
44
+ }
45
+
46
+ /**
47
+ * Wait for a port to become free by polling at regular intervals
48
+ * @param {number} port - The port number to monitor
49
+ * @param {number} timeoutMs - Maximum time to wait in milliseconds (default: 5000)
50
+ * @param {number} pollInterval - Time between checks in milliseconds (default: 50)
51
+ * @returns {Observable<void>} - Observable that completes when port is free, errors on timeout
52
+ */
53
+ export function waitForPortFree(port, timeoutMs = 5000, pollInterval = 50) {
54
+ return interval(pollInterval).pipe(
55
+ // Check port availability on each interval tick
56
+ switchMap(() => checkPort(port)),
57
+ // Only pass through when port is free
58
+ filter((isFree) => isFree === true),
59
+ // Complete after first confirmation that port is free
60
+ take(1),
61
+ // Map to void (we don't need the boolean value)
62
+ map(() => undefined),
63
+ // Timeout if port doesn't become free within the specified time
64
+ timeout({
65
+ each: timeoutMs,
66
+ with: () => throwError(() => new Error(`Port ${port} did not become free within ${timeoutMs}ms`))
67
+ }),
68
+ // Provide graceful fallback on timeout (continue anyway but log warning)
69
+ catchError((err) => {
70
+ console.warn(`[port.js] Warning: ${err.message}. Proceeding anyway.`);
71
+ return of(undefined);
72
+ })
73
+ );
74
+ }
75
+
76
+ /**
77
+ * Wait for a port to be in use (server started)
78
+ * @param {number} port - The port number to monitor
79
+ * @param {number} timeoutMs - Maximum time to wait in milliseconds (default: 10000)
80
+ * @param {number} pollInterval - Time between checks in milliseconds (default: 100)
81
+ * @returns {Observable<void>} - Observable that completes when port is in use, errors on timeout
82
+ */
83
+ export function waitForPortInUse(port, timeoutMs = 10000, pollInterval = 100) {
84
+ return interval(pollInterval).pipe(
85
+ // Check port availability on each interval tick
86
+ switchMap(() => checkPort(port)),
87
+ // Only pass through when port is occupied (server running)
88
+ filter((isFree) => isFree === false),
89
+ // Complete after first confirmation that port is in use
90
+ take(1),
91
+ // Map to void
92
+ map(() => undefined),
93
+ // Timeout if server doesn't start within the specified time
94
+ timeout({
95
+ each: timeoutMs,
96
+ with: () => throwError(() => new Error(`Port ${port} did not become active within ${timeoutMs}ms`))
97
+ }),
98
+ // Provide graceful fallback on timeout
99
+ catchError((err) => {
100
+ console.warn(`[port.js] Warning: ${err.message}. Proceeding anyway.`);
101
+ return of(undefined);
102
+ })
103
+ );
104
+ }
@@ -0,0 +1,218 @@
1
+ import { Observable, of, timer, race, EMPTY } from 'rxjs';
2
+ import { map, switchMap, take, catchError, finalize, tap } from 'rxjs/operators';
3
+ import { spawn, execSync } from 'node:child_process';
4
+
5
+ /**
6
+ * Process event types emitted by spawnProcess observable
7
+ */
8
+ export const ProcessEvent = {
9
+ SPAWNED: 'spawned',
10
+ STDOUT: 'stdout',
11
+ STDERR: 'stderr',
12
+ EXITED: 'exited',
13
+ ERROR: 'error'
14
+ };
15
+
16
+ /**
17
+ * Kill a process and all its descendants (entire process tree)
18
+ * This ensures no orphaned processes remain when killing a parent
19
+ * @param {number} pid - Process ID to kill
20
+ * @param {string} signal - Signal to send (default: 'SIGKILL')
21
+ */
22
+ export function killProcessTree(pid, signal = 'SIGKILL') {
23
+ if (!pid) return;
24
+
25
+ try {
26
+ // On Unix systems, use pkill to kill all descendants
27
+ if (process.platform !== 'win32') {
28
+ // Kill all child processes first
29
+ try {
30
+ execSync(`pkill -P ${pid}`, { stdio: 'ignore' });
31
+ } catch {
32
+ // No children or already dead, that's fine
33
+ }
34
+ // Then kill the parent
35
+ try {
36
+ process.kill(pid, signal);
37
+ } catch {
38
+ // Process may already be dead
39
+ }
40
+ } else {
41
+ // On Windows, use taskkill with /T flag to kill process tree
42
+ try {
43
+ execSync(`taskkill /pid ${pid} /T /F`, { stdio: 'ignore' });
44
+ } catch {
45
+ // Process may already be dead
46
+ }
47
+ }
48
+ } catch {
49
+ // Ignore errors - process might already be dead
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Spawn a child process and return it as an Observable stream of events
55
+ * @param {string} command - The command to execute
56
+ * @param {string[]} args - Command arguments
57
+ * @param {object} options - Spawn options (cwd, env, etc.)
58
+ * @returns {Observable<{type: string, process?: ChildProcess, data?: any, code?: number, signal?: string}>}
59
+ */
60
+ export function spawnProcess(command, args, options = {}) {
61
+ return new Observable((subscriber) => {
62
+ let proc = null;
63
+
64
+ try {
65
+ proc = spawn(command, args, {
66
+ ...options,
67
+ detached: false
68
+ });
69
+
70
+ // Emit spawned event with process reference
71
+ subscriber.next({
72
+ type: ProcessEvent.SPAWNED,
73
+ process: proc,
74
+ pid: proc.pid
75
+ });
76
+
77
+ // Handle stdout if not inherited
78
+ if (proc.stdout) {
79
+ proc.stdout.on('data', (data) => {
80
+ subscriber.next({
81
+ type: ProcessEvent.STDOUT,
82
+ data: data.toString()
83
+ });
84
+ });
85
+ }
86
+
87
+ // Handle stderr if not inherited
88
+ if (proc.stderr) {
89
+ proc.stderr.on('data', (data) => {
90
+ subscriber.next({
91
+ type: ProcessEvent.STDERR,
92
+ data: data.toString()
93
+ });
94
+ });
95
+ }
96
+
97
+ // Handle process exit
98
+ proc.on('exit', (code, signal) => {
99
+ subscriber.next({
100
+ type: ProcessEvent.EXITED,
101
+ code,
102
+ signal
103
+ });
104
+ subscriber.complete();
105
+ });
106
+
107
+ // Handle spawn errors
108
+ proc.on('error', (error) => {
109
+ subscriber.next({
110
+ type: ProcessEvent.ERROR,
111
+ error
112
+ });
113
+ subscriber.error(error);
114
+ });
115
+ } catch (error) {
116
+ subscriber.error(error);
117
+ }
118
+
119
+ // Cleanup on unsubscription
120
+ return () => {
121
+ if (proc && !proc.killed) {
122
+ try {
123
+ proc.kill('SIGTERM');
124
+ } catch {
125
+ // Process may already be dead
126
+ }
127
+ }
128
+ };
129
+ });
130
+ }
131
+
132
+ /**
133
+ * Stop a process gracefully with timeout-based force kill
134
+ * @param {ChildProcess} proc - The child process to stop
135
+ * @param {number} gracefulTimeout - Time to wait before force-killing (default: 5000ms)
136
+ * @param {function} killTreeFn - Function to kill process tree (optional, uses killProcessTree by default)
137
+ * @returns {Observable<void>} - Observable that completes when process has exited
138
+ */
139
+ export function stopProcess(proc, gracefulTimeout = 5000, killTreeFn = killProcessTree) {
140
+ if (!proc || proc.killed) {
141
+ return of(undefined);
142
+ }
143
+
144
+ return new Observable((subscriber) => {
145
+ const pid = proc.pid;
146
+ let exited = false;
147
+ let forceKillTimer = null;
148
+
149
+ // Set up force kill timer
150
+ forceKillTimer = setTimeout(() => {
151
+ if (!exited && pid) {
152
+ console.warn(`[process-observable] Process ${pid} didn't exit gracefully, force killing...`);
153
+ killTreeFn(pid, 'SIGKILL');
154
+ }
155
+ }, gracefulTimeout);
156
+
157
+ // Listen for exit event
158
+ const onExit = () => {
159
+ exited = true;
160
+ if (forceKillTimer) {
161
+ clearTimeout(forceKillTimer);
162
+ forceKillTimer = null;
163
+ }
164
+ subscriber.next(undefined);
165
+ subscriber.complete();
166
+ };
167
+
168
+ proc.once('exit', onExit);
169
+
170
+ // Send SIGTERM to request graceful shutdown
171
+ try {
172
+ proc.kill('SIGTERM');
173
+ } catch {
174
+ // Process may already be dead
175
+ if (!exited) {
176
+ exited = true;
177
+ if (forceKillTimer) {
178
+ clearTimeout(forceKillTimer);
179
+ forceKillTimer = null;
180
+ }
181
+ subscriber.next(undefined);
182
+ subscriber.complete();
183
+ }
184
+ }
185
+
186
+ // Cleanup on unsubscription
187
+ return () => {
188
+ if (forceKillTimer) {
189
+ clearTimeout(forceKillTimer);
190
+ }
191
+ proc.off('exit', onExit);
192
+ };
193
+ });
194
+ }
195
+
196
+ /**
197
+ * Stop a process and verify it has completely stopped
198
+ * This is a higher-level function that combines stopProcess with verification
199
+ * @param {ChildProcess} proc - The child process to stop
200
+ * @param {number} gracefulTimeout - Time to wait before force-killing (default: 5000ms)
201
+ * @param {function} killTreeFn - Function to kill process tree
202
+ * @returns {Observable<void>} - Observable that completes when process is confirmed stopped
203
+ */
204
+ export function stopProcessAndVerify(proc, gracefulTimeout = 5000, killTreeFn = killProcessTree) {
205
+ if (!proc || proc.killed) {
206
+ return of(undefined);
207
+ }
208
+
209
+ return stopProcess(proc, gracefulTimeout, killTreeFn).pipe(
210
+ // Add a small delay to ensure OS has released resources
211
+ switchMap(() => timer(50)),
212
+ map(() => undefined),
213
+ catchError((err) => {
214
+ console.warn(`[process-observable] Error stopping process: ${err.message}`);
215
+ return of(undefined);
216
+ })
217
+ );
218
+ }