@multiplatform.one/config 6.0.0

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/src/vite.ts ADDED
@@ -0,0 +1,623 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { lookupTamaguiModules, resolveConfig } from "@multiplatform.one/utils/dev";
4
+ import { discoverPublicPackageRoots } from "./workspacePublicPackages.js";
5
+ import { tamaguiPlugin } from "@tamagui/vite-plugin";
6
+ import dotenv from "dotenv";
7
+ import type { Plugin, UserConfig } from "vite";
8
+ import createExternal from "vite-plugin-external";
9
+ import i18nextLoader from "vite-plugin-i18next-loader";
10
+
11
+ export interface CreateViteConfigOptions {
12
+ /**
13
+ * Absolute path to the project root (directory containing .env files).
14
+ * Defaults to looking up the git root.
15
+ */
16
+ projectRoot?: string;
17
+
18
+ /**
19
+ * Public config keys to expose via VITE_MP_CONFIG.
20
+ * These are typically the `public` keys from features/config.json.
21
+ */
22
+ publicConfigKeys?: string[];
23
+
24
+ /**
25
+ * Enable Tamagui plugin. Defaults to true.
26
+ */
27
+ tamagui?:
28
+ | boolean
29
+ | {
30
+ /**
31
+ * Tamagui components to include.
32
+ * Defaults to looking up tamaguiModules from package.json files.
33
+ */
34
+ components?: string[];
35
+
36
+ /**
37
+ * Path to the Tamagui config file.
38
+ * Defaults to "./config/tamagui.config.ts".
39
+ */
40
+ config?: string;
41
+
42
+ /**
43
+ * Path to output CSS. Defaults to "./tamagui.css".
44
+ */
45
+ outputCSS?: string;
46
+ };
47
+
48
+ /**
49
+ * Enable i18next loader plugin. Defaults to false.
50
+ */
51
+ i18n?:
52
+ | boolean
53
+ | {
54
+ /**
55
+ * Paths to scan for translation files.
56
+ * Defaults to ["../../features/i18n"].
57
+ */
58
+ paths?: string[];
59
+
60
+ /**
61
+ * How to resolve namespaces. Defaults to "basename".
62
+ */
63
+ namespaceResolution?: string;
64
+ };
65
+
66
+ /**
67
+ * Enable One framework plugin. Defaults to false.
68
+ * When true, uses sensible defaults for router, web, react, and native.
69
+ */
70
+ one?:
71
+ | boolean
72
+ | {
73
+ router?: { root?: string };
74
+ web?: { deploy?: string; defaultRenderMode?: string };
75
+ react?: { compiler?: boolean };
76
+ native?: { key?: string };
77
+ deps?: Record<string, boolean>;
78
+ };
79
+
80
+ /**
81
+ * SSR configuration. Defaults to reasonable values when One is enabled.
82
+ */
83
+ ssr?: UserConfig["ssr"];
84
+
85
+ /**
86
+ * Server configuration. Merged with defaults.
87
+ */
88
+ server?: UserConfig["server"];
89
+
90
+ /**
91
+ * Additional Vite plugins to include.
92
+ */
93
+ plugins?: UserConfig["plugins"];
94
+
95
+ /**
96
+ * Externalize react-router-dom. Defaults to true when One is enabled.
97
+ */
98
+ externalReactRouter?: boolean;
99
+
100
+ /**
101
+ * Additional dependency optimizations for SSR.
102
+ */
103
+ ssrDeps?: {
104
+ include?: string[];
105
+ exclude?: string[];
106
+ external?: string[];
107
+ };
108
+
109
+ /**
110
+ * Entry files for Vite's native SSR dep scanner.
111
+ * Vite follows actual import statements from these files to determine what
112
+ * needs pre-bundling — much more accurate than walking package.json dep trees.
113
+ * Defaults to ["routes/**\/*.{ts,tsx}"] for One framework apps.
114
+ * Only used when `one` is enabled.
115
+ */
116
+ ssrEntries?: string[];
117
+ }
118
+
119
+ /**
120
+ * Creates a shared Vite configuration for multiplatform.one apps.
121
+ *
122
+ * This abstracts away the boilerplate that's typically duplicated across
123
+ * app vite.config.ts files, including Tamagui plugin setup, i18n,
124
+ * env variable loading, SSR configuration, and One framework integration.
125
+ *
126
+ * @example
127
+ * ```ts
128
+ * // apps/one/vite.config.ts
129
+ * import { createViteConfig } from "@multiplatform.one/config/vite";
130
+ * import { public as publicConfigKeys } from "../../features/config.json";
131
+ *
132
+ * export default createViteConfig({
133
+ * publicConfigKeys,
134
+ * one: { web: { deploy: "node", defaultRenderMode: "ssr" } },
135
+ * tamagui: { config: "./config/tamagui.config.ts" },
136
+ * i18n: { paths: ["../../features/i18n"] },
137
+ * });
138
+ * ```
139
+ */
140
+ export function createViteConfig(options: CreateViteConfigOptions = {}): UserConfig {
141
+ const {
142
+ projectRoot: _projectRoot,
143
+ publicConfigKeys = [],
144
+ tamagui = true,
145
+ i18n = false,
146
+ one = false,
147
+ ssr,
148
+ server,
149
+ plugins: extraPlugins = [],
150
+ externalReactRouter,
151
+ ssrDeps,
152
+ ssrEntries,
153
+ } = options;
154
+
155
+ const projectRoot = _projectRoot || findProjectRoot();
156
+
157
+ // Load env from project root
158
+ dotenv.config({ path: path.resolve(projectRoot, ".env") });
159
+ if (publicConfigKeys.length > 0) {
160
+ process.env.VITE_MP_CONFIG = JSON.stringify(resolveConfig(publicConfigKeys));
161
+ }
162
+
163
+ const vitePlugins: UserConfig["plugins"] = [];
164
+ vitePlugins.push(preferBuiltWorkspaceEntryPlugin());
165
+
166
+ if (one) {
167
+ // One plugin must be added by the consumer in their extraPlugins.
168
+ // Alias react-native to react-native-web in SSR so Rolldown never parses
169
+ // react-native/index.js (which contains Flow-only syntax).
170
+ vitePlugins.push(ssrReactNativeAliasPlugin());
171
+ vitePlugins.push(clientBrokenEsmPlugin());
172
+ // Register a native-only resolveId plugin through vxrn's __vxrnAddNativePlugins
173
+ // hook (consumed in vxrn's getNativePlugins). vxrn's native rolldown resolver
174
+ // does NOT honor engine.io-client's `browser` field, so its Node `.node`
175
+ // transports (ws + xmlhttprequest-ssl) leak into Hermes and won't even compile.
176
+ // This re-asserts the package's own browser redirect (-> browser transports that
177
+ // use RN's global WebSocket/XMLHttpRequest) with NO node_modules patch.
178
+ registerNativeEngineIoBrowserTransports();
179
+ // preferBuiltWorkspaceEntryPlugin (web-only) hard-resolves every workspace
180
+ // package to its `dist/esm/index.mjs` (WEB build), and that resolution reaches
181
+ // the native graph too — pinning the whole @multiplatform.one/* chain to web
182
+ // (dragging @tiptap/prosemirror/html5-qrcode/etc into Hermes). This native-only
183
+ // resolveId redirects each workspace web entry to its `dist/esm/index.native.js`
184
+ // sibling so native gets the react-native build (the wrappers' native variants).
185
+ registerNativeWorkspaceEntries();
186
+ }
187
+
188
+ // Tamagui plugin
189
+ if (tamagui) {
190
+ const tamaguiOpts = typeof tamagui === "object" ? tamagui : {};
191
+ vitePlugins.push(
192
+ tamaguiPlugin({
193
+ components: tamaguiOpts.components || lookupTamaguiModules([process.cwd()]),
194
+ config: tamaguiOpts.config || "./config/tamagui.config.ts",
195
+ outputCSS: tamaguiOpts.outputCSS || "./public/tamagui.css",
196
+ // Prevents the config file watcher from re-evaluating the Tamagui
197
+ // config in the main process when the Piscina worker writes changes
198
+ // to .tamagui/. Without this, the watcher triggers bundleConfig →
199
+ // import() → createTamagui() on a raw @tamagui/web instance that
200
+ // conflicts with the SSR module runner's pre-bundled instance.
201
+ // See also: patches/@tamagui__static (forces CJS config bundles in
202
+ // the worker to avoid ESM/CJS dual-instance globalThis conflicts).
203
+ disableWatchTamaguiConfig: true,
204
+ }),
205
+ );
206
+ }
207
+
208
+ // i18n plugin
209
+ if (i18n) {
210
+ const i18nOpts = typeof i18n === "object" ? i18n : {};
211
+ vitePlugins.push(
212
+ i18nextLoader({
213
+ paths: i18nOpts.paths || ["../../features/i18n"],
214
+ namespaceResolution: (i18nOpts.namespaceResolution as any) || "basename",
215
+ }),
216
+ );
217
+ }
218
+
219
+ // Externalize react-router-dom (needed for One framework)
220
+ if (externalReactRouter !== false && one) {
221
+ vitePlugins.push(
222
+ createExternal({
223
+ externals: {
224
+ "react-router-dom": {} as any,
225
+ },
226
+ }),
227
+ );
228
+ }
229
+
230
+ // Merge extra plugins
231
+ vitePlugins.push(...(extraPlugins as any[]));
232
+
233
+ // SSR defaults for One framework
234
+ const defaultSsr: UserConfig["ssr"] = one
235
+ ? ({
236
+ noExternal: true,
237
+ optimizeDeps: {
238
+ entries: ssrEntries ?? ["routes/**/*.{ts,tsx}"],
239
+ include: [
240
+ // @tamagui/core holds a global config singleton. It must be
241
+ // pre-bundled as a single chunk so all SSR code shares one instance.
242
+ // If loaded via two different paths (optimizer + module runner),
243
+ // TamaguiProvider sets config on one instance but components read
244
+ // from the other, triggering the "global config fallback" warning.
245
+ "@tamagui/core",
246
+ "@tamagui/web",
247
+ "tamagui",
248
+ "@tamagui/toast",
249
+ "@tamagui/linear-gradient",
250
+ // react-i18next and i18next must be pre-bundled for SSR so they
251
+ // share the same React instance as react-dom/server. Without
252
+ // explicit include, the SSR module runner loads react-i18next
253
+ // directly from node_modules, which imports a raw
254
+ // node_modules/react/cjs/react.development.js. That raw React
255
+ // has its dispatcher unset (null), causing "Cannot read properties
256
+ // of null (reading 'useSyncExternalStore')" during useTranslation.
257
+ "react-i18next",
258
+ "i18next",
259
+ // @tanstack/react-store must be pre-bundled for SSR so it uses the
260
+ // shared React chunk (react-D-en6o7j.js) instead of loading
261
+ // use-sync-external-store/shim/with-selector as a CJS external which
262
+ // internally does require('react') → raw node_modules React →
263
+ // different instance from react-dom/server → dispatcher null.
264
+ "@tanstack/react-store",
265
+ "@tanstack/store",
266
+ // CJS-only transitive dep of react-i18next (via html-parse-stringify).
267
+ // Rolldown doesn't pull it into the react-i18next pre-bundle chunk, so
268
+ // the SSR module runner gets the raw file and fails with
269
+ // "module is not defined".
270
+ "void-elements",
271
+ // use-sync-external-store must share the same React instance as the
272
+ // SSR renderer. Without bundling it in, the CJS shim does
273
+ // require('react') at runtime → different node_modules React →
274
+ // dispatcher null → "Cannot read properties of null (reading
275
+ // 'useSyncExternalStore')" crash during useTranslation in SSR.
276
+ "use-sync-external-store",
277
+ "use-sync-external-store/shim",
278
+ ...(ssrDeps?.include || []),
279
+ ],
280
+ exclude: [
281
+ "@react-native/assets-registry",
282
+ "@react-native-community/datetimepicker",
283
+ "socket.io-client",
284
+ "engine.io-client",
285
+ "xmlhttprequest-ssl",
286
+ // Devtools — never needed in SSR, and their nested deps have
287
+ // version mismatches (form-core@1.27.7 needs Derived from store).
288
+ "@tanstack/form-devtools",
289
+ "@tanstack/react-form-devtools",
290
+ ...(ssrDeps?.exclude || []),
291
+ ],
292
+ rolldownOptions: {
293
+ // Belt-and-suspenders: force Rolldown to treat these packages as
294
+ // external at the bundler level, not just via the exclude plugin.
295
+ // Without this, Rolldown can still inline them as transitive deps
296
+ // of other pre-bundled packages, causing MISSING_EXPORT errors.
297
+ external: [
298
+ "@tanstack/form-devtools",
299
+ "@tanstack/react-form-devtools",
300
+ ...(ssrDeps?.external || []),
301
+ ],
302
+ // Shim any remaining missing exports instead of hard-failing.
303
+ shimMissingExports: true,
304
+ },
305
+ },
306
+ external: [
307
+ "loglevel",
308
+ "@react-native/assets-registry",
309
+ "@react-native-community/datetimepicker",
310
+ "socket.io-client",
311
+ "engine.io-client",
312
+ "xmlhttprequest-ssl",
313
+ "@tanstack/form-devtools",
314
+ "@tanstack/react-form-devtools",
315
+ ...(ssrDeps?.external || []),
316
+ // better-auth pulls @opentelemetry/* (CJS) which fails in the ESM
317
+ // SSR module runner with "exports is not defined". Externalize so
318
+ // Node's native CJS loader handles them.
319
+ "@opentelemetry/semantic-conventions",
320
+ "@opentelemetry/api",
321
+ "@opentelemetry/sdk-trace-base",
322
+ "@opentelemetry/sdk-trace-node",
323
+ ],
324
+ } as any)
325
+ : undefined;
326
+
327
+ // Server defaults
328
+ //
329
+ // When the One app runs behind Frappe's dev proxy (port 8000 → 3000), the
330
+ // browser page origin is :8000 but the vxrn HMR WebSocket lives on :3000.
331
+ // Werkzeug (WSGI) can't proxy WebSocket upgrades, so we tell the HMR client
332
+ // to connect directly to the One dev server port.
333
+ //
334
+ // IMPORTANT: vxrn internally sets `hmr.path = '/__vxrnhmr'`. We must
335
+ // include the path here because config merging replaces the entire `hmr`
336
+ // object (shallow merge at the `server` level). If vxrn ever changes the
337
+ // path, update it here too.
338
+ const onePort = Number(process.env.ONE_PORT) || 3000;
339
+ const defaultServer: UserConfig["server"] = {
340
+ allowedHosts: ["localhost", "127.0.0.1", "app.localhost", "app.test"],
341
+ strictPort: true,
342
+ watch: {
343
+ ignored: ["**/src-tauri/**"],
344
+ },
345
+ ...(one
346
+ ? {
347
+ hmr: {
348
+ path: "/__vxrnhmr",
349
+ clientPort: onePort,
350
+ },
351
+ }
352
+ : {}),
353
+ ...server,
354
+ };
355
+
356
+ return {
357
+ css: {
358
+ modules: {
359
+ localsConvention: "camelCase",
360
+ },
361
+ },
362
+ build: {
363
+ chunkSizeWarningLimit: 600,
364
+ },
365
+ define: {
366
+ // Build-time flag for Frappe translation backend tree-shaking
367
+ "process.env.VITE_FRAPPE_ENABLED": JSON.stringify(
368
+ process.env.VITE_FRAPPE_ENABLED === "true" ? "true" : "false",
369
+ ),
370
+ },
371
+ ssr: ssr || defaultSsr,
372
+ resolve: {
373
+ alias: {
374
+ // Fix tamagui-build outputting `from "react.mjs"` instead of `from "react"`
375
+ "react.mjs": "react",
376
+
377
+ // React 19.1+ ships its own compiler runtime. The standalone
378
+ // `react-compiler-runtime` beta package (pulled in by `one`) gets
379
+ // pre-bundled by Vite with its own React copy, causing a duplicate-
380
+ // React dispatcher-is-null crash (`useMemoCache`). Aliasing to
381
+ // the built-in entry point ensures a single React instance.
382
+ "react-compiler-runtime": "react/compiler-runtime",
383
+
384
+ // use-sync-external-store is marked as SSR-external by the
385
+ // ssrExternalFix plugin in vite.config.ts, so Node's CJS loader
386
+ // handles it during SSR (avoiding the "module is not defined" error).
387
+ // Do NOT alias it here — Vite's prefix-match aliases catch subpaths
388
+ // like /shim/with-selector.js and /with-selector, rewriting them to
389
+ // react/with-selector which doesn't exist, crashing dep optimisation.
390
+ },
391
+ // @react-navigation/core ships a nested node_modules/react-is (CJS-only).
392
+ // Deduplication forces the root copy so clientBrokenEsmPlugin's pre-bundle
393
+ // covers it everywhere.
394
+ dedupe: ["react-is"],
395
+ },
396
+ server: defaultServer,
397
+ envPrefix: ["VITE_", "TAURI_ENV_"],
398
+ plugins: vitePlugins,
399
+ } satisfies UserConfig;
400
+ }
401
+
402
+ export {
403
+ discoverPublicPackageRoots,
404
+ publicPackageViteSourceAliases,
405
+ } from "./workspacePublicPackages.js";
406
+
407
+ /**
408
+ * Aliases react-native to react-native-web in the SSR environment.
409
+ *
410
+ * @react-navigation/native statically imports react-native, which causes
411
+ * Rolldown's dep optimizer to load react-native/index.js — a file that contains
412
+ * Flow-only syntax (`import typeof`, `as Type`) that Rolldown cannot parse.
413
+ * Aliasing to react-native-web redirects those imports to the web-safe shim,
414
+ * so the real react-native package is never touched during SSR bundling.
415
+ */
416
+ function ssrReactNativeAliasPlugin(): Plugin {
417
+ return {
418
+ name: "multiplatform-ssr-react-native-alias",
419
+ enforce: "post",
420
+ configResolved(config) {
421
+ const ssrEnv = (config as any).environments?.ssr;
422
+ if (!ssrEnv?.resolve) return;
423
+ const alias: Array<{ find: string | RegExp; replacement: string }> =
424
+ ssrEnv.resolve.alias ?? [];
425
+ if (!alias.some((a: any) => a.find === "react-native")) {
426
+ alias.push({ find: "react-native", replacement: "react-native-web" });
427
+ }
428
+ ssrEnv.resolve.alias = alias;
429
+ },
430
+ };
431
+ }
432
+
433
+ /**
434
+ * Adds packages that have broken or missing ESM default exports to the client
435
+ * environments pre-bundle. Rolldown resolves them via their "import" condition
436
+ * and generates a proper ESM chunk with synthetic interop default export.
437
+ *
438
+ * use-latest-callback: type=commonjs, exports.import → esm.mjs, but vxrn's
439
+ * client conditions (["vxrn-web"]) skip "import" and land on "default" →
440
+ * lib/src/index.js (CJS, named-only). Pre-bundling forces the import condition.
441
+ */
442
+ function clientBrokenEsmPlugin(): Plugin {
443
+ return {
444
+ name: "multiplatform-client-broken-esm",
445
+ configResolved(config) {
446
+ const clientEnv = (config as any).environments?.client;
447
+ if (!clientEnv?.optimizeDeps) return;
448
+ const extras = [
449
+ "use-latest-callback",
450
+ // CJS packages in vxrn's dedupe list that are not auto-added to the
451
+ // client environment optimizer, causing "doesn't provide an export
452
+ // named 'default'" errors when served raw by Vite.
453
+ "escape-string-regexp",
454
+ // Pure CJS with no "import" condition — subpath must be pre-bundled
455
+ // explicitly so named exports (useSyncExternalStoreWithSelector) exist.
456
+ // Root entry is SSR-external (Node handles it); client needs pre-bundle.
457
+ "use-sync-external-store/with-selector",
458
+ // @react-navigation/core ships a nested node_modules/react-is (CJS).
459
+ // resolve.dedupe forces the root copy to be used; pre-bundling it here
460
+ // adds ESM interop so named exports like isValidElementType are available.
461
+ "react-is",
462
+ // CJS-only packages imported by @react-navigation/* TypeScript source.
463
+ // When the client loads @react-navigation/* via the "source" condition
464
+ // (TypeScript), these transitive CJS deps get served raw and lack ESM
465
+ // named exports. Pre-bundling generates the ESM interop wrappers.
466
+ "fast-deep-equal",
467
+ "color",
468
+ // query-string v7 is CJS-only; imported by @react-navigation/core for URL parsing.
469
+ "query-string",
470
+ ];
471
+ const include: string[] = clientEnv.optimizeDeps.include ?? [];
472
+ for (const dep of extras) {
473
+ if (!include.includes(dep)) include.push(dep);
474
+ }
475
+ clientEnv.optimizeDeps.include = include;
476
+ },
477
+ };
478
+ }
479
+
480
+ /**
481
+ * Adds engine.io-client's `browser`-field transport redirect to vxrn's NATIVE
482
+ * rolldown pipeline via its `globalThis.__vxrnAddNativePlugins` hook.
483
+ *
484
+ * socket.io-client -> engine.io-client/index.js statically imports its Node
485
+ * transports (`./globals.node.js`, `./transports/{websocket,polling-xhr}.node.js`),
486
+ * which pull `ws` + `xmlhttprequest-ssl` + Node globals. Those `.node` files also
487
+ * contain raw ESM `import` syntax that Hermes can't even COMPILE, so the native
488
+ * bundle dies at boot. engine.io's own `package.json` `browser` field already
489
+ * redirects each `*.node.js` to a browser sibling that uses the global
490
+ * `WebSocket` / `XMLHttpRequest` React Native provides — but vxrn's native
491
+ * resolver omits the `browser` aliasField (and its rolldown binding doesn't even
492
+ * support `aliasFields`). vxrn's native dev engine ignores the Vite config's
493
+ * `resolve.alias`/`resolveId` too — BUT it DOES read `globalThis.__vxrnAddNativePlugins`
494
+ * inside `getNativePlugins`, so a `resolveId` registered there runs on native.
495
+ * This re-asserts the browser redirect with no node_modules patch. Web is
496
+ * unaffected (engine.io is SSR-externalized and the web client already maps `browser`).
497
+ */
498
+ function registerNativeEngineIoBrowserTransports(): void {
499
+ const g = globalThis as unknown as { __vxrnAddNativePlugins?: unknown[] };
500
+ const name = "vxrn-engineio-browser-transports";
501
+ g.__vxrnAddNativePlugins = g.__vxrnAddNativePlugins ?? [];
502
+ if (g.__vxrnAddNativePlugins.some((p) => (p as { name?: string })?.name === name)) return;
503
+ g.__vxrnAddNativePlugins.push({
504
+ name,
505
+ async resolveId(
506
+ this: { resolve: (id: string, importer: string, opts: { skipSelf: boolean }) => unknown },
507
+ id: string,
508
+ importer?: string,
509
+ ) {
510
+ if (
511
+ importer &&
512
+ /engine\.io-client[\\/]build[\\/]/.test(importer) &&
513
+ id.endsWith(".node.js")
514
+ ) {
515
+ return this.resolve(`${id.slice(0, -".node.js".length)}.js`, importer, { skipSelf: true });
516
+ }
517
+ return null;
518
+ },
519
+ });
520
+ }
521
+
522
+ /**
523
+ * Routes workspace (`@multiplatform.one/*`) packages to their `dist/esm/index.native.js`
524
+ * build on NATIVE, via vxrn's `globalThis.__vxrnAddNativePlugins` hook.
525
+ *
526
+ * Why this is needed: `preferBuiltWorkspaceEntryPlugin` (a Vite plugin, web/SSR only)
527
+ * hard-resolves every workspace bare specifier to `<root>/dist/esm/index.mjs` — the WEB
528
+ * build — bypassing the package.json `exports` `react-native` condition. That web
529
+ * resolution propagates into vxrn's native graph (the native engine receives no Vite
530
+ * userPlugins, so it cannot be made native-aware there), pinning the entire workspace
531
+ * chain to web on native and dragging web-only deps (@tiptap, prosemirror, html5-qrcode,
532
+ * @mixmark-io/domino, …) into Hermes. Each affected package ships a `dist/esm/index.native.js`
533
+ * (built by tamagui-build, intra-package imports already rewritten to their `.native`
534
+ * siblings). This resolveId matches both forms — the bare specifier and the already-resolved
535
+ * `…/dist/esm/index.mjs` path — and redirects to the native sibling when it exists, so the
536
+ * react-native build wins on native. Web/SSR are unaffected (this plugin runs ONLY on native).
537
+ */
538
+ function registerNativeWorkspaceEntries(): void {
539
+ const g = globalThis as unknown as { __vxrnAddNativePlugins?: unknown[] };
540
+ const name = "vxrn-workspace-native-entries";
541
+ g.__vxrnAddNativePlugins = g.__vxrnAddNativePlugins ?? [];
542
+ if (g.__vxrnAddNativePlugins.some((p) => (p as { name?: string })?.name === name)) return;
543
+ let roots: Map<string, string>;
544
+ try {
545
+ roots = discoverPublicPackageRoots(findProjectRoot());
546
+ } catch {
547
+ roots = new Map();
548
+ }
549
+ g.__vxrnAddNativePlugins.push({
550
+ name,
551
+ resolveId(id: string): string | null {
552
+ // (a) bare workspace specifier (e.g. "@multiplatform.one/rich-text")
553
+ const root = roots.get(id);
554
+ if (root) {
555
+ const nativeEntry = path.join(root, "dist/esm/index.native.js");
556
+ return fs.existsSync(nativeEntry) ? nativeEntry : null;
557
+ }
558
+ // (b) already-resolved web entry "<...>/dist/esm/index.mjs" -> ".native.js" sibling
559
+ if (id.endsWith("/dist/esm/index.mjs")) {
560
+ const nativeEntry = `${id.slice(0, -".mjs".length)}.native.js`;
561
+ if (fs.existsSync(nativeEntry)) return nativeEntry;
562
+ }
563
+ return null;
564
+ },
565
+ });
566
+ }
567
+
568
+ function findProjectRoot(): string {
569
+ try {
570
+ const { execSync } = require("node:child_process");
571
+ return execSync("git rev-parse --show-toplevel", { encoding: "utf8" }).trim();
572
+ } catch {
573
+ return process.cwd();
574
+ }
575
+ }
576
+
577
+ /**
578
+ * package.json `exports` cannot branch on whether `dist/` exists. For workspace
579
+ * packages, prefer `dist/esm/index.{mjs,js}` when built; otherwise fall back to
580
+ * `src/index.ts` so local dev works without a build while published tarballs
581
+ * still resolve through `exports` to `dist`.
582
+ */
583
+ function preferBuiltWorkspaceEntryPlugin(): Plugin {
584
+ const roots = discoverPublicPackageRoots(findProjectRoot());
585
+
586
+ return {
587
+ name: "multiplatform-prefer-built-workspace-main",
588
+ enforce: "pre",
589
+ resolveId(id) {
590
+ const root = roots.get(id);
591
+ if (!root) return null;
592
+ const distCandidates = [
593
+ path.join(root, "dist/esm/index.mjs"),
594
+ path.join(root, "dist/esm/index.js"),
595
+ ];
596
+ for (const file of distCandidates) {
597
+ if (fs.existsSync(file)) return file;
598
+ }
599
+ const srcTs = path.join(root, "src/index.ts");
600
+ if (fs.existsSync(srcTs)) return srcTs;
601
+ const srcTsx = path.join(root, "src/index.tsx");
602
+ if (fs.existsSync(srcTsx)) return srcTsx;
603
+
604
+ // Vendored packages (e.g. keycloak-js) have no src/ — resolve via
605
+ // package.json exports/main so the dep scanner doesn't fail.
606
+ const pkgJsonPath = path.join(root, "package.json");
607
+ if (fs.existsSync(pkgJsonPath)) {
608
+ try {
609
+ const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8"));
610
+ const entry =
611
+ pkg.exports?.["."]?.import || pkg.exports?.["."]?.default || pkg.main || pkg.module;
612
+ if (entry) {
613
+ const resolved = path.resolve(root, entry);
614
+ if (fs.existsSync(resolved)) return resolved;
615
+ }
616
+ } catch {
617
+ /* ignore */
618
+ }
619
+ }
620
+ return null;
621
+ },
622
+ };
623
+ }