@moostjs/vite 0.6.42 → 0.6.43

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
@@ -222,6 +222,8 @@ The guard works in one direction only, and the runtime is not the only package t
222
222
 
223
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
224
 
225
+ **Dev server.** In `vite serve`, installed `moost` / `@moostjs/*` / `@wooksjs/*` are loaded natively by Node, so the app and any library importing them on its own (e.g. `@atscript/moost-db` resolving `MoostHttp` through DI) share one copy. Don't put the runtime in `ssr.noExternal` for dev, and list a linked workspace copy of it in `ssr.external`; the plugin warns at startup when a natively loaded package depends on a watched package Vite's SSR module runner inlines (same `ssrExternalCheck` option). Up to 0.6.42 such libraries failed with `Class is not Injectable` in dev unless listed in `ssr.noExternal` — drop that workaround after upgrading. Details: [Dev server: one runtime copy](https://moost.org/webapp/vite#dev-server-one-runtime-copy).
226
+
225
227
  ## SSR Local Fetch
226
228
 
227
229
  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:
@@ -249,26 +251,26 @@ The plugin injects a `__VITE_ID` decorator on `@Injectable` and `@Controller` cl
249
251
 
250
252
  ## Options
251
253
 
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). |
254
+ | Option | Type | Default | Description |
255
+ | ------------------ | ------------------------------------------------ | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
256
+ | `entry` | `string` | — | Application entry file (required) |
257
+ | `port` | `number` | `3000` | Dev server port (backend mode only) |
258
+ | `host` | `string` | `'localhost'` | Dev server host (backend mode only) |
259
+ | `outDir` | `string` | `'dist'` | Build output directory (backend mode only) |
260
+ | `format` | `'cjs' \| 'esm'` | `'esm'` | Output module format (backend mode only) |
261
+ | `sourcemap` | `boolean` | `true` | Generate source maps (both modes; in middleware mode applies to the `dist/server` SSR build) |
262
+ | `externals` | `boolean \| object` | `true` | Configure external dependencies (backend mode only) |
263
+ | `onEject` | `function` | — | Hook to control DI instance ejection during HMR |
264
+ | `ssrFetch` | `boolean` | `true` | Enable local fetch interception for SSR |
265
+ | `middleware` | `boolean` | `false` | Run Moost as Connect middleware (Vite serves frontend) |
266
+ | `prefix` | `string` | `'/api'` (prod server) | URL prefix filter for middleware mode (e.g. `'/api'`); the production server defaults to `'/api'` when omitted |
267
+ | `ssrEntry` | `string` | — | Vue/React SSR entry module (e.g. `'/src/entry-server.ts'`) |
268
+ | `ssrOutlet` | `string` | `'<!--ssr-outlet-->'` | HTML placeholder for SSR-rendered content |
269
+ | `ssrState` | `string` | `'<!--ssr-state-->'` | HTML placeholder for SSR state transfer script |
270
+ | `ssrHead` | `string` | `'<!--ssr-head-->'` | HTML placeholder for SSR-rendered `<head>` tags (place inside `<head>`) |
271
+ | `serverEntry` | `string` | — | Custom production server entry file (e.g. `'./server.ts'`) |
272
+ | `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). |
273
+ | `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/*`, `@prostojs/infact`/`mate`, `@atscript/*`), and at dev-server startup when one depends on a package the SSR runner inlines; `packages` watches more. See [SSR Bundle Size](#ssr-bundle-size). |
272
274
 
273
275
  Options marked "backend mode only" are ignored when `middleware: true` — the user's `vite.config.ts` controls build/server configuration in middleware mode.
274
276
 
package/dist/index.cjs CHANGED
@@ -142,6 +142,12 @@ function getExternals({ node, workspace }) {
142
142
 
143
143
  //#endregion
144
144
  //#region packages/vite/src/adapter-detector.ts
145
+ /** Id prefix of the virtual modules re-exporting an adapter package (`virtual:moost-adapter/http`). */ const ADAPTER_VIRTUAL_PREFIX = "virtual:moost-adapter/";
146
+ /** Source of a resolved (`\0`-prefixed) adapter virtual module, or `undefined` for any other id. */ function loadAdapterVirtualModule(id) {
147
+ if (!id.startsWith(`\0${ADAPTER_VIRTUAL_PREFIX}`)) return;
148
+ const adapter = id.slice(23);
149
+ return `export * from ${JSON.stringify(`@moostjs/event-${adapter}`)}\n`;
150
+ }
145
151
  /**
146
152
  * Creates an adapter detector for a specific adapter type.
147
153
  * @param adapter
@@ -156,7 +162,7 @@ function getExternals({ node, workspace }) {
156
162
  async init() {
157
163
  this.detected = true;
158
164
  const moduleId = `@moostjs/event-${adapter}`;
159
- const module = this.ssrLoadModule ? await this.ssrLoadModule(moduleId) : await import(moduleId);
165
+ const module = this.ssrLoadModule ? await this.ssrLoadModule(`${ADAPTER_VIRTUAL_PREFIX}${adapter}`) : await import(moduleId);
160
166
  const constructorName = `Moost${adapter.charAt(0).toUpperCase() + adapter.slice(1)}`;
161
167
  getLogger().log(`🔍 Extracting Adapter "${constructorName}"`);
162
168
  this.constructor = module[constructorName];
@@ -460,15 +466,24 @@ function constructorName(i) {
460
466
  /^wooks($|\/)/
461
467
  ];
462
468
  /**
463
- * Packages the build check watches by default: the moost/wooks runtime plus the
464
- * `@atscript/*` family. All of them keep module-level state — Symbol slot keys,
465
- * DI/model registries, class identity used by `instanceof` — so a second copy
466
- * loaded by Node breaks lookups that the bundled copy filled in.
469
+ * The `ssr.noExternal` entry the plugin always adds, so the `define:`
470
+ * substitutions in `prod-server.mjs` land.
471
+ */ const PLUGIN_NO_EXTERNAL = /^@moostjs\/vite($|\/)/;
472
+ /**
473
+ * Packages the split checks watch by default: the moost/wooks runtime, the DI
474
+ * and metadata containers under it (`@prostojs/infact`, `@prostojs/mate`) and
475
+ * the `@atscript/*` family. All of them keep module-level state — Symbol slot
476
+ * keys, DI/model registries, class identity used by `instanceof` — so a second
477
+ * copy loaded by Node breaks lookups that the other copy filled in.
467
478
  *
468
479
  * This is deliberately wider than {@link RUNTIME_PACKAGE_PATTERNS}: the guard
469
- * that force-bundles packages covers the runtime only, while the check merely
470
- * reports what the output shows.
471
- */ const SHARED_STATE_PACKAGE_PATTERNS = [...RUNTIME_PACKAGE_PATTERNS, /^@atscript\//];
480
+ * that force-bundles packages covers the runtime only, while the checks merely
481
+ * report what the build output / dev config shows.
482
+ */ const SHARED_STATE_PACKAGE_PATTERNS = [
483
+ ...RUNTIME_PACKAGE_PATTERNS,
484
+ /^@prostojs\/(infact|mate)($|\/)/,
485
+ /^@atscript\//
486
+ ];
472
487
  function escapeRegExp(input) {
473
488
  return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
474
489
  }
@@ -596,22 +611,120 @@ function readPkgJson(dir) {
596
611
  }
597
612
  return found;
598
613
  }
599
- /** Human-readable build warning for {@link findSplitPackages} results. */ function formatSplitPackagesWarning(splits) {
600
- const lines = splits.map((s) => {
614
+ /** One warning line per split: the consumer, its external chain and its split deps (tagged `label`). */ function formatSplitLines(splits, label) {
615
+ return splits.map((s) => {
601
616
  const chain = s.via.length > 0 ? ` (loaded via external ${s.via.join(" → ")})` : "";
602
- const deps = s.splitDeps.map((d) => `${d} (bundled)`).join(", ");
617
+ const deps = s.splitDeps.map((d) => `${d} (${label})`).join(", ");
603
618
  return ` - ${s.name}${chain} depends on ${deps}`;
604
619
  });
620
+ }
621
+ /** Human-readable build warning for {@link findSplitPackages} results. */ function formatSplitPackagesWarning(splits) {
605
622
  const direct = [...new Set(splits.map((s) => s.via.length > 0 ? s.via[0] : s.name))];
606
623
  return [
607
624
  "These externalized packages depend on packages that are bundled into dist/server, so Node will load a second copy of them from node_modules:",
608
- ...lines,
625
+ ...formatSplitLines(splits, "bundled"),
609
626
  "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.",
610
627
  `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.`,
611
628
  "Set ssrExternalCheck: false in moostVite() to silence this check, or ssrExternalCheck: { packages: [...] } to watch more packages."
612
629
  ].join("\n");
613
630
  }
614
631
 
632
+ //#endregion
633
+ //#region packages/vite/src/ssr-externals-dev-check.ts
634
+ const toArray = (v) => Array.isArray(v) ? v : [v];
635
+ /** A `noExternal` string entry is a glob over package names (`@acme/*`), as in Vite's filter. */ function globToRegExp(glob) {
636
+ const source = glob.split("**").map((part) => part.split("*").map((chunk) => escapeRegExp(chunk)).join("[^/]*")).join(".*");
637
+ return new RegExp(`^${source}$`);
638
+ }
639
+ /**
640
+ * Whether Vite's dev SSR module runner evaluates its own copy of a package —
641
+ * mirroring Vite's externalization rules for bare imports: listed in `external`
642
+ * → external; matched by `noExternal` → inlined; otherwise external when it
643
+ * resolves into `node_modules`, inlined when it is linked (a workspace package
644
+ * outside `node_modules`). Packages that do not resolve from `root` count as
645
+ * external — the runner never loads them.
646
+ */ function createDevInlineCheck(root, config) {
647
+ const { external = [], noExternal = [] } = config;
648
+ const noExternalPatterns = typeof noExternal === "boolean" ? [] : toArray(noExternal).map((p) => p instanceof RegExp ? p : globToRegExp(p));
649
+ const cache = /* @__PURE__ */ new Map();
650
+ const decide = (name) => {
651
+ if (external !== true && external.includes(name)) return false;
652
+ if (typeof noExternal === "boolean") return noExternal;
653
+ if (noExternalPatterns.some((re) => re.test(name))) return true;
654
+ if (external === true) return false;
655
+ const dir = findPackageDir(name, root);
656
+ return dir !== void 0 && !dir.replaceAll("\\", "/").includes("/node_modules/");
657
+ };
658
+ return (name) => {
659
+ let inlined = cache.get(name);
660
+ if (inlined === void 0) {
661
+ inlined = decide(name);
662
+ cache.set(name, inlined);
663
+ }
664
+ return inlined;
665
+ };
666
+ }
667
+ /** Package names `root` depends on directly, from every dependency group of its package.json. */ function rootDependencies(root) {
668
+ const pkg = readPkgJson(root);
669
+ if (!pkg) return [];
670
+ const names = /* @__PURE__ */ new Set();
671
+ for (const group of [
672
+ pkg.dependencies,
673
+ pkg.devDependencies,
674
+ pkg.peerDependencies,
675
+ pkg.optionalDependencies
676
+ ]) for (const dep of Object.keys(group ?? {})) names.add(dep);
677
+ return [...names];
678
+ }
679
+ /** Is `entry` (a `noExternal` entry) the plugin's own (`PLUGIN_NO_EXTERNAL`)? */ const isPluginOwnEntry = (entry) => entry instanceof RegExp && entry.source === PLUGIN_NO_EXTERNAL.source;
680
+ /**
681
+ * The dev-server counterpart of {@link findSplitPackages}. In `vite serve` the
682
+ * SSR module runner evaluates app code and every *inlined* dependency, while
683
+ * Node loads every *externalized* one natively. When an external package
684
+ * depends on a watched package the runner inlines (forced by `ssr.noExternal`,
685
+ * or a linked workspace package), the two sides run separate copies of it —
686
+ * the same split as in production, only in dev.
687
+ *
688
+ * The externals walked are the root package's direct dependencies the runner
689
+ * does not inline (and their dependency trees). The plugin itself is always
690
+ * loaded by Node and works on Node's `moost` (DI cleanup on reload, init
691
+ * capture), so an inlined `moost` is reported against `@moostjs/vite` too.
692
+ *
693
+ * Skips the walk when nothing watched can be inlined: no `noExternal` entry
694
+ * beyond the plugin's own, and no watched direct dependency that is linked.
695
+ */ function findDevSplitPackages(opts) {
696
+ const patterns = opts.patterns ?? SHARED_STATE_PACKAGE_PATTERNS;
697
+ const isInlined = createDevInlineCheck(opts.root, opts.config);
698
+ const isInlinedWatched = (name) => !PLUGIN_NO_EXTERNAL.test(name) && patterns.some((re) => re.test(name)) && isInlined(name);
699
+ const deps = rootDependencies(opts.root);
700
+ const { noExternal = [] } = opts.config;
701
+ if (!(noExternal === true || typeof noExternal !== "boolean" && toArray(noExternal).some((e) => !isPluginOwnEntry(e))) && !deps.some((dep) => isInlinedWatched(dep))) return [];
702
+ const found = findSplitPackages({
703
+ root: opts.root,
704
+ externalIds: deps.filter((dep) => !isInlined(dep)),
705
+ bundledPackages: { has: isInlinedWatched },
706
+ patterns
707
+ });
708
+ if (isInlined("moost") && findPackageDir("moost", opts.root)) found.unshift({
709
+ name: "@moostjs/vite",
710
+ via: [],
711
+ splitDeps: ["moost"]
712
+ });
713
+ return found;
714
+ }
715
+ /** Human-readable dev-server warning for {@link findDevSplitPackages} results. */ function formatDevSplitPackagesWarning(splits) {
716
+ const inlined = [...new Set(splits.flatMap((s) => s.splitDeps))];
717
+ const consumers = [...new Set(splits.map((s) => s.via.length > 0 ? s.via[0] : s.name).filter((n) => n !== "@moostjs/vite"))];
718
+ const addConsumers = consumers.length > 0 ? `, or add ${consumers.map((d) => `'${d}'`).join(", ")} to ssr.noExternal so the runner evaluates those as well` : "";
719
+ return [
720
+ "These packages are loaded natively by Node in dev, but depend on packages Vite's SSR module runner evaluates its own copy of (ssr.noExternal, or a linked workspace package):",
721
+ ...formatSplitLines(splits, "inlined"),
722
+ "Two copies of a package split its module state — class identity (\"Class is not Injectable\" when a library resolves an adapter through DI), DI registries and event-context slots stop matching across the boundary.",
723
+ `Fix: let Vite externalize ${inlined.map((d) => `'${d}'`).join(", ")} in dev (remove it from ssr.noExternal; list a linked workspace package in ssr.external)${addConsumers}.`,
724
+ "Set ssrExternalCheck: false in moostVite() to silence this check."
725
+ ].join("\n");
726
+ }
727
+
615
728
  //#endregion
616
729
  //#region packages/vite/src/moost-vite.ts
617
730
  /**
@@ -682,6 +795,7 @@ function moostVite(options) {
682
795
  const ssrOutlet = options.ssrOutlet || DEFAULT_SSR_OUTLET;
683
796
  const ssrState = options.ssrState || DEFAULT_SSR_STATE;
684
797
  const ssrHead = options.ssrHead || DEFAULT_SSR_HEAD;
798
+ const splitCheckPatterns = compilePackagePatterns(typeof options.ssrExternalCheck === "object" ? options.ssrExternalCheck.packages : void 0);
685
799
  /**
686
800
  * Boot-identity stamps (dev-only "mongrel state" diagnostic — a stale pipeline
687
801
  * presents as security middleware silently switched off). `bootGeneration` is
@@ -846,9 +960,8 @@ function moostVite(options) {
846
960
  }
847
961
  const outDir = cfg.build?.outDir || "dist";
848
962
  const isBuild = env.command === "build";
849
- const ourPlugin = /^@moostjs\/vite($|\/)/;
850
963
  const userNoExternal = cfg.ssr?.noExternal;
851
- let ssrNoExternal = userNoExternal === true ? true : userNoExternal !== void 0 ? [...Array.isArray(userNoExternal) ? userNoExternal : [userNoExternal], ourPlugin] : isBuild ? true : [ourPlugin];
964
+ let ssrNoExternal = userNoExternal === true ? true : userNoExternal !== void 0 ? [...Array.isArray(userNoExternal) ? userNoExternal : [userNoExternal], PLUGIN_NO_EXTERNAL] : isBuild ? true : [PLUGIN_NO_EXTERNAL];
852
965
  const userExternal = cfg.ssr?.external;
853
966
  const ssrExternal = isBuild ? [...Array.isArray(userExternal) ? userExternal : [], ...options.ssrExternal ?? []] : void 0;
854
967
  runtimeExternalized = (ssrExternal ?? []).some((e) => typeof e === "string" && RUNTIME_PACKAGE_PATTERNS.some((re) => re.test(e)));
@@ -939,7 +1052,7 @@ function moostVite(options) {
939
1052
  root: cfg.root,
940
1053
  externalIds,
941
1054
  bundledPackages,
942
- patterns: compilePackagePatterns(typeof options.ssrExternalCheck === "object" ? options.ssrExternalCheck.packages : void 0)
1055
+ patterns: splitCheckPatterns
943
1056
  });
944
1057
  if (splits.length > 0) cfg.logger.warn(`\n[${PLUGIN_NAME}] ${formatSplitPackagesWarning(splits)}\n`);
945
1058
  },
@@ -958,9 +1071,14 @@ function moostVite(options) {
958
1071
  return null;
959
1072
  },
960
1073
  resolveId(id) {
961
- if (id === "virtual:vite-id") return "\0virtual:vite-id";
1074
+ if (id === "virtual:vite-id" || id.startsWith(ADAPTER_VIRTUAL_PREFIX)) return `\0${id}`;
962
1075
  },
963
1076
  load(id) {
1077
+ const adapterModule = loadAdapterVirtualModule(id);
1078
+ if (adapterModule !== void 0) return {
1079
+ code: adapterModule,
1080
+ map: null
1081
+ };
964
1082
  if (id === "\0virtual:vite-id") return {
965
1083
  code: `
966
1084
  import { getMoostMate } from "moost";
@@ -1010,6 +1128,14 @@ function moostVite(options) {
1010
1128
  await reloadPromise;
1011
1129
  }
1012
1130
  };
1131
+ if (options.ssrExternalCheck !== false) {
1132
+ const splits = findDevSplitPackages({
1133
+ root: server.config.root,
1134
+ config: server.environments.ssr.config.resolve,
1135
+ patterns: splitCheckPatterns
1136
+ });
1137
+ if (splits.length > 0) server.config.logger.warn(`\n[${PLUGIN_NAME}] ${formatDevSplitPackagesWarning(splits)}\n`);
1138
+ }
1013
1139
  for (const adapter of adapters) adapter.ssrLoadModule = ssrImport;
1014
1140
  await prepareBoot(EJECT_ALL);
1015
1141
  await ssrImport(options.entry);
package/dist/index.d.ts CHANGED
@@ -170,11 +170,15 @@ interface TMoostViteDevOptions {
170
170
  * (`vite serve`) is unaffected: Vite's default externalizer continues
171
171
  * to handle node_modules, which is required so CJS-only packages like
172
172
  * `@vue/server-renderer` can be loaded by Node's ESM/CJS interop
173
- * instead of evaluated by Vite's ESM-only SSR module runner.
173
+ * instead of evaluated by Vite's ESM-only SSR module runner. It also keeps
174
+ * the moost/wooks runtime a single (Node-loaded) copy shared by app code and
175
+ * natively loaded dependencies — don't put the runtime in `ssr.noExternal`
176
+ * for dev.
174
177
  */
175
178
  ssrExternal?: string[];
176
179
  /**
177
- * Verify the production SSR build for split shared-state packages.
180
+ * Verify the SSR module graph for split shared-state packages — in the
181
+ * production build and on the dev server.
178
182
  *
179
183
  * After the middleware-mode `vite build`, the plugin compares what ended up
180
184
  * inside `dist/server` (the bundled packages) with the bare imports left in
@@ -184,12 +188,18 @@ interface TMoostViteDevOptions {
184
188
  * and `instanceof` checks stop matching in production only — so the build
185
189
  * prints a warning naming the package and the fix.
186
190
  *
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
+ * On `vite serve` (both modes) the same walk runs against the dev SSR config:
192
+ * an externalized dependency (loaded natively by Node) that depends on a
193
+ * watched package the SSR module runner inlines — forced by `ssr.noExternal`,
194
+ * or a linked workspace package — gets a second copy in dev, and the server
195
+ * prints a warning at startup.
191
196
  *
192
- * Default: `true`. Set `false` to silence the check.
197
+ * Watched by default: `moost`, `@moostjs/*`, `wooks`, `@wooksjs/*`,
198
+ * `@prostojs/infact`, `@prostojs/mate` and `@atscript/*`. Pass
199
+ * `{ packages: [...] }` to watch more: an exact package name (`'lodash'`), a
200
+ * scope/prefix ending with `/` (`'@acme/'`), or a `RegExp`.
201
+ *
202
+ * Default: `true`. Set `false` to silence both checks.
193
203
  */
194
204
  ssrExternalCheck?: boolean | TSSRExternalCheckOptions;
195
205
  }
package/dist/index.mjs CHANGED
@@ -113,6 +113,12 @@ function getExternals({ node, workspace }) {
113
113
 
114
114
  //#endregion
115
115
  //#region packages/vite/src/adapter-detector.ts
116
+ /** Id prefix of the virtual modules re-exporting an adapter package (`virtual:moost-adapter/http`). */ const ADAPTER_VIRTUAL_PREFIX = "virtual:moost-adapter/";
117
+ /** Source of a resolved (`\0`-prefixed) adapter virtual module, or `undefined` for any other id. */ function loadAdapterVirtualModule(id) {
118
+ if (!id.startsWith(`\0${ADAPTER_VIRTUAL_PREFIX}`)) return;
119
+ const adapter = id.slice(23);
120
+ return `export * from ${JSON.stringify(`@moostjs/event-${adapter}`)}\n`;
121
+ }
116
122
  /**
117
123
  * Creates an adapter detector for a specific adapter type.
118
124
  * @param adapter
@@ -127,7 +133,7 @@ function getExternals({ node, workspace }) {
127
133
  async init() {
128
134
  this.detected = true;
129
135
  const moduleId = `@moostjs/event-${adapter}`;
130
- const module = this.ssrLoadModule ? await this.ssrLoadModule(moduleId) : await import(moduleId);
136
+ const module = this.ssrLoadModule ? await this.ssrLoadModule(`${ADAPTER_VIRTUAL_PREFIX}${adapter}`) : await import(moduleId);
131
137
  const constructorName = `Moost${adapter.charAt(0).toUpperCase() + adapter.slice(1)}`;
132
138
  getLogger().log(`🔍 Extracting Adapter "${constructorName}"`);
133
139
  this.constructor = module[constructorName];
@@ -431,15 +437,24 @@ function constructorName(i) {
431
437
  /^wooks($|\/)/
432
438
  ];
433
439
  /**
434
- * Packages the build check watches by default: the moost/wooks runtime plus the
435
- * `@atscript/*` family. All of them keep module-level state — Symbol slot keys,
436
- * DI/model registries, class identity used by `instanceof` — so a second copy
437
- * loaded by Node breaks lookups that the bundled copy filled in.
440
+ * The `ssr.noExternal` entry the plugin always adds, so the `define:`
441
+ * substitutions in `prod-server.mjs` land.
442
+ */ const PLUGIN_NO_EXTERNAL = /^@moostjs\/vite($|\/)/;
443
+ /**
444
+ * Packages the split checks watch by default: the moost/wooks runtime, the DI
445
+ * and metadata containers under it (`@prostojs/infact`, `@prostojs/mate`) and
446
+ * the `@atscript/*` family. All of them keep module-level state — Symbol slot
447
+ * keys, DI/model registries, class identity used by `instanceof` — so a second
448
+ * copy loaded by Node breaks lookups that the other copy filled in.
438
449
  *
439
450
  * This is deliberately wider than {@link RUNTIME_PACKAGE_PATTERNS}: the guard
440
- * that force-bundles packages covers the runtime only, while the check merely
441
- * reports what the output shows.
442
- */ const SHARED_STATE_PACKAGE_PATTERNS = [...RUNTIME_PACKAGE_PATTERNS, /^@atscript\//];
451
+ * that force-bundles packages covers the runtime only, while the checks merely
452
+ * report what the build output / dev config shows.
453
+ */ const SHARED_STATE_PACKAGE_PATTERNS = [
454
+ ...RUNTIME_PACKAGE_PATTERNS,
455
+ /^@prostojs\/(infact|mate)($|\/)/,
456
+ /^@atscript\//
457
+ ];
443
458
  function escapeRegExp(input) {
444
459
  return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
445
460
  }
@@ -567,22 +582,120 @@ function readPkgJson(dir) {
567
582
  }
568
583
  return found;
569
584
  }
570
- /** Human-readable build warning for {@link findSplitPackages} results. */ function formatSplitPackagesWarning(splits) {
571
- const lines = splits.map((s) => {
585
+ /** One warning line per split: the consumer, its external chain and its split deps (tagged `label`). */ function formatSplitLines(splits, label) {
586
+ return splits.map((s) => {
572
587
  const chain = s.via.length > 0 ? ` (loaded via external ${s.via.join(" → ")})` : "";
573
- const deps = s.splitDeps.map((d) => `${d} (bundled)`).join(", ");
588
+ const deps = s.splitDeps.map((d) => `${d} (${label})`).join(", ");
574
589
  return ` - ${s.name}${chain} depends on ${deps}`;
575
590
  });
591
+ }
592
+ /** Human-readable build warning for {@link findSplitPackages} results. */ function formatSplitPackagesWarning(splits) {
576
593
  const direct = [...new Set(splits.map((s) => s.via.length > 0 ? s.via[0] : s.name))];
577
594
  return [
578
595
  "These externalized packages depend on packages that are bundled into dist/server, so Node will load a second copy of them from node_modules:",
579
- ...lines,
596
+ ...formatSplitLines(splits, "bundled"),
580
597
  "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.",
581
598
  `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.`,
582
599
  "Set ssrExternalCheck: false in moostVite() to silence this check, or ssrExternalCheck: { packages: [...] } to watch more packages."
583
600
  ].join("\n");
584
601
  }
585
602
 
603
+ //#endregion
604
+ //#region packages/vite/src/ssr-externals-dev-check.ts
605
+ const toArray = (v) => Array.isArray(v) ? v : [v];
606
+ /** A `noExternal` string entry is a glob over package names (`@acme/*`), as in Vite's filter. */ function globToRegExp(glob) {
607
+ const source = glob.split("**").map((part) => part.split("*").map((chunk) => escapeRegExp(chunk)).join("[^/]*")).join(".*");
608
+ return new RegExp(`^${source}$`);
609
+ }
610
+ /**
611
+ * Whether Vite's dev SSR module runner evaluates its own copy of a package —
612
+ * mirroring Vite's externalization rules for bare imports: listed in `external`
613
+ * → external; matched by `noExternal` → inlined; otherwise external when it
614
+ * resolves into `node_modules`, inlined when it is linked (a workspace package
615
+ * outside `node_modules`). Packages that do not resolve from `root` count as
616
+ * external — the runner never loads them.
617
+ */ function createDevInlineCheck(root, config) {
618
+ const { external = [], noExternal = [] } = config;
619
+ const noExternalPatterns = typeof noExternal === "boolean" ? [] : toArray(noExternal).map((p) => p instanceof RegExp ? p : globToRegExp(p));
620
+ const cache = /* @__PURE__ */ new Map();
621
+ const decide = (name) => {
622
+ if (external !== true && external.includes(name)) return false;
623
+ if (typeof noExternal === "boolean") return noExternal;
624
+ if (noExternalPatterns.some((re) => re.test(name))) return true;
625
+ if (external === true) return false;
626
+ const dir = findPackageDir(name, root);
627
+ return dir !== void 0 && !dir.replaceAll("\\", "/").includes("/node_modules/");
628
+ };
629
+ return (name) => {
630
+ let inlined = cache.get(name);
631
+ if (inlined === void 0) {
632
+ inlined = decide(name);
633
+ cache.set(name, inlined);
634
+ }
635
+ return inlined;
636
+ };
637
+ }
638
+ /** Package names `root` depends on directly, from every dependency group of its package.json. */ function rootDependencies(root) {
639
+ const pkg = readPkgJson(root);
640
+ if (!pkg) return [];
641
+ const names = /* @__PURE__ */ new Set();
642
+ for (const group of [
643
+ pkg.dependencies,
644
+ pkg.devDependencies,
645
+ pkg.peerDependencies,
646
+ pkg.optionalDependencies
647
+ ]) for (const dep of Object.keys(group ?? {})) names.add(dep);
648
+ return [...names];
649
+ }
650
+ /** Is `entry` (a `noExternal` entry) the plugin's own (`PLUGIN_NO_EXTERNAL`)? */ const isPluginOwnEntry = (entry) => entry instanceof RegExp && entry.source === PLUGIN_NO_EXTERNAL.source;
651
+ /**
652
+ * The dev-server counterpart of {@link findSplitPackages}. In `vite serve` the
653
+ * SSR module runner evaluates app code and every *inlined* dependency, while
654
+ * Node loads every *externalized* one natively. When an external package
655
+ * depends on a watched package the runner inlines (forced by `ssr.noExternal`,
656
+ * or a linked workspace package), the two sides run separate copies of it —
657
+ * the same split as in production, only in dev.
658
+ *
659
+ * The externals walked are the root package's direct dependencies the runner
660
+ * does not inline (and their dependency trees). The plugin itself is always
661
+ * loaded by Node and works on Node's `moost` (DI cleanup on reload, init
662
+ * capture), so an inlined `moost` is reported against `@moostjs/vite` too.
663
+ *
664
+ * Skips the walk when nothing watched can be inlined: no `noExternal` entry
665
+ * beyond the plugin's own, and no watched direct dependency that is linked.
666
+ */ function findDevSplitPackages(opts) {
667
+ const patterns = opts.patterns ?? SHARED_STATE_PACKAGE_PATTERNS;
668
+ const isInlined = createDevInlineCheck(opts.root, opts.config);
669
+ const isInlinedWatched = (name) => !PLUGIN_NO_EXTERNAL.test(name) && patterns.some((re) => re.test(name)) && isInlined(name);
670
+ const deps = rootDependencies(opts.root);
671
+ const { noExternal = [] } = opts.config;
672
+ if (!(noExternal === true || typeof noExternal !== "boolean" && toArray(noExternal).some((e) => !isPluginOwnEntry(e))) && !deps.some((dep) => isInlinedWatched(dep))) return [];
673
+ const found = findSplitPackages({
674
+ root: opts.root,
675
+ externalIds: deps.filter((dep) => !isInlined(dep)),
676
+ bundledPackages: { has: isInlinedWatched },
677
+ patterns
678
+ });
679
+ if (isInlined("moost") && findPackageDir("moost", opts.root)) found.unshift({
680
+ name: "@moostjs/vite",
681
+ via: [],
682
+ splitDeps: ["moost"]
683
+ });
684
+ return found;
685
+ }
686
+ /** Human-readable dev-server warning for {@link findDevSplitPackages} results. */ function formatDevSplitPackagesWarning(splits) {
687
+ const inlined = [...new Set(splits.flatMap((s) => s.splitDeps))];
688
+ const consumers = [...new Set(splits.map((s) => s.via.length > 0 ? s.via[0] : s.name).filter((n) => n !== "@moostjs/vite"))];
689
+ const addConsumers = consumers.length > 0 ? `, or add ${consumers.map((d) => `'${d}'`).join(", ")} to ssr.noExternal so the runner evaluates those as well` : "";
690
+ return [
691
+ "These packages are loaded natively by Node in dev, but depend on packages Vite's SSR module runner evaluates its own copy of (ssr.noExternal, or a linked workspace package):",
692
+ ...formatSplitLines(splits, "inlined"),
693
+ "Two copies of a package split its module state — class identity (\"Class is not Injectable\" when a library resolves an adapter through DI), DI registries and event-context slots stop matching across the boundary.",
694
+ `Fix: let Vite externalize ${inlined.map((d) => `'${d}'`).join(", ")} in dev (remove it from ssr.noExternal; list a linked workspace package in ssr.external)${addConsumers}.`,
695
+ "Set ssrExternalCheck: false in moostVite() to silence this check."
696
+ ].join("\n");
697
+ }
698
+
586
699
  //#endregion
587
700
  //#region packages/vite/src/moost-vite.ts
588
701
  /**
@@ -651,6 +764,7 @@ function moostVite(options) {
651
764
  const ssrOutlet = options.ssrOutlet || DEFAULT_SSR_OUTLET;
652
765
  const ssrState = options.ssrState || DEFAULT_SSR_STATE;
653
766
  const ssrHead = options.ssrHead || DEFAULT_SSR_HEAD;
767
+ const splitCheckPatterns = compilePackagePatterns(typeof options.ssrExternalCheck === "object" ? options.ssrExternalCheck.packages : void 0);
654
768
  /**
655
769
  * Boot-identity stamps (dev-only "mongrel state" diagnostic — a stale pipeline
656
770
  * presents as security middleware silently switched off). `bootGeneration` is
@@ -815,9 +929,8 @@ function moostVite(options) {
815
929
  }
816
930
  const outDir = cfg.build?.outDir || "dist";
817
931
  const isBuild = env.command === "build";
818
- const ourPlugin = /^@moostjs\/vite($|\/)/;
819
932
  const userNoExternal = cfg.ssr?.noExternal;
820
- let ssrNoExternal = userNoExternal === true ? true : userNoExternal !== void 0 ? [...Array.isArray(userNoExternal) ? userNoExternal : [userNoExternal], ourPlugin] : isBuild ? true : [ourPlugin];
933
+ let ssrNoExternal = userNoExternal === true ? true : userNoExternal !== void 0 ? [...Array.isArray(userNoExternal) ? userNoExternal : [userNoExternal], PLUGIN_NO_EXTERNAL] : isBuild ? true : [PLUGIN_NO_EXTERNAL];
821
934
  const userExternal = cfg.ssr?.external;
822
935
  const ssrExternal = isBuild ? [...Array.isArray(userExternal) ? userExternal : [], ...options.ssrExternal ?? []] : void 0;
823
936
  runtimeExternalized = (ssrExternal ?? []).some((e) => typeof e === "string" && RUNTIME_PACKAGE_PATTERNS.some((re) => re.test(e)));
@@ -908,7 +1021,7 @@ function moostVite(options) {
908
1021
  root: cfg.root,
909
1022
  externalIds,
910
1023
  bundledPackages,
911
- patterns: compilePackagePatterns(typeof options.ssrExternalCheck === "object" ? options.ssrExternalCheck.packages : void 0)
1024
+ patterns: splitCheckPatterns
912
1025
  });
913
1026
  if (splits.length > 0) cfg.logger.warn(`\n[${PLUGIN_NAME}] ${formatSplitPackagesWarning(splits)}\n`);
914
1027
  },
@@ -927,9 +1040,14 @@ function moostVite(options) {
927
1040
  return null;
928
1041
  },
929
1042
  resolveId(id) {
930
- if (id === "virtual:vite-id") return "\0virtual:vite-id";
1043
+ if (id === "virtual:vite-id" || id.startsWith(ADAPTER_VIRTUAL_PREFIX)) return `\0${id}`;
931
1044
  },
932
1045
  load(id) {
1046
+ const adapterModule = loadAdapterVirtualModule(id);
1047
+ if (adapterModule !== void 0) return {
1048
+ code: adapterModule,
1049
+ map: null
1050
+ };
933
1051
  if (id === "\0virtual:vite-id") return {
934
1052
  code: `
935
1053
  import { getMoostMate } from "moost";
@@ -979,6 +1097,14 @@ function moostVite(options) {
979
1097
  await reloadPromise;
980
1098
  }
981
1099
  };
1100
+ if (options.ssrExternalCheck !== false) {
1101
+ const splits = findDevSplitPackages({
1102
+ root: server.config.root,
1103
+ config: server.environments.ssr.config.resolve,
1104
+ patterns: splitCheckPatterns
1105
+ });
1106
+ if (splits.length > 0) server.config.logger.warn(`\n[${PLUGIN_NAME}] ${formatDevSplitPackagesWarning(splits)}\n`);
1107
+ }
982
1108
  for (const adapter of adapters) adapter.ssrLoadModule = ssrImport;
983
1109
  await prepareBoot(EJECT_ALL);
984
1110
  await ssrImport(options.entry);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moostjs/vite",
3
- "version": "0.6.42",
3
+ "version": "0.6.43",
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.42",
62
- "moost": "^0.6.42"
61
+ "@moostjs/event-http": "^0.6.43",
62
+ "moost": "^0.6.43"
63
63
  },
64
64
  "peerDependenciesMeta": {
65
65
  "sirv": {