@moostjs/vite 0.6.35 → 0.6.37

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/README.md CHANGED
@@ -3,6 +3,7 @@
3
3
  Vite dev plugin for [Moost](https://moost.org). Enables hot module replacement for Moost HTTP applications during development, automatic adapter detection, and production build configuration.
4
4
 
5
5
  Supports two modes:
6
+
6
7
  - **Backend mode** (default) — Moost owns the server, Vite provides HMR and TypeScript transforms
7
8
  - **Middleware mode** — Vite serves the frontend (Vue, React, etc.), Moost handles API routes as middleware
8
9
 
@@ -217,6 +218,10 @@ ssr: {
217
218
 
218
219
  When you use an explicit `noExternal` list, the plugin automatically keeps the **moost/wooks runtime** (`moost`, `@moostjs/*`, `@wooksjs/*`, `wooks`) bundled alongside your listed packages, so there is always a single runtime instance. This avoids a subtle production-only failure: if any `noExternal` package imports `@wooksjs/*` (e.g. `@aooth/*` and other `.as`-shipping libs do), it would otherwise pull in a second copy while externalized `moost` uses the first — splitting the event context so `useRequest()` / `useHeaders()` / `useAuthorization()` read `undefined`. If you would rather externalize the runtime instead, list it under `ssr.external` (e.g. `['@wooksjs/event-http', '@wooksjs/event-core', 'wooks', ...]`) and add those packages as direct dependencies — the plugin detects an externalized runtime and leaves your all-external setup intact.
219
220
 
221
+ The guard works in one direction only, and the runtime is not the only package that keeps module-level state — the `@atscript/*` family (`core`, `typescript`, `db` and its adapters, moost-db, the UI packages) relies on registries and class identity too. The mirror case is on you: a bundled shared package plus an external package that depends on it loads a second copy from `node_modules` and fails the same way — a lib calling wooks composables (`useRequest()`, `useHeaders()`, …) that wasn't in your `noExternal` list (or was put in `ssr.external`), or `@atscript/db-mysql` / `@atscript/db-sqlite` externalized while `@atscript/db` is bundled, where the adapter's `instanceof` check fails and it silently creates an empty table instead of the declared view. **A shared-state package is either entirely bundled or entirely external, together with everything that depends on it** — whatever `pnpm why @wooksjs/event-http` (or `pnpm why @atscript/db`) lists sits on the same side as the package itself. Never split.
222
+
223
+ After the SSR build the plugin checks for exactly that: it compares the packages inlined into `dist/server` with the bare imports left in it and warns when any externalized package (or one of its transitive dependencies) depends on a bundled watched package — `moost` / `@moostjs/*` / `@wooksjs/*` / `wooks` / `@atscript/*` by default — naming the package and the fix. `ssrExternalCheck: { packages: ['some-registry-lib', '@acme/', /^my-lib-/] }` watches more (exact name, scope prefix, `RegExp`); `ssrExternalCheck: false` silences it. The symptoms of a split are `TypeError: Cannot read properties of undefined (reading 'headers')` (or any header name) from inside a composable, and an empty table where a managed view was declared — production only. Source-level tests share one module graph and cannot see it: verify on the installed build. Full explanation and a manual verification recipe: [SSR Bundle Size](https://moost.org/webapp/vite#ssr-bundle-size).
224
+
220
225
  ## SSR Local Fetch
221
226
 
222
227
  When `ssrFetch` is enabled (default: `true`), the plugin patches `globalThis.fetch` so that local paths are routed in-process through Moost instead of making a real HTTP request. This is useful for SSR where server-side code fetches from its own API:
@@ -244,25 +249,26 @@ The plugin injects a `__VITE_ID` decorator on `@Injectable` and `@Controller` cl
244
249
 
245
250
  ## Options
246
251
 
247
- | Option | Type | Default | Description |
248
- |---|---|---|---|
249
- | `entry` | `string` | — | Application entry file (required) |
250
- | `port` | `number` | `3000` | Dev server port (backend mode only) |
251
- | `host` | `string` | `'localhost'` | Dev server host (backend mode only) |
252
- | `outDir` | `string` | `'dist'` | Build output directory (backend mode only) |
253
- | `format` | `'cjs' \| 'esm'` | `'esm'` | Output module format (backend mode only) |
254
- | `sourcemap` | `boolean` | `true` | Generate source maps (both modes; in middleware mode applies to the `dist/server` SSR build) |
255
- | `externals` | `boolean \| object` | `true` | Configure external dependencies (backend mode only) |
256
- | `onEject` | `function` | — | Hook to control DI instance ejection during HMR |
257
- | `ssrFetch` | `boolean` | `true` | Enable local fetch interception for SSR |
258
- | `middleware` | `boolean` | `false` | Run Moost as Connect middleware (Vite serves frontend) |
259
- | `prefix` | `string` | `'/api'` (prod server) | URL prefix filter for middleware mode (e.g. `'/api'`); the production server defaults to `'/api'` when omitted |
260
- | `ssrEntry` | `string` | — | Vue/React SSR entry module (e.g. `'/src/entry-server.ts'`) |
261
- | `ssrOutlet` | `string` | `'<!--ssr-outlet-->'` | HTML placeholder for SSR-rendered content |
262
- | `ssrState` | `string` | `'<!--ssr-state-->'` | HTML placeholder for SSR state transfer script |
263
- | `ssrHead` | `string` | `'<!--ssr-head-->'` | HTML placeholder for SSR-rendered `<head>` tags (place inside `<head>`) |
264
- | `serverEntry` | `string` | — | Custom production server entry file (e.g. `'./server.ts'`) |
265
- | `ssrExternal` | `string[]` | — | Packages to keep external in the SSR build (middleware mode, `vite build` only). Concatenated with `cfg.ssr.external`. See [SSR Bundle Size](#ssr-bundle-size). |
252
+ | Option | Type | Default | Description |
253
+ | ------------------ | ------------------------------------------------ | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
254
+ | `entry` | `string` | — | Application entry file (required) |
255
+ | `port` | `number` | `3000` | Dev server port (backend mode only) |
256
+ | `host` | `string` | `'localhost'` | Dev server host (backend mode only) |
257
+ | `outDir` | `string` | `'dist'` | Build output directory (backend mode only) |
258
+ | `format` | `'cjs' \| 'esm'` | `'esm'` | Output module format (backend mode only) |
259
+ | `sourcemap` | `boolean` | `true` | Generate source maps (both modes; in middleware mode applies to the `dist/server` SSR build) |
260
+ | `externals` | `boolean \| object` | `true` | Configure external dependencies (backend mode only) |
261
+ | `onEject` | `function` | — | Hook to control DI instance ejection during HMR |
262
+ | `ssrFetch` | `boolean` | `true` | Enable local fetch interception for SSR |
263
+ | `middleware` | `boolean` | `false` | Run Moost as Connect middleware (Vite serves frontend) |
264
+ | `prefix` | `string` | `'/api'` (prod server) | URL prefix filter for middleware mode (e.g. `'/api'`); the production server defaults to `'/api'` when omitted |
265
+ | `ssrEntry` | `string` | — | Vue/React SSR entry module (e.g. `'/src/entry-server.ts'`) |
266
+ | `ssrOutlet` | `string` | `'<!--ssr-outlet-->'` | HTML placeholder for SSR-rendered content |
267
+ | `ssrState` | `string` | `'<!--ssr-state-->'` | HTML placeholder for SSR state transfer script |
268
+ | `ssrHead` | `string` | `'<!--ssr-head-->'` | HTML placeholder for SSR-rendered `<head>` tags (place inside `<head>`) |
269
+ | `serverEntry` | `string` | — | Custom production server entry file (e.g. `'./server.ts'`) |
270
+ | `ssrExternal` | `string[]` | — | Packages to keep external in the SSR build (middleware mode, `vite build` only). Concatenated with `cfg.ssr.external`. See [SSR Bundle Size](#ssr-bundle-size). |
271
+ | `ssrExternalCheck` | `boolean \| { packages?: (string \| RegExp)[] }` | `true` | Warn after the middleware-mode SSR build when an externalized package depends on a bundled shared-state package (`moost`, `@moostjs/*`, `wooks`, `@wooksjs/*`, `@atscript/*`); `packages` watches more. See [SSR Bundle Size](#ssr-bundle-size). |
266
272
 
267
273
  Options marked "backend mode only" are ignored when `middleware: true` — the user's `vite.config.ts` controls build/server configuration in middleware mode.
268
274
 
package/dist/index.cjs CHANGED
@@ -249,19 +249,63 @@ function _define_property(obj, key, value) {
249
249
  * A reference to a shared LogsStorage instance that persists
250
250
  * across init cycles in dev mode.
251
251
  */ let logsStorage;
252
+ /** Whether {@link patchMoostHandlerLogging} already installed its wrappers in this process. */ let installed = false;
252
253
  /**
253
- * Patches Moost’s .init() and .logMappedHandler() methods so that, during dev:
254
+ * The slot {@link captureMoostInit} fills: receives every `init()` promise the
255
+ * wrapper below produces. Unset in prod/test, where nothing consumes a capture.
256
+ */ let onInitCaptured;
257
+ /**
258
+ * Hands every `Moost.prototype.init()` promise to `onCapture`, letting a boot
259
+ * await the app's **initialization** instead of only its module evaluation.
260
+ *
261
+ * The documented entry shape is `app.adapter(http).listen(port)` followed by an
262
+ * **un-awaited** `app.init()`: the HTTP middleware is therefore captured *before*
263
+ * init finishes, and nothing else observes a rejecting init — a bind error, a DI
264
+ * audit error or a throwing `@MoostInit` hook would leave the dev server serving
265
+ * a half-booted app (the routes bound before the failure answer 200, the rest
266
+ * fall through to the SPA fallback) instead of the plugin's 502. That is also
267
+ * why the plugin's `bootError` gate runs BEFORE the captured middleware, and why
268
+ * the SSR fallback checks it too: `listen()` ran first and left both a middleware
269
+ * and the local-fetch hook behind.
270
+ *
271
+ * Patches the plugin's **own** `moost` import, exactly like the handler logging
272
+ * does: in dev the runtime is externalized, so the plugin and the app share one
273
+ * native `moost` instance (the premise the DI eject in `restart-cleanup` rests on
274
+ * too, and what `ssrExternalCheck` warns about when it stops holding). Unlike the
275
+ * adapters there is nothing to re-establish per reload — a static import is never
276
+ * re-evaluated — and unlike them it must NOT be pulled through the SSR module
277
+ * runner: importing `moost` there registers it as the runner's first module and
278
+ * makes subsequent hot updates re-evaluate the entry only, serving every one of
279
+ * its dependencies (controllers included) from the stale cache.
280
+ *
281
+ * There is exactly ONE `init` wrapper per process (installed by
282
+ * {@link patchMoostHandlerLogging}) and this only fills its slot, so the newest
283
+ * plugin instance wins: Vite re-imports the config on every config-file
284
+ * `server.restart()`, re-running the plugin factory, and a per-instance wrapper
285
+ * would stack on the previous one and keep the dead instance's scope alive.
286
+ *
287
+ * @param onCapture receives each captured init promise (already marked handled).
288
+ */ function captureMoostInit(onCapture) {
289
+ onInitCaptured = onCapture;
290
+ }
291
+ /**
292
+ * Patches Moost’s .init() and .logMappedHandler() methods — once per process —
293
+ * so that, during dev:
254
294
  * - Only newly added handlers are logged normally.
255
295
  * - Removed handlers are logged/stroked out to indicate removal.
256
296
  * - Prevents repeated logs from flooding the console across multiple hot reloads.
297
+ * - Every init() promise reaches the {@link captureMoostInit} slot, if filled.
257
298
  */ function patchMoostHandlerLogging() {
299
+ if (installed) return;
300
+ installed = true;
258
301
  const origInit = moost.Moost.prototype.init;
302
+ const origLogMappedHandler = moost.Moost.prototype.logMappedHandler;
259
303
  /**
260
- * Monkey-patch Moost.init:
304
+ * The logging body of the patched Moost.init:
261
305
  * 1. Start a new recording cycle in LogsStorage.
262
306
  * 2. Call the real .init().
263
307
  * 3. End the recording, logging any removed handlers.
264
- */ moost.Moost.prototype.init = async function init() {
308
+ */ const initWithLogging = async function() {
265
309
  if (!logsStorage) logsStorage = new LogsStorage();
266
310
  logsStorage.startRecording();
267
311
  await origInit.call(this);
@@ -269,7 +313,14 @@ function _define_property(obj, key, value) {
269
313
  origLogMappedHandler.call(this, item.eventName, item.classConstructor, item.method, true, "❌ ");
270
314
  });
271
315
  };
272
- const origLogMappedHandler = moost.Moost.prototype.logMappedHandler;
316
+ moost.Moost.prototype.init = function init() {
317
+ const promise = initWithLogging.call(this);
318
+ if (onInitCaptured) {
319
+ promise.catch(() => {});
320
+ onInitCaptured(promise);
321
+ }
322
+ return promise;
323
+ };
273
324
  /**
274
325
  * Monkey-patch Moost.logMappedHandler:
275
326
  * If we haven’t seen this particular route+class+method combination
@@ -289,14 +340,23 @@ function _define_property(obj, key, value) {
289
340
  /**
290
341
  * Clean up Moost’s global containers and optionally remove specific instances from the registry.
291
342
  *
343
+ * Every instance actually removed from a registry is **disposed** before the
344
+ * caches are dropped: its `@MoostDispose` hooks (or `Symbol.asyncDispose` /
345
+ * `Symbol.dispose`) are awaited, so a singleton owning a connection, consumer,
346
+ * timer or file handle releases it instead of leaking one copy per reload. An
347
+ * instance kept by an `onEject` veto is never disposed. A failing hook is
348
+ * warned about and the reload continues.
349
+ *
292
350
  * @param {Set<string>} [cleanupInstances] A set of module IDs to remove from the registry.
293
- */ function moostRestartCleanup(adapters, onEject, cleanupInstances) {
351
+ * @returns the instances that were ejected (and therefore disposed)
352
+ */ async function moostRestartCleanup(adapters, onEject, cleanupInstances) {
294
353
  const logger = getLogger();
295
354
  const infact = (0, moost.getMoostInfact)();
296
355
  const { registry, scopes } = infact;
297
356
  const registries = [registry, ...Object.values(scopes)];
298
357
  infact._cleanup();
299
358
  const mate = (0, moost.getMoostMate)();
359
+ /** Instances removed from a registry by this run — disposed below, before the caches drop. */ const ejected = [];
300
360
  if (cleanupInstances) {
301
361
  for (const reg of registries) {
302
362
  for (const key of Object.getOwnPropertySymbols(reg)) {
@@ -305,6 +365,7 @@ function _define_property(obj, key, value) {
305
365
  if (viteId && cleanupInstances.has(viteId)) {
306
366
  logger.debug(`🔃 Replacing "${constructorName(instance)}"`);
307
367
  delete reg[key];
368
+ ejected.push(instance);
308
369
  }
309
370
  }
310
371
  for (const key of Object.getOwnPropertySymbols(reg)) {
@@ -312,25 +373,39 @@ function _define_property(obj, key, value) {
312
373
  scanParams(instance, (type) => {
313
374
  if ((type === moost.Moost || type instanceof moost.Moost || type.prototype instanceof moost.Moost) && (!onEject || onEject(instance, type))) {
314
375
  delete reg[key];
376
+ ejected.push(instance);
315
377
  logger.debug(`✖️ Ejecting "${constructorName(instance)}" (depends on re-instantiated "Moost")`);
316
378
  return true;
317
379
  }
318
380
  for (const adapter of adapters) if (adapter.compare(type) && (!onEject || onEject(instance, type))) {
319
381
  delete reg[key];
382
+ ejected.push(instance);
320
383
  logger.debug(`✖️ Ejecting "${constructorName(instance)}" (depends on re-instantiated "${adapter.constructor.name}")`);
321
384
  return true;
322
385
  }
323
386
  });
324
387
  }
325
- clearDependantRegistry(reg, onEject);
388
+ clearDependantRegistry(reg, onEject, ejected);
326
389
  }
327
390
  infact.registry = registry;
328
391
  infact.scopes = scopes;
329
392
  }
393
+ await disposeEjected(ejected);
330
394
  (0, moost.getMoostMate)()._cleanup();
331
395
  (0, moost.clearGlobalWooks)();
396
+ return ejected;
332
397
  }
333
- function clearDependantRegistry(registry, onEject) {
398
+ /** Awaits the `@MoostDispose` hooks of the ejected instances; never throws. */ async function disposeEjected(ejected) {
399
+ if (ejected.length === 0) return;
400
+ const logger = getLogger();
401
+ const { disposed, errors } = await (0, moost.disposeInstances)(ejected, {
402
+ logger,
403
+ onError: "warn"
404
+ });
405
+ for (const instance of disposed) logger.debug(`♻️ Disposed "${constructorName(instance)}"`);
406
+ for (const e of errors) logger.warn(`⚠️ Dispose hook "${constructorName(e.instance)}.${String(e.method)}" failed — the reload continues`);
407
+ }
408
+ function clearDependantRegistry(registry, onEject, ejected) {
334
409
  const logger = getLogger();
335
410
  const objSet = /* @__PURE__ */ new Set();
336
411
  let somethingIsDeleted = true;
@@ -342,7 +417,10 @@ function clearDependantRegistry(registry, onEject) {
342
417
  }
343
418
  for (const key of Object.getOwnPropertySymbols(registry)) {
344
419
  const instance = registry[key];
345
- if (checkAndEject(instance, objSet, onEject, registry, key, logger)) somethingIsDeleted = true;
420
+ if (checkAndEject(instance, objSet, onEject, registry, key, logger)) {
421
+ ejected.push(instance);
422
+ somethingIsDeleted = true;
423
+ }
346
424
  }
347
425
  }
348
426
  }
@@ -375,6 +453,173 @@ function constructorName(i) {
375
453
  return Object.getPrototypeOf(i).constructor.name;
376
454
  }
377
455
 
456
+ //#endregion
457
+ //#region packages/vite/src/ssr-externals-check.ts
458
+ /**
459
+ * Packages that make up the moost/wooks runtime. They mint per-module Symbol
460
+ * slot keys, so the whole set must resolve to a single module instance in the
461
+ * production SSR graph. This is the scope of the single-instance *guard*
462
+ * (force-bundling under an explicit `noExternal` list) — the build *check*
463
+ * watches the wider {@link SHARED_STATE_PACKAGE_PATTERNS}.
464
+ */ const RUNTIME_PACKAGE_PATTERNS = [
465
+ /^moost($|\/)/,
466
+ /^@moostjs\//,
467
+ /^@wooksjs\//,
468
+ /^wooks($|\/)/
469
+ ];
470
+ /**
471
+ * Packages the build check watches by default: the moost/wooks runtime plus the
472
+ * `@atscript/*` family. All of them keep module-level state — Symbol slot keys,
473
+ * DI/model registries, class identity used by `instanceof` — so a second copy
474
+ * loaded by Node breaks lookups that the bundled copy filled in.
475
+ *
476
+ * This is deliberately wider than {@link RUNTIME_PACKAGE_PATTERNS}: the guard
477
+ * that force-bundles packages covers the runtime only, while the check merely
478
+ * reports what the output shows.
479
+ */ const SHARED_STATE_PACKAGE_PATTERNS = [...RUNTIME_PACKAGE_PATTERNS, /^@atscript\//];
480
+ function escapeRegExp(input) {
481
+ return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
482
+ }
483
+ /**
484
+ * Build the watched-package patterns for the check: the defaults plus the
485
+ * user's extras. A `RegExp` is used as is, a string ending with `/` matches a
486
+ * scope/prefix (`'@acme/'` → every `@acme/*` package), any other string is an
487
+ * exact package name.
488
+ */ function compilePackagePatterns(extra) {
489
+ const patterns = [...SHARED_STATE_PACKAGE_PATTERNS];
490
+ for (const item of extra ?? []) if (item instanceof RegExp) patterns.push(item);
491
+ else if (item.endsWith("/")) patterns.push(new RegExp(`^${escapeRegExp(item)}`));
492
+ else patterns.push(new RegExp(`^${escapeRegExp(item)}$`));
493
+ return patterns;
494
+ }
495
+ const BUILTINS = new Set(node_module.builtinModules);
496
+ /** Bare npm package name of an import specifier (`vue/server-renderer` → `vue`). */ function npmPackageName(id) {
497
+ if (!id || id.startsWith(".") || id.startsWith("/") || id.startsWith("\0")) return;
498
+ if (id.startsWith("node:") || id.startsWith("data:") || id.startsWith("virtual:")) return;
499
+ const parts = id.split("/");
500
+ const name = id.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
501
+ if (!name || id.startsWith("@") && parts.length < 2) return;
502
+ return BUILTINS.has(name) ? void 0 : name;
503
+ }
504
+ /**
505
+ * Package names the bundler inlined into the output, derived from the module
506
+ * ids of the emitted chunks. Ids under `node_modules` name their package after
507
+ * the LAST `node_modules/` segment, which also yields the right name for pnpm
508
+ * store paths (`…/node_modules/.pnpm/pkg@1.0.0/node_modules/pkg/index.mjs`).
509
+ * Virtual ids and app sources (no `node_modules`) are ignored.
510
+ */ function bundledPackagesFromModuleIds(moduleIds) {
511
+ const packages = /* @__PURE__ */ new Set();
512
+ const marker = "/node_modules/";
513
+ for (const raw of moduleIds) {
514
+ if (!raw || raw.startsWith("\0")) continue;
515
+ const id = raw.replaceAll("\\", "/");
516
+ const at = id.lastIndexOf(marker);
517
+ if (at === -1) continue;
518
+ const name = npmPackageName(id.slice(at + 14));
519
+ if (name) packages.add(name);
520
+ }
521
+ return packages;
522
+ }
523
+ function readPkgJson(dir) {
524
+ try {
525
+ return JSON.parse((0, node_fs.readFileSync)((0, node_path.join)(dir, "package.json"), "utf8"));
526
+ } catch {
527
+ return;
528
+ }
529
+ }
530
+ /**
531
+ * Locate `name`'s package directory the way Node would from `fromDir`:
532
+ * `<fromDir>/node_modules/<name>`, then each ancestor's `node_modules`.
533
+ * Returns the realpath so pnpm's symlinked layout resolves to the store entry,
534
+ * whose own `node_modules` sibling holds the package's dependencies.
535
+ */ function findPackageDir(name, fromDir) {
536
+ let dir = fromDir;
537
+ for (;;) {
538
+ const candidate = (0, node_path.join)(dir, "node_modules", name);
539
+ if ((0, node_fs.existsSync)((0, node_path.join)(candidate, "package.json"))) try {
540
+ return (0, node_fs.realpathSync)(candidate);
541
+ } catch {
542
+ return candidate;
543
+ }
544
+ const parent = (0, node_path.dirname)(dir);
545
+ if (parent === dir) return;
546
+ dir = parent;
547
+ }
548
+ }
549
+ /** Deps of `pkg` that are watched packages AND were bundled into the output. */ function splitDepsOf(pkg, patterns, bundledPackages) {
550
+ const names = /* @__PURE__ */ new Set();
551
+ for (const group of [
552
+ pkg.dependencies,
553
+ pkg.peerDependencies,
554
+ pkg.optionalDependencies
555
+ ]) for (const dep of Object.keys(group ?? {})) if (bundledPackages.has(dep) && patterns.some((re) => re.test(dep))) names.add(dep);
556
+ return [...names];
557
+ }
558
+ /** Upper bound on visited packages — keeps a pathological tree from stalling the build. */ const MAX_VISITED = 5e3;
559
+ /**
560
+ * Given the bare specifiers left in the SSR build output (i.e. the packages the
561
+ * bundler externalized) and the package names it bundled, find every package
562
+ * Node will load at runtime that depends on a bundled shared-state package.
563
+ * Such a package resolves its own copy from `node_modules` while the bundled
564
+ * copy lives inside `dist/server` — two instances, split module state.
565
+ *
566
+ * Walks the dependency tree beneath each external (Node loads a package's deps
567
+ * from its own resolution root, so transitive consumers matter too), but never
568
+ * descends into a bundled dep: the fix belongs to the external consumer.
569
+ * Packages that are not installed are skipped silently.
570
+ */ function findSplitPackages(opts) {
571
+ const patterns = opts.patterns ?? SHARED_STATE_PACKAGE_PATTERNS;
572
+ const found = [];
573
+ const visited = /* @__PURE__ */ new Set();
574
+ const queue = [];
575
+ for (const id of opts.externalIds) {
576
+ const name = npmPackageName(id);
577
+ if (name && !visited.has(`root:${name}`)) {
578
+ visited.add(`root:${name}`);
579
+ queue.push({
580
+ name,
581
+ fromDir: opts.root,
582
+ via: []
583
+ });
584
+ }
585
+ }
586
+ while (queue.length > 0 && visited.size < MAX_VISITED) {
587
+ const { name, fromDir, via } = queue.shift();
588
+ const dir = findPackageDir(name, fromDir);
589
+ if (!dir || visited.has(dir)) continue;
590
+ visited.add(dir);
591
+ const pkg = readPkgJson(dir);
592
+ if (!pkg) continue;
593
+ const splitDeps = splitDepsOf(pkg, patterns, opts.bundledPackages);
594
+ if (splitDeps.length > 0) found.push({
595
+ name,
596
+ via,
597
+ splitDeps
598
+ });
599
+ for (const dep of Object.keys(pkg.dependencies ?? {})) if (!opts.bundledPackages.has(dep)) queue.push({
600
+ name: dep,
601
+ fromDir: dir,
602
+ via: [...via, name]
603
+ });
604
+ }
605
+ return found;
606
+ }
607
+ /** Human-readable build warning for {@link findSplitPackages} results. */ function formatSplitPackagesWarning(splits) {
608
+ const lines = splits.map((s) => {
609
+ const chain = s.via.length > 0 ? ` (loaded via external ${s.via.join(" → ")})` : "";
610
+ const deps = s.splitDeps.map((d) => `${d} (bundled)`).join(", ");
611
+ return ` - ${s.name}${chain} depends on ${deps}`;
612
+ });
613
+ const direct = [...new Set(splits.map((s) => s.via.length > 0 ? s.via[0] : s.name))];
614
+ return [
615
+ "These externalized packages depend on packages that are bundled into dist/server, so Node will load a second copy of them from node_modules:",
616
+ ...lines,
617
+ "Two copies of a package split its module state — event-context slots, DI registries and class identity (instanceof) stop matching across the boundary, in production only. Symptoms: \"Cannot read properties of undefined (reading 'headers')\" inside a wooks composable, or a database adapter creating an empty table where a managed view was declared.",
618
+ `Fix: keep each shared package and everything that depends on it on the same side — add ${direct.map((d) => `'${d}'`).join(", ")} to ssr.noExternal (or drop them from ssr.external / ssrExternal), or externalize the whole family (for example every @atscript/* package, or the whole moost/wooks runtime) via ssr.external.`,
619
+ "Set ssrExternalCheck: false in moostVite() to silence this check, or ssrExternalCheck: { packages: [...] } to watch more packages."
620
+ ].join("\n");
621
+ }
622
+
378
623
  //#endregion
379
624
  //#region packages/vite/src/moost-vite.ts
380
625
  /** Regex checks */ const REG_HAS_EXPORT_CLASS = /(^\s*@(Injectable|Controller)\()/m;
@@ -408,6 +653,22 @@ function generatedServerEntry(root) {
408
653
  (0, node_fs.writeFileSync)(entryPath, DEFAULT_SERVER_ENTRY_CODE);
409
654
  return entryPath;
410
655
  }
656
+ /**
657
+ * Read the emitted SSR bundle: the packages inlined into it (from every chunk's
658
+ * module ids) and the bare specifiers it still imports (the externalized ones).
659
+ */ function scanSsrBundle(bundle) {
660
+ const externalIds = /* @__PURE__ */ new Set();
661
+ const moduleIds = [];
662
+ for (const chunk of Object.values(bundle)) {
663
+ if (chunk.type !== "chunk") continue;
664
+ moduleIds.push(...chunk.moduleIds ?? []);
665
+ for (const id of [...chunk.imports ?? [], ...chunk.dynamicImports ?? []]) if (!(id in bundle) && npmPackageName(id)) externalIds.add(id);
666
+ }
667
+ return {
668
+ externalIds,
669
+ bundledPackages: bundledPackagesFromModuleIds(moduleIds)
670
+ };
671
+ }
411
672
  function moostVite(options) {
412
673
  const isTest = process.env.NODE_ENV === "test";
413
674
  const isProd = process.env.NODE_ENV === "production";
@@ -433,6 +694,11 @@ function moostVite(options) {
433
694
  */ let bootGeneration = 0;
434
695
  let bootingGeneration = 0;
435
696
  /** Whether the HTTP listen() patch has ever captured a middleware (i.e. this is an HTTP app). */ let httpCaptured = false;
697
+ /**
698
+ * The `app.init()` promises the current boot handed us (see `captureMoostInit`) —
699
+ * several when one entry boots several apps, none for a boot that never calls
700
+ * `init()`. Reset by `beginBoot()`, consumed by `settleBootInit()`.
701
+ */ let bootInitPromises = [];
436
702
  /** Module IDs awaiting DI cleanup — consumed in runReload; see ejectApp. */ let pendingCleanup = null;
437
703
  /** In middleware mode: maps req → next() for the onNoMatch callback */ const pendingNextMap = /* @__PURE__ */ new WeakMap();
438
704
  const adapters = isTest ? [] : [
@@ -502,6 +768,43 @@ function moostVite(options) {
502
768
  reloadRequired = true;
503
769
  };
504
770
  patchMoostHandlerLogging();
771
+ if (!isTest && !isProd) captureMoostInit((promise) => {
772
+ if (bootingGeneration !== bootGeneration) {
773
+ logger.error(`⚠️ A stale Moost boot called init() (boot generation ${bootingGeneration}, latest ${bootGeneration}) — an HMR reload race; a follow-up reload will replace it.`);
774
+ return;
775
+ }
776
+ bootInitPromises.push(promise);
777
+ });
778
+ /** Marks the boot about to run as authoritative for the current generation. */ const beginBoot = () => {
779
+ bootingGeneration = bootGeneration;
780
+ bootInitPromises = [];
781
+ };
782
+ /**
783
+ * Finishes a boot: awaits the `init()` promise(s) the entry that just executed
784
+ * handed us, so the boot is only "done" once the app is fully initialized —
785
+ * every controller bound, every `@MoostInit` hook run (see `captureMoostInit`).
786
+ * Owns `bootError`: cleared on success, set and logged when an init rejected;
787
+ * returns whether the boot succeeded.
788
+ */ const settleBootInit = async () => {
789
+ const initPromises = bootInitPromises;
790
+ bootInitPromises = [];
791
+ try {
792
+ await Promise.all(initPromises);
793
+ bootError = null;
794
+ return true;
795
+ } catch (error) {
796
+ bootError = error;
797
+ logger.error(`✖️ Moost app init failed: ${error.message}`);
798
+ return false;
799
+ }
800
+ };
801
+ /** Answers a request while `bootError` is set (see the gate in configureServer). */ const answerBootError = (res) => {
802
+ res.statusCode = 502;
803
+ res.setHeader("Content-Type", "text/plain");
804
+ res.end(`Moost app failed to load: ${bootError.message}`);
805
+ };
806
+ /** Set by the middleware-mode `config` hook: the consumer externalized the runtime themselves. */ let runtimeExternalized = false;
807
+ let resolvedConfig;
505
808
  const pluginConfig = {
506
809
  name: PLUGIN_NAME,
507
810
  enforce: "pre",
@@ -534,14 +837,8 @@ function moostVite(options) {
534
837
  let ssrNoExternal = userNoExternal === true ? true : userNoExternal !== void 0 ? [...Array.isArray(userNoExternal) ? userNoExternal : [userNoExternal], ourPlugin] : isBuild ? true : [ourPlugin];
535
838
  const userExternal = cfg.ssr?.external;
536
839
  const ssrExternal = isBuild ? [...Array.isArray(userExternal) ? userExternal : [], ...options.ssrExternal ?? []] : void 0;
537
- const runtimeNoExternal = [
538
- /^moost($|\/)/,
539
- /^@moostjs\//,
540
- /^@wooksjs\//,
541
- /^wooks($|\/)/
542
- ];
543
- const runtimeExternalized = (ssrExternal ?? []).some((e) => typeof e === "string" && runtimeNoExternal.some((re) => re.test(e)));
544
- if (isBuild && Array.isArray(ssrNoExternal) && !runtimeExternalized) ssrNoExternal = [...ssrNoExternal, ...runtimeNoExternal];
840
+ runtimeExternalized = (ssrExternal ?? []).some((e) => typeof e === "string" && RUNTIME_PACKAGE_PATTERNS.some((re) => re.test(e)));
841
+ if (isBuild && Array.isArray(ssrNoExternal) && !runtimeExternalized) ssrNoExternal = [...ssrNoExternal, ...RUNTIME_PACKAGE_PATTERNS];
545
842
  return {
546
843
  ...options.ssrEntry && { appType: "custom" },
547
844
  ssr: {
@@ -619,6 +916,19 @@ function moostVite(options) {
619
916
  ssrFetchForwarding: options.ssrFetchForwarding
620
917
  };
621
918
  config.__moostViteHttpRef = moostHttpRef;
919
+ resolvedConfig = config;
920
+ },
921
+ generateBundle(_outputOptions, bundle) {
922
+ const cfg = resolvedConfig;
923
+ if (!cfg || cfg.command !== "build" || !options.middleware || options.ssrExternalCheck === false || this.environment?.name !== "ssr") return;
924
+ const { externalIds, bundledPackages } = scanSsrBundle(bundle);
925
+ const splits = findSplitPackages({
926
+ root: cfg.root,
927
+ externalIds,
928
+ bundledPackages,
929
+ patterns: compilePackagePatterns(typeof options.ssrExternalCheck === "object" ? options.ssrExternalCheck.packages : void 0)
930
+ });
931
+ if (splits.length > 0) cfg.logger.warn(`\n[${PLUGIN_NAME}] ${formatSplitPackagesWarning(splits)}\n`);
622
932
  },
623
933
  async transform(code, id) {
624
934
  if (!id.endsWith(".ts")) return null;
@@ -656,7 +966,7 @@ function moostVite(options) {
656
966
  reloadRequired = false;
657
967
  reloadPromise = null;
658
968
  pendingCleanup = null;
659
- bootingGeneration = bootGeneration;
969
+ beginBoot();
660
970
  const runReload = () => {
661
971
  reloadRequired = false;
662
972
  console.log();
@@ -664,16 +974,15 @@ function moostVite(options) {
664
974
  console.log();
665
975
  return (async () => {
666
976
  try {
667
- bootingGeneration = bootGeneration;
977
+ beginBoot();
668
978
  const cleanupInstances = pendingCleanup ?? void 0;
669
979
  pendingCleanup = null;
670
- moostRestartCleanup(adapters, options.onEject, cleanupInstances);
980
+ await moostRestartCleanup(adapters, options.onEject, cleanupInstances);
671
981
  for (const adapter of adapters) if (adapter.detected) await adapter.init();
672
982
  const entryModule = await getEntryModule(server.environments.ssr.moduleGraph);
673
983
  if (entryModule?.transformResult) server.environments.ssr.moduleGraph.invalidateModule(entryModule);
674
984
  await ssrImport(options.entry);
675
- bootError = null;
676
- if (httpCaptured && !moostMiddleware) logger.error(`⚠️ Moost app reloaded but no HTTP middleware was captured — the entry did not re-run listen().`);
985
+ if (await settleBootInit() && httpCaptured && !moostMiddleware) logger.error(`⚠️ Moost app reloaded but no HTTP middleware was captured — the entry did not re-run listen().`);
677
986
  } catch (error) {
678
987
  bootError = error;
679
988
  logger.error(`✖️ Failed to reload Moost App: ${error.message}`);
@@ -690,11 +999,16 @@ function moostVite(options) {
690
999
  }
691
1000
  };
692
1001
  for (const adapter of adapters) adapter.ssrLoadModule = ssrImport;
693
- moostRestartCleanup(adapters, options.onEject);
1002
+ await moostRestartCleanup(adapters, options.onEject);
694
1003
  await ssrImport(options.entry);
1004
+ await settleBootInit();
695
1005
  server.middlewares.use(async (req, res, next) => {
696
1006
  await drainReload();
697
1007
  if (options.middleware && prefixes && !matchesPrefix(req.url || "", prefixes)) return next();
1008
+ if (bootError) {
1009
+ answerBootError(res);
1010
+ return;
1011
+ }
698
1012
  if (moostMiddleware) {
699
1013
  if (options.middleware) {
700
1014
  pendingNextMap.set(req, next);
@@ -703,12 +1017,6 @@ function moostVite(options) {
703
1017
  }
704
1018
  return moostMiddleware(req, res);
705
1019
  }
706
- if (bootError) {
707
- res.statusCode = 502;
708
- res.setHeader("Content-Type", "text/plain");
709
- res.end(`Moost app failed to load: ${bootError.message}`);
710
- return;
711
- }
712
1020
  next();
713
1021
  });
714
1022
  },
@@ -748,6 +1056,10 @@ function moostVite(options) {
748
1056
  const url = req.originalUrl || req.url || "/";
749
1057
  try {
750
1058
  await drainReload();
1059
+ if (bootError) {
1060
+ answerBootError(res);
1061
+ return;
1062
+ }
751
1063
  let template = await fs.readFile((0, node_path.resolve)(server.config.root, "index.html"), "utf8");
752
1064
  template = await server.transformIndexHtml(url, template);
753
1065
  const { render } = await server.ssrLoadModule(options.ssrEntry);
package/dist/index.d.ts CHANGED
@@ -173,6 +173,33 @@ interface TMoostViteDevOptions {
173
173
  * instead of evaluated by Vite's ESM-only SSR module runner.
174
174
  */
175
175
  ssrExternal?: string[];
176
+ /**
177
+ * Verify the production SSR build for split shared-state packages.
178
+ *
179
+ * After the middleware-mode `vite build`, the plugin compares what ended up
180
+ * inside `dist/server` (the bundled packages) with the bare imports left in
181
+ * the output (the externalized ones) and walks the externals' dependency
182
+ * trees. When an external package depends on a bundled watched package, Node
183
+ * loads a second copy of it at runtime — event-context slots, DI registries
184
+ * and `instanceof` checks stop matching in production only — so the build
185
+ * prints a warning naming the package and the fix.
186
+ *
187
+ * Watched by default: `moost`, `@moostjs/*`, `wooks`, `@wooksjs/*` and
188
+ * `@atscript/*`. Pass `{ packages: [...] }` to watch more: an exact package
189
+ * name (`'lodash'`), a scope/prefix ending with `/` (`'@acme/'`), or a
190
+ * `RegExp`.
191
+ *
192
+ * Default: `true`. Set `false` to silence the check.
193
+ */
194
+ ssrExternalCheck?: boolean | TSSRExternalCheckOptions;
195
+ }
196
+ /** Extra packages the SSR split check should watch on top of its defaults. */
197
+ interface TSSRExternalCheckOptions {
198
+ /**
199
+ * Package names (`'lodash'`), scope/prefix strings ending with `/`
200
+ * (`'@acme/'`) or `RegExp`s, added to the watched set.
201
+ */
202
+ packages?: (string | RegExp)[];
176
203
  }
177
204
  declare function moostVite(options: TMoostViteDevOptions): PluginOption;
178
205
 
package/dist/index.mjs CHANGED
@@ -1,9 +1,9 @@
1
- import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
- import { resolve } from "node:path";
1
+ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
3
  import { createServerModuleRunner } from "vite";
4
4
  import MagicString from "magic-string";
5
5
  import { builtinModules } from "node:module";
6
- import { Moost, clearGlobalWooks, createLogger, getMoostInfact, getMoostMate } from "moost";
6
+ import { Moost, clearGlobalWooks, createLogger, disposeInstances, getMoostInfact, getMoostMate } from "moost";
7
7
 
8
8
  //#region packages/vite/src/utils.ts
9
9
  const PLUGIN_NAME = "moost-vite";
@@ -220,19 +220,63 @@ function _define_property(obj, key, value) {
220
220
  * A reference to a shared LogsStorage instance that persists
221
221
  * across init cycles in dev mode.
222
222
  */ let logsStorage;
223
+ /** Whether {@link patchMoostHandlerLogging} already installed its wrappers in this process. */ let installed = false;
223
224
  /**
224
- * Patches Moost’s .init() and .logMappedHandler() methods so that, during dev:
225
+ * The slot {@link captureMoostInit} fills: receives every `init()` promise the
226
+ * wrapper below produces. Unset in prod/test, where nothing consumes a capture.
227
+ */ let onInitCaptured;
228
+ /**
229
+ * Hands every `Moost.prototype.init()` promise to `onCapture`, letting a boot
230
+ * await the app's **initialization** instead of only its module evaluation.
231
+ *
232
+ * The documented entry shape is `app.adapter(http).listen(port)` followed by an
233
+ * **un-awaited** `app.init()`: the HTTP middleware is therefore captured *before*
234
+ * init finishes, and nothing else observes a rejecting init — a bind error, a DI
235
+ * audit error or a throwing `@MoostInit` hook would leave the dev server serving
236
+ * a half-booted app (the routes bound before the failure answer 200, the rest
237
+ * fall through to the SPA fallback) instead of the plugin's 502. That is also
238
+ * why the plugin's `bootError` gate runs BEFORE the captured middleware, and why
239
+ * the SSR fallback checks it too: `listen()` ran first and left both a middleware
240
+ * and the local-fetch hook behind.
241
+ *
242
+ * Patches the plugin's **own** `moost` import, exactly like the handler logging
243
+ * does: in dev the runtime is externalized, so the plugin and the app share one
244
+ * native `moost` instance (the premise the DI eject in `restart-cleanup` rests on
245
+ * too, and what `ssrExternalCheck` warns about when it stops holding). Unlike the
246
+ * adapters there is nothing to re-establish per reload — a static import is never
247
+ * re-evaluated — and unlike them it must NOT be pulled through the SSR module
248
+ * runner: importing `moost` there registers it as the runner's first module and
249
+ * makes subsequent hot updates re-evaluate the entry only, serving every one of
250
+ * its dependencies (controllers included) from the stale cache.
251
+ *
252
+ * There is exactly ONE `init` wrapper per process (installed by
253
+ * {@link patchMoostHandlerLogging}) and this only fills its slot, so the newest
254
+ * plugin instance wins: Vite re-imports the config on every config-file
255
+ * `server.restart()`, re-running the plugin factory, and a per-instance wrapper
256
+ * would stack on the previous one and keep the dead instance's scope alive.
257
+ *
258
+ * @param onCapture receives each captured init promise (already marked handled).
259
+ */ function captureMoostInit(onCapture) {
260
+ onInitCaptured = onCapture;
261
+ }
262
+ /**
263
+ * Patches Moost’s .init() and .logMappedHandler() methods — once per process —
264
+ * so that, during dev:
225
265
  * - Only newly added handlers are logged normally.
226
266
  * - Removed handlers are logged/stroked out to indicate removal.
227
267
  * - Prevents repeated logs from flooding the console across multiple hot reloads.
268
+ * - Every init() promise reaches the {@link captureMoostInit} slot, if filled.
228
269
  */ function patchMoostHandlerLogging() {
270
+ if (installed) return;
271
+ installed = true;
229
272
  const origInit = Moost.prototype.init;
273
+ const origLogMappedHandler = Moost.prototype.logMappedHandler;
230
274
  /**
231
- * Monkey-patch Moost.init:
275
+ * The logging body of the patched Moost.init:
232
276
  * 1. Start a new recording cycle in LogsStorage.
233
277
  * 2. Call the real .init().
234
278
  * 3. End the recording, logging any removed handlers.
235
- */ Moost.prototype.init = async function init() {
279
+ */ const initWithLogging = async function() {
236
280
  if (!logsStorage) logsStorage = new LogsStorage();
237
281
  logsStorage.startRecording();
238
282
  await origInit.call(this);
@@ -240,7 +284,14 @@ function _define_property(obj, key, value) {
240
284
  origLogMappedHandler.call(this, item.eventName, item.classConstructor, item.method, true, "❌ ");
241
285
  });
242
286
  };
243
- const origLogMappedHandler = Moost.prototype.logMappedHandler;
287
+ Moost.prototype.init = function init() {
288
+ const promise = initWithLogging.call(this);
289
+ if (onInitCaptured) {
290
+ promise.catch(() => {});
291
+ onInitCaptured(promise);
292
+ }
293
+ return promise;
294
+ };
244
295
  /**
245
296
  * Monkey-patch Moost.logMappedHandler:
246
297
  * If we haven’t seen this particular route+class+method combination
@@ -260,14 +311,23 @@ function _define_property(obj, key, value) {
260
311
  /**
261
312
  * Clean up Moost’s global containers and optionally remove specific instances from the registry.
262
313
  *
314
+ * Every instance actually removed from a registry is **disposed** before the
315
+ * caches are dropped: its `@MoostDispose` hooks (or `Symbol.asyncDispose` /
316
+ * `Symbol.dispose`) are awaited, so a singleton owning a connection, consumer,
317
+ * timer or file handle releases it instead of leaking one copy per reload. An
318
+ * instance kept by an `onEject` veto is never disposed. A failing hook is
319
+ * warned about and the reload continues.
320
+ *
263
321
  * @param {Set<string>} [cleanupInstances] A set of module IDs to remove from the registry.
264
- */ function moostRestartCleanup(adapters, onEject, cleanupInstances) {
322
+ * @returns the instances that were ejected (and therefore disposed)
323
+ */ async function moostRestartCleanup(adapters, onEject, cleanupInstances) {
265
324
  const logger = getLogger();
266
325
  const infact = getMoostInfact();
267
326
  const { registry, scopes } = infact;
268
327
  const registries = [registry, ...Object.values(scopes)];
269
328
  infact._cleanup();
270
329
  const mate = getMoostMate();
330
+ /** Instances removed from a registry by this run — disposed below, before the caches drop. */ const ejected = [];
271
331
  if (cleanupInstances) {
272
332
  for (const reg of registries) {
273
333
  for (const key of Object.getOwnPropertySymbols(reg)) {
@@ -276,6 +336,7 @@ function _define_property(obj, key, value) {
276
336
  if (viteId && cleanupInstances.has(viteId)) {
277
337
  logger.debug(`🔃 Replacing "${constructorName(instance)}"`);
278
338
  delete reg[key];
339
+ ejected.push(instance);
279
340
  }
280
341
  }
281
342
  for (const key of Object.getOwnPropertySymbols(reg)) {
@@ -283,25 +344,39 @@ function _define_property(obj, key, value) {
283
344
  scanParams(instance, (type) => {
284
345
  if ((type === Moost || type instanceof Moost || type.prototype instanceof Moost) && (!onEject || onEject(instance, type))) {
285
346
  delete reg[key];
347
+ ejected.push(instance);
286
348
  logger.debug(`✖️ Ejecting "${constructorName(instance)}" (depends on re-instantiated "Moost")`);
287
349
  return true;
288
350
  }
289
351
  for (const adapter of adapters) if (adapter.compare(type) && (!onEject || onEject(instance, type))) {
290
352
  delete reg[key];
353
+ ejected.push(instance);
291
354
  logger.debug(`✖️ Ejecting "${constructorName(instance)}" (depends on re-instantiated "${adapter.constructor.name}")`);
292
355
  return true;
293
356
  }
294
357
  });
295
358
  }
296
- clearDependantRegistry(reg, onEject);
359
+ clearDependantRegistry(reg, onEject, ejected);
297
360
  }
298
361
  infact.registry = registry;
299
362
  infact.scopes = scopes;
300
363
  }
364
+ await disposeEjected(ejected);
301
365
  getMoostMate()._cleanup();
302
366
  clearGlobalWooks();
367
+ return ejected;
303
368
  }
304
- function clearDependantRegistry(registry, onEject) {
369
+ /** Awaits the `@MoostDispose` hooks of the ejected instances; never throws. */ async function disposeEjected(ejected) {
370
+ if (ejected.length === 0) return;
371
+ const logger = getLogger();
372
+ const { disposed, errors } = await disposeInstances(ejected, {
373
+ logger,
374
+ onError: "warn"
375
+ });
376
+ for (const instance of disposed) logger.debug(`♻️ Disposed "${constructorName(instance)}"`);
377
+ for (const e of errors) logger.warn(`⚠️ Dispose hook "${constructorName(e.instance)}.${String(e.method)}" failed — the reload continues`);
378
+ }
379
+ function clearDependantRegistry(registry, onEject, ejected) {
305
380
  const logger = getLogger();
306
381
  const objSet = /* @__PURE__ */ new Set();
307
382
  let somethingIsDeleted = true;
@@ -313,7 +388,10 @@ function clearDependantRegistry(registry, onEject) {
313
388
  }
314
389
  for (const key of Object.getOwnPropertySymbols(registry)) {
315
390
  const instance = registry[key];
316
- if (checkAndEject(instance, objSet, onEject, registry, key, logger)) somethingIsDeleted = true;
391
+ if (checkAndEject(instance, objSet, onEject, registry, key, logger)) {
392
+ ejected.push(instance);
393
+ somethingIsDeleted = true;
394
+ }
317
395
  }
318
396
  }
319
397
  }
@@ -346,6 +424,173 @@ function constructorName(i) {
346
424
  return Object.getPrototypeOf(i).constructor.name;
347
425
  }
348
426
 
427
+ //#endregion
428
+ //#region packages/vite/src/ssr-externals-check.ts
429
+ /**
430
+ * Packages that make up the moost/wooks runtime. They mint per-module Symbol
431
+ * slot keys, so the whole set must resolve to a single module instance in the
432
+ * production SSR graph. This is the scope of the single-instance *guard*
433
+ * (force-bundling under an explicit `noExternal` list) — the build *check*
434
+ * watches the wider {@link SHARED_STATE_PACKAGE_PATTERNS}.
435
+ */ const RUNTIME_PACKAGE_PATTERNS = [
436
+ /^moost($|\/)/,
437
+ /^@moostjs\//,
438
+ /^@wooksjs\//,
439
+ /^wooks($|\/)/
440
+ ];
441
+ /**
442
+ * Packages the build check watches by default: the moost/wooks runtime plus the
443
+ * `@atscript/*` family. All of them keep module-level state — Symbol slot keys,
444
+ * DI/model registries, class identity used by `instanceof` — so a second copy
445
+ * loaded by Node breaks lookups that the bundled copy filled in.
446
+ *
447
+ * This is deliberately wider than {@link RUNTIME_PACKAGE_PATTERNS}: the guard
448
+ * that force-bundles packages covers the runtime only, while the check merely
449
+ * reports what the output shows.
450
+ */ const SHARED_STATE_PACKAGE_PATTERNS = [...RUNTIME_PACKAGE_PATTERNS, /^@atscript\//];
451
+ function escapeRegExp(input) {
452
+ return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
453
+ }
454
+ /**
455
+ * Build the watched-package patterns for the check: the defaults plus the
456
+ * user's extras. A `RegExp` is used as is, a string ending with `/` matches a
457
+ * scope/prefix (`'@acme/'` → every `@acme/*` package), any other string is an
458
+ * exact package name.
459
+ */ function compilePackagePatterns(extra) {
460
+ const patterns = [...SHARED_STATE_PACKAGE_PATTERNS];
461
+ for (const item of extra ?? []) if (item instanceof RegExp) patterns.push(item);
462
+ else if (item.endsWith("/")) patterns.push(new RegExp(`^${escapeRegExp(item)}`));
463
+ else patterns.push(new RegExp(`^${escapeRegExp(item)}$`));
464
+ return patterns;
465
+ }
466
+ const BUILTINS = new Set(builtinModules);
467
+ /** Bare npm package name of an import specifier (`vue/server-renderer` → `vue`). */ function npmPackageName(id) {
468
+ if (!id || id.startsWith(".") || id.startsWith("/") || id.startsWith("\0")) return;
469
+ if (id.startsWith("node:") || id.startsWith("data:") || id.startsWith("virtual:")) return;
470
+ const parts = id.split("/");
471
+ const name = id.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
472
+ if (!name || id.startsWith("@") && parts.length < 2) return;
473
+ return BUILTINS.has(name) ? void 0 : name;
474
+ }
475
+ /**
476
+ * Package names the bundler inlined into the output, derived from the module
477
+ * ids of the emitted chunks. Ids under `node_modules` name their package after
478
+ * the LAST `node_modules/` segment, which also yields the right name for pnpm
479
+ * store paths (`…/node_modules/.pnpm/pkg@1.0.0/node_modules/pkg/index.mjs`).
480
+ * Virtual ids and app sources (no `node_modules`) are ignored.
481
+ */ function bundledPackagesFromModuleIds(moduleIds) {
482
+ const packages = /* @__PURE__ */ new Set();
483
+ const marker = "/node_modules/";
484
+ for (const raw of moduleIds) {
485
+ if (!raw || raw.startsWith("\0")) continue;
486
+ const id = raw.replaceAll("\\", "/");
487
+ const at = id.lastIndexOf(marker);
488
+ if (at === -1) continue;
489
+ const name = npmPackageName(id.slice(at + 14));
490
+ if (name) packages.add(name);
491
+ }
492
+ return packages;
493
+ }
494
+ function readPkgJson(dir) {
495
+ try {
496
+ return JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
497
+ } catch {
498
+ return;
499
+ }
500
+ }
501
+ /**
502
+ * Locate `name`'s package directory the way Node would from `fromDir`:
503
+ * `<fromDir>/node_modules/<name>`, then each ancestor's `node_modules`.
504
+ * Returns the realpath so pnpm's symlinked layout resolves to the store entry,
505
+ * whose own `node_modules` sibling holds the package's dependencies.
506
+ */ function findPackageDir(name, fromDir) {
507
+ let dir = fromDir;
508
+ for (;;) {
509
+ const candidate = join(dir, "node_modules", name);
510
+ if (existsSync(join(candidate, "package.json"))) try {
511
+ return realpathSync(candidate);
512
+ } catch {
513
+ return candidate;
514
+ }
515
+ const parent = dirname(dir);
516
+ if (parent === dir) return;
517
+ dir = parent;
518
+ }
519
+ }
520
+ /** Deps of `pkg` that are watched packages AND were bundled into the output. */ function splitDepsOf(pkg, patterns, bundledPackages) {
521
+ const names = /* @__PURE__ */ new Set();
522
+ for (const group of [
523
+ pkg.dependencies,
524
+ pkg.peerDependencies,
525
+ pkg.optionalDependencies
526
+ ]) for (const dep of Object.keys(group ?? {})) if (bundledPackages.has(dep) && patterns.some((re) => re.test(dep))) names.add(dep);
527
+ return [...names];
528
+ }
529
+ /** Upper bound on visited packages — keeps a pathological tree from stalling the build. */ const MAX_VISITED = 5e3;
530
+ /**
531
+ * Given the bare specifiers left in the SSR build output (i.e. the packages the
532
+ * bundler externalized) and the package names it bundled, find every package
533
+ * Node will load at runtime that depends on a bundled shared-state package.
534
+ * Such a package resolves its own copy from `node_modules` while the bundled
535
+ * copy lives inside `dist/server` — two instances, split module state.
536
+ *
537
+ * Walks the dependency tree beneath each external (Node loads a package's deps
538
+ * from its own resolution root, so transitive consumers matter too), but never
539
+ * descends into a bundled dep: the fix belongs to the external consumer.
540
+ * Packages that are not installed are skipped silently.
541
+ */ function findSplitPackages(opts) {
542
+ const patterns = opts.patterns ?? SHARED_STATE_PACKAGE_PATTERNS;
543
+ const found = [];
544
+ const visited = /* @__PURE__ */ new Set();
545
+ const queue = [];
546
+ for (const id of opts.externalIds) {
547
+ const name = npmPackageName(id);
548
+ if (name && !visited.has(`root:${name}`)) {
549
+ visited.add(`root:${name}`);
550
+ queue.push({
551
+ name,
552
+ fromDir: opts.root,
553
+ via: []
554
+ });
555
+ }
556
+ }
557
+ while (queue.length > 0 && visited.size < MAX_VISITED) {
558
+ const { name, fromDir, via } = queue.shift();
559
+ const dir = findPackageDir(name, fromDir);
560
+ if (!dir || visited.has(dir)) continue;
561
+ visited.add(dir);
562
+ const pkg = readPkgJson(dir);
563
+ if (!pkg) continue;
564
+ const splitDeps = splitDepsOf(pkg, patterns, opts.bundledPackages);
565
+ if (splitDeps.length > 0) found.push({
566
+ name,
567
+ via,
568
+ splitDeps
569
+ });
570
+ for (const dep of Object.keys(pkg.dependencies ?? {})) if (!opts.bundledPackages.has(dep)) queue.push({
571
+ name: dep,
572
+ fromDir: dir,
573
+ via: [...via, name]
574
+ });
575
+ }
576
+ return found;
577
+ }
578
+ /** Human-readable build warning for {@link findSplitPackages} results. */ function formatSplitPackagesWarning(splits) {
579
+ const lines = splits.map((s) => {
580
+ const chain = s.via.length > 0 ? ` (loaded via external ${s.via.join(" → ")})` : "";
581
+ const deps = s.splitDeps.map((d) => `${d} (bundled)`).join(", ");
582
+ return ` - ${s.name}${chain} depends on ${deps}`;
583
+ });
584
+ const direct = [...new Set(splits.map((s) => s.via.length > 0 ? s.via[0] : s.name))];
585
+ return [
586
+ "These externalized packages depend on packages that are bundled into dist/server, so Node will load a second copy of them from node_modules:",
587
+ ...lines,
588
+ "Two copies of a package split its module state — event-context slots, DI registries and class identity (instanceof) stop matching across the boundary, in production only. Symptoms: \"Cannot read properties of undefined (reading 'headers')\" inside a wooks composable, or a database adapter creating an empty table where a managed view was declared.",
589
+ `Fix: keep each shared package and everything that depends on it on the same side — add ${direct.map((d) => `'${d}'`).join(", ")} to ssr.noExternal (or drop them from ssr.external / ssrExternal), or externalize the whole family (for example every @atscript/* package, or the whole moost/wooks runtime) via ssr.external.`,
590
+ "Set ssrExternalCheck: false in moostVite() to silence this check, or ssrExternalCheck: { packages: [...] } to watch more packages."
591
+ ].join("\n");
592
+ }
593
+
349
594
  //#endregion
350
595
  //#region packages/vite/src/moost-vite.ts
351
596
  /** Regex checks */ const REG_HAS_EXPORT_CLASS = /(^\s*@(Injectable|Controller)\()/m;
@@ -379,6 +624,22 @@ function generatedServerEntry(root) {
379
624
  writeFileSync(entryPath, DEFAULT_SERVER_ENTRY_CODE);
380
625
  return entryPath;
381
626
  }
627
+ /**
628
+ * Read the emitted SSR bundle: the packages inlined into it (from every chunk's
629
+ * module ids) and the bare specifiers it still imports (the externalized ones).
630
+ */ function scanSsrBundle(bundle) {
631
+ const externalIds = /* @__PURE__ */ new Set();
632
+ const moduleIds = [];
633
+ for (const chunk of Object.values(bundle)) {
634
+ if (chunk.type !== "chunk") continue;
635
+ moduleIds.push(...chunk.moduleIds ?? []);
636
+ for (const id of [...chunk.imports ?? [], ...chunk.dynamicImports ?? []]) if (!(id in bundle) && npmPackageName(id)) externalIds.add(id);
637
+ }
638
+ return {
639
+ externalIds,
640
+ bundledPackages: bundledPackagesFromModuleIds(moduleIds)
641
+ };
642
+ }
382
643
  function moostVite(options) {
383
644
  const externals = options.externals ?? true;
384
645
  const prefixes = normalizePrefixes(options.prefix);
@@ -402,6 +663,11 @@ function moostVite(options) {
402
663
  */ let bootGeneration = 0;
403
664
  let bootingGeneration = 0;
404
665
  /** Whether the HTTP listen() patch has ever captured a middleware (i.e. this is an HTTP app). */ let httpCaptured = false;
666
+ /**
667
+ * The `app.init()` promises the current boot handed us (see `captureMoostInit`) —
668
+ * several when one entry boots several apps, none for a boot that never calls
669
+ * `init()`. Reset by `beginBoot()`, consumed by `settleBootInit()`.
670
+ */ let bootInitPromises = [];
405
671
  /** Module IDs awaiting DI cleanup — consumed in runReload; see ejectApp. */ let pendingCleanup = null;
406
672
  /** In middleware mode: maps req → next() for the onNoMatch callback */ const pendingNextMap = /* @__PURE__ */ new WeakMap();
407
673
  const adapters = [
@@ -471,6 +737,43 @@ function moostVite(options) {
471
737
  reloadRequired = true;
472
738
  };
473
739
  patchMoostHandlerLogging();
740
+ captureMoostInit((promise) => {
741
+ if (bootingGeneration !== bootGeneration) {
742
+ logger.error(`⚠️ A stale Moost boot called init() (boot generation ${bootingGeneration}, latest ${bootGeneration}) — an HMR reload race; a follow-up reload will replace it.`);
743
+ return;
744
+ }
745
+ bootInitPromises.push(promise);
746
+ });
747
+ /** Marks the boot about to run as authoritative for the current generation. */ const beginBoot = () => {
748
+ bootingGeneration = bootGeneration;
749
+ bootInitPromises = [];
750
+ };
751
+ /**
752
+ * Finishes a boot: awaits the `init()` promise(s) the entry that just executed
753
+ * handed us, so the boot is only "done" once the app is fully initialized —
754
+ * every controller bound, every `@MoostInit` hook run (see `captureMoostInit`).
755
+ * Owns `bootError`: cleared on success, set and logged when an init rejected;
756
+ * returns whether the boot succeeded.
757
+ */ const settleBootInit = async () => {
758
+ const initPromises = bootInitPromises;
759
+ bootInitPromises = [];
760
+ try {
761
+ await Promise.all(initPromises);
762
+ bootError = null;
763
+ return true;
764
+ } catch (error) {
765
+ bootError = error;
766
+ logger.error(`✖️ Moost app init failed: ${error.message}`);
767
+ return false;
768
+ }
769
+ };
770
+ /** Answers a request while `bootError` is set (see the gate in configureServer). */ const answerBootError = (res) => {
771
+ res.statusCode = 502;
772
+ res.setHeader("Content-Type", "text/plain");
773
+ res.end(`Moost app failed to load: ${bootError.message}`);
774
+ };
775
+ /** Set by the middleware-mode `config` hook: the consumer externalized the runtime themselves. */ let runtimeExternalized = false;
776
+ let resolvedConfig;
474
777
  return [{
475
778
  name: PLUGIN_NAME,
476
779
  enforce: "pre",
@@ -503,14 +806,8 @@ function moostVite(options) {
503
806
  let ssrNoExternal = userNoExternal === true ? true : userNoExternal !== void 0 ? [...Array.isArray(userNoExternal) ? userNoExternal : [userNoExternal], ourPlugin] : isBuild ? true : [ourPlugin];
504
807
  const userExternal = cfg.ssr?.external;
505
808
  const ssrExternal = isBuild ? [...Array.isArray(userExternal) ? userExternal : [], ...options.ssrExternal ?? []] : void 0;
506
- const runtimeNoExternal = [
507
- /^moost($|\/)/,
508
- /^@moostjs\//,
509
- /^@wooksjs\//,
510
- /^wooks($|\/)/
511
- ];
512
- const runtimeExternalized = (ssrExternal ?? []).some((e) => typeof e === "string" && runtimeNoExternal.some((re) => re.test(e)));
513
- if (isBuild && Array.isArray(ssrNoExternal) && !runtimeExternalized) ssrNoExternal = [...ssrNoExternal, ...runtimeNoExternal];
809
+ runtimeExternalized = (ssrExternal ?? []).some((e) => typeof e === "string" && RUNTIME_PACKAGE_PATTERNS.some((re) => re.test(e)));
810
+ if (isBuild && Array.isArray(ssrNoExternal) && !runtimeExternalized) ssrNoExternal = [...ssrNoExternal, ...RUNTIME_PACKAGE_PATTERNS];
514
811
  return {
515
812
  ...options.ssrEntry && { appType: "custom" },
516
813
  ssr: {
@@ -588,6 +885,19 @@ function moostVite(options) {
588
885
  ssrFetchForwarding: options.ssrFetchForwarding
589
886
  };
590
887
  config.__moostViteHttpRef = moostHttpRef;
888
+ resolvedConfig = config;
889
+ },
890
+ generateBundle(_outputOptions, bundle) {
891
+ const cfg = resolvedConfig;
892
+ if (!cfg || cfg.command !== "build" || !options.middleware || options.ssrExternalCheck === false || this.environment?.name !== "ssr") return;
893
+ const { externalIds, bundledPackages } = scanSsrBundle(bundle);
894
+ const splits = findSplitPackages({
895
+ root: cfg.root,
896
+ externalIds,
897
+ bundledPackages,
898
+ patterns: compilePackagePatterns(typeof options.ssrExternalCheck === "object" ? options.ssrExternalCheck.packages : void 0)
899
+ });
900
+ if (splits.length > 0) cfg.logger.warn(`\n[${PLUGIN_NAME}] ${formatSplitPackagesWarning(splits)}\n`);
591
901
  },
592
902
  async transform(code, id) {
593
903
  if (!id.endsWith(".ts")) return null;
@@ -625,7 +935,7 @@ function moostVite(options) {
625
935
  reloadRequired = false;
626
936
  reloadPromise = null;
627
937
  pendingCleanup = null;
628
- bootingGeneration = bootGeneration;
938
+ beginBoot();
629
939
  const runReload = () => {
630
940
  reloadRequired = false;
631
941
  console.log();
@@ -633,16 +943,15 @@ function moostVite(options) {
633
943
  console.log();
634
944
  return (async () => {
635
945
  try {
636
- bootingGeneration = bootGeneration;
946
+ beginBoot();
637
947
  const cleanupInstances = pendingCleanup ?? void 0;
638
948
  pendingCleanup = null;
639
- moostRestartCleanup(adapters, options.onEject, cleanupInstances);
949
+ await moostRestartCleanup(adapters, options.onEject, cleanupInstances);
640
950
  for (const adapter of adapters) if (adapter.detected) await adapter.init();
641
951
  const entryModule = await getEntryModule(server.environments.ssr.moduleGraph);
642
952
  if (entryModule?.transformResult) server.environments.ssr.moduleGraph.invalidateModule(entryModule);
643
953
  await ssrImport(options.entry);
644
- bootError = null;
645
- if (httpCaptured && !moostMiddleware) logger.error(`⚠️ Moost app reloaded but no HTTP middleware was captured — the entry did not re-run listen().`);
954
+ if (await settleBootInit() && httpCaptured && !moostMiddleware) logger.error(`⚠️ Moost app reloaded but no HTTP middleware was captured — the entry did not re-run listen().`);
646
955
  } catch (error) {
647
956
  bootError = error;
648
957
  logger.error(`✖️ Failed to reload Moost App: ${error.message}`);
@@ -659,11 +968,16 @@ function moostVite(options) {
659
968
  }
660
969
  };
661
970
  for (const adapter of adapters) adapter.ssrLoadModule = ssrImport;
662
- moostRestartCleanup(adapters, options.onEject);
971
+ await moostRestartCleanup(adapters, options.onEject);
663
972
  await ssrImport(options.entry);
973
+ await settleBootInit();
664
974
  server.middlewares.use(async (req, res, next) => {
665
975
  await drainReload();
666
976
  if (options.middleware && prefixes && !matchesPrefix(req.url || "", prefixes)) return next();
977
+ if (bootError) {
978
+ answerBootError(res);
979
+ return;
980
+ }
667
981
  if (moostMiddleware) {
668
982
  if (options.middleware) {
669
983
  pendingNextMap.set(req, next);
@@ -672,12 +986,6 @@ function moostVite(options) {
672
986
  }
673
987
  return moostMiddleware(req, res);
674
988
  }
675
- if (bootError) {
676
- res.statusCode = 502;
677
- res.setHeader("Content-Type", "text/plain");
678
- res.end(`Moost app failed to load: ${bootError.message}`);
679
- return;
680
- }
681
989
  next();
682
990
  });
683
991
  },
@@ -709,6 +1017,10 @@ function moostVite(options) {
709
1017
  const url = req.originalUrl || req.url || "/";
710
1018
  try {
711
1019
  await drainReload();
1020
+ if (bootError) {
1021
+ answerBootError(res);
1022
+ return;
1023
+ }
712
1024
  let template = await fs.readFile(resolve(server.config.root, "index.html"), "utf8");
713
1025
  template = await server.transformIndexHtml(url, template);
714
1026
  const { render } = await server.ssrLoadModule(options.ssrEntry);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moostjs/vite",
3
- "version": "0.6.35",
3
+ "version": "0.6.37",
4
4
  "description": "Vite Dev plugin for moostjs",
5
5
  "keywords": [
6
6
  "composables",
@@ -58,8 +58,8 @@
58
58
  "peerDependencies": {
59
59
  "sirv": "^3.0.0",
60
60
  "vite": "^8.0.0",
61
- "@moostjs/event-http": "^0.6.35",
62
- "moost": "^0.6.35"
61
+ "@moostjs/event-http": "^0.6.37",
62
+ "moost": "^0.6.37"
63
63
  },
64
64
  "peerDependenciesMeta": {
65
65
  "sirv": {