@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 +83 -74
- package/package.json +2 -1
- package/src/commands/dev.js +270 -56
- package/src/runtime/runtime.js +4 -1
- package/src/utils/file-watcher-observable.js +124 -0
- package/src/utils/port.js +104 -0
- package/src/utils/process-observable.js +218 -0
package/AGENTS.md
CHANGED
|
@@ -12,37 +12,43 @@ Objective
|
|
|
12
12
|
|
|
13
13
|
Entry Points
|
|
14
14
|
|
|
15
|
-
- Server entry: `
|
|
16
|
-
- Page entry: `
|
|
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`)
|
|
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/
|
|
23
|
-
- Dinner: `/Users/shavauhngabay/dev/noego/dinner`
|
|
24
|
-
- Sqlstack: `/Users/shavauhngabay/dev/
|
|
25
|
-
- App (this CLI): `/Users/shavauhngabay/dev/app`
|
|
26
|
-
- Example dist outputs in this doc are under the application project: `
|
|
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 (
|
|
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/
|
|
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 `
|
|
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/
|
|
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
|
-
- `
|
|
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
|
|
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 (`
|
|
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., `
|
|
147
|
-
- `rootDir`: e.g., `
|
|
148
|
-
- `controllers`: e.g., `
|
|
149
|
-
- `middleware`: e.g., `
|
|
150
|
-
- `openapi`: e.g., `
|
|
151
|
-
- `sqlGlobs`: list of globs, e.g., `['
|
|
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., `
|
|
154
|
-
- `options`: e.g., `
|
|
155
|
-
- `rootDir`: e.g., `
|
|
156
|
-
- `openapi`: e.g., `
|
|
157
|
-
- `assets`: list of globs, e.g., `['
|
|
158
|
-
- `clientExclude`: list of globs for CSR exclusions, e.g., `['
|
|
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., `
|
|
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: `
|
|
184
|
+
- Example: `{project}/app.yaml`
|
|
179
185
|
- server:
|
|
180
|
-
entry:
|
|
181
|
-
rootDir:
|
|
182
|
-
controllers:
|
|
183
|
-
middleware:
|
|
184
|
-
openapi:
|
|
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
|
-
-
|
|
192
|
+
- {project}/server/repo/**/*.sql
|
|
187
193
|
- ui:
|
|
188
|
-
page:
|
|
189
|
-
options:
|
|
190
|
-
rootDir:
|
|
191
|
-
openapi:
|
|
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
|
-
-
|
|
194
|
-
-
|
|
199
|
+
- {project}/ui/resources/**
|
|
200
|
+
- {project}/ui/styles/**
|
|
195
201
|
clientExclude:
|
|
196
|
-
-
|
|
197
|
-
-
|
|
202
|
+
- {project}/server/**
|
|
203
|
+
- {project}/middleware/**
|
|
198
204
|
- dev:
|
|
199
205
|
watch: true
|
|
200
206
|
watchPaths:
|
|
201
|
-
-
|
|
202
|
-
-
|
|
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:
|
|
208
|
-
override:
|
|
213
|
+
configFile: {project}/vite.config.js
|
|
214
|
+
override: {project}/vite.client.override.json
|
|
209
215
|
ssr:
|
|
210
|
-
configFile:
|
|
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:
|
|
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 (`
|
|
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 (`
|
|
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 `
|
|
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 `
|
|
240
|
-
- If inference fails or is ambiguous, App falls back to
|
|
241
|
-
- `--ui-openapi` remains required for SSR discovery unless the project embeds its location in code; App defaults to `
|
|
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
|
-
- `
|
|
254
|
-
- `
|
|
255
|
-
- `
|
|
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 `
|
|
261
|
-
- Forge’s `open_api_path` resolves correctly under `
|
|
262
|
-
- sqlstack finds `.sql` files next to the transpiled repo JS under `
|
|
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
|
|
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
|
|
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
|
|
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 `
|
|
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
|
-
- `
|
|
416
|
-
- `
|
|
417
|
-
- `
|
|
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
|
|
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/
|
|
452
|
-
`/Users/shavauhngabay/dev/noego/dinner`
|
|
453
|
-
`/Users/shavauhngabay/dev/
|
|
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
|
|
457
|
-
`/Users/shavauhngabay/dev/
|
|
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.
|
|
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": {
|
package/src/commands/dev.js
CHANGED
|
@@ -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(`
|
|
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(`
|
|
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
|
|
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
|
|
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
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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
|
-
|
|
620
|
-
|
|
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
|
-
|
|
632
|
-
|
|
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
|
-
|
|
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
|
package/src/runtime/runtime.js
CHANGED
|
@@ -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:
|
|
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
|
+
}
|