rails-vite-plugin 0.2.0 → 0.2.2

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/dist/index.js CHANGED
@@ -2,9 +2,11 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  import picomatch from 'picomatch';
4
4
  import { loadEnv, defaultAllowedOrigins, } from 'vite';
5
+ import { ORIGIN_PLACEHOLDER } from './shared/types.js';
5
6
  import { resolveInput, detectEntrypointsDir, discoverEntrypointInputs, detectEntrypoint } from './shared/entries.js';
6
7
  import { resolveAlias } from './shared/alias.js';
7
- import { resolveDevServerUrl, isAddressInfo } from './shared/dev-server.js';
8
+ import { resolveDevServerUrl, isAddressInfo, replaceOriginPlaceholder } from './shared/dev-server.js';
9
+ import { resolveBundlerOptionsKey, getUserBundlerInput } from './shared/bundler-compat.js';
8
10
  import { ensureCommandShouldRunInEnvironment } from './shared/env-guard.js';
9
11
  import { refreshPaths, resolveRefreshPaths } from './shared/refresh.js';
10
12
  import { readDevServerIndexHtml } from './shared/dev-server-page.js';
@@ -16,32 +18,39 @@ export default function rails(options = {}) {
16
18
  const entrypointsDir = options.input === undefined ? detectEntrypointsDir(sourceDir) : null;
17
19
  const input = options.input ?? (entrypointsDir ? discoverEntrypointInputs(sourceDir, entrypointsDir) : detectEntrypoint(sourceDir));
18
20
  const publicDir = options.publicDir ?? 'public';
19
- const buildDir = options.buildDir ?? 'vite';
21
+ const userBuildDir = options.buildDir;
20
22
  const devMetaPath = options.devMetaFile ?? path.join('tmp', 'rails-vite.json');
21
23
  const ssrOutDir = options.ssrOutDir ?? 'ssr';
22
24
  const resolvedInput = resolveInput(input, sourceDir);
23
25
  const resolvedSsr = options.ssr !== undefined ? resolveInput(options.ssr, sourceDir) : undefined;
24
26
  let resolvedConfig;
25
27
  let reactRefresh = false;
28
+ let devServerUrl = null;
29
+ let effectiveBuildDir;
26
30
  return {
27
31
  name: 'rails-vite',
28
32
  enforce: 'post',
29
33
  config(userConfig, { command, mode, isSsrBuild }) {
30
34
  const env = loadEnv(mode, userConfig.envDir || process.cwd(), '');
31
35
  ensureCommandShouldRunInEnvironment(command, env, 'rails-vite-plugin');
36
+ // @ts-expect-error -- `this.meta.rolldownVersion` exists in Vite 8+
37
+ const bundlerOptionsKey = resolveBundlerOptionsKey(this.meta);
38
+ const userBundlerInput = getUserBundlerInput(userConfig);
39
+ effectiveBuildDir = userBuildDir ?? (mode === 'test' ? 'vite-test' : 'vite');
32
40
  return {
33
- base: userConfig.base ?? (command === 'build' ? `/${buildDir}/` : ''),
41
+ base: userConfig.base ?? (command === 'build' ? `/${effectiveBuildDir}/` : ''),
34
42
  publicDir: userConfig.publicDir ?? false,
35
43
  build: {
36
44
  manifest: userConfig.build?.manifest ?? (isSsrBuild ? false : 'manifest.json'),
37
45
  ssrManifest: userConfig.build?.ssrManifest ?? (isSsrBuild ? 'ssr-manifest.json' : false),
38
- outDir: userConfig.build?.outDir ?? (isSsrBuild ? ssrOutDir : path.join(publicDir, buildDir)),
39
- rollupOptions: {
40
- input: userConfig.build?.rollupOptions?.input ?? (isSsrBuild ? resolvedSsr : resolvedInput),
46
+ outDir: userConfig.build?.outDir ?? (isSsrBuild ? ssrOutDir : path.join(publicDir, effectiveBuildDir)),
47
+ [bundlerOptionsKey]: {
48
+ input: userBundlerInput ?? (isSsrBuild ? resolvedSsr : resolvedInput),
41
49
  },
42
50
  assetsInlineLimit: userConfig.build?.assetsInlineLimit ?? 0,
43
51
  },
44
52
  server: {
53
+ origin: command === 'serve' ? (userConfig.server?.origin ?? ORIGIN_PLACEHOLDER) : undefined,
45
54
  cors: userConfig.server?.cors ?? {
46
55
  origin: userConfig.server?.origin ?? defaultAllowedOrigins,
47
56
  },
@@ -62,15 +71,20 @@ export default function rails(options = {}) {
62
71
  if (resolvedConfig.build.ssr)
63
72
  return;
64
73
  const outDir = resolvedConfig.build.outDir;
65
- fs.writeFileSync(path.join(outDir, 'rails-vite.json'), JSON.stringify(entrypointsDir ? { sourceDir, entrypointsDir } : { sourceDir }));
74
+ const meta = { sourceDir, buildDir: effectiveBuildDir };
75
+ if (entrypointsDir)
76
+ meta.entrypointsDir = entrypointsDir;
77
+ fs.writeFileSync(path.join(outDir, 'rails-vite.json'), JSON.stringify(meta));
78
+ },
79
+ transform(code) {
80
+ return replaceOriginPlaceholder(code, devServerUrl);
66
81
  },
67
82
  configureServer(server) {
68
83
  server.httpServer?.once('listening', () => {
69
84
  const address = server.httpServer?.address();
70
85
  if (isAddressInfo(address)) {
71
- const devServerUrl = resolveDevServerUrl(address, resolvedConfig);
72
- resolvedConfig.server.origin = devServerUrl;
73
- const meta = { url: devServerUrl, sourceDir };
86
+ devServerUrl = resolveDevServerUrl(address, resolvedConfig);
87
+ const meta = { url: devServerUrl, sourceDir, buildDir: effectiveBuildDir };
74
88
  if (entrypointsDir)
75
89
  meta.entrypointsDir = entrypointsDir;
76
90
  if (reactRefresh)
@@ -2,9 +2,11 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  import picomatch from 'picomatch';
4
4
  import { loadEnv, defaultAllowedOrigins, } from 'vite';
5
+ import { ORIGIN_PLACEHOLDER } from './shared/types.js';
5
6
  import { resolveEntries, entriesToRollupInput, prefixWithSourceDir, detectEntrypointsDir, discoverEntrypointInputs, detectEntrypoint, isEntrypointFile } from './shared/entries.js';
6
7
  import { resolveAlias } from './shared/alias.js';
7
- import { resolveDevServerUrl, isAddressInfo } from './shared/dev-server.js';
8
+ import { resolveDevServerUrl, isAddressInfo, replaceOriginPlaceholder } from './shared/dev-server.js';
9
+ import { resolveBundlerOptionsKey, getUserBundlerInput } from './shared/bundler-compat.js';
8
10
  import { ensureCommandShouldRunInEnvironment } from './shared/env-guard.js';
9
11
  import { refreshPaths, resolveRefreshPaths } from './shared/refresh.js';
10
12
  import { cssExtensions } from './shared/css.js';
@@ -30,6 +32,7 @@ export default function jsbundling(options = {}) {
30
32
  const ssrConfig = resolveSsrConfig(options.ssr, sourceDir, outputDir);
31
33
  let resolvedConfig;
32
34
  let reactRefresh = false;
35
+ let devServerUrl = null;
33
36
  // Track stubs written in dev so we can clean them up
34
37
  const writtenStubs = [];
35
38
  return {
@@ -38,6 +41,9 @@ export default function jsbundling(options = {}) {
38
41
  config(userConfig, { command, mode, isSsrBuild }) {
39
42
  const env = loadEnv(mode, userConfig.envDir || process.cwd(), '');
40
43
  ensureCommandShouldRunInEnvironment(command, env, 'rails-vite-plugin/jsbundling');
44
+ // @ts-expect-error -- `this.meta.rolldownVersion` exists in Vite 8+
45
+ const bundlerOptionsKey = resolveBundlerOptionsKey(this.meta);
46
+ const userBundlerInput = getUserBundlerInput(userConfig);
41
47
  // SSR builds get minimal config — just entry, outDir, base, and alias.
42
48
  // No asset pipeline stubs or client-specific settings.
43
49
  if (isSsrBuild) {
@@ -48,8 +54,8 @@ export default function jsbundling(options = {}) {
48
54
  build: {
49
55
  ssrManifest: userConfig.build?.ssrManifest ?? 'ssr-manifest.json',
50
56
  outDir: userConfig.build?.outDir ?? ssrConfig.outDir,
51
- rollupOptions: {
52
- input: userConfig.build?.rollupOptions?.input ?? ssrConfig.entry,
57
+ [bundlerOptionsKey]: {
58
+ input: userBundlerInput ?? ssrConfig.entry,
53
59
  },
54
60
  },
55
61
  } : {}),
@@ -61,25 +67,20 @@ export default function jsbundling(options = {}) {
61
67
  },
62
68
  };
63
69
  }
64
- // In dev mode, write placeholder stubs immediately so Propshaft/Sprockets
65
- // can discover them when Rails boots (both may start concurrently via bin/dev).
66
- if (command === 'serve') {
67
- writePlaceholderStubs(entries, assetPipelineDir, writtenStubs);
68
- }
69
70
  return {
70
71
  base: userConfig.base ?? (command === 'build' ? buildBase : ''),
71
72
  publicDir: userConfig.publicDir ?? false,
72
73
  build: {
73
74
  manifest: userConfig.build?.manifest ?? false,
74
75
  outDir: userConfig.build?.outDir ?? outputDir,
75
- rollupOptions: {
76
- input: userConfig.build?.rollupOptions?.input ?? rollupInput,
76
+ [bundlerOptionsKey]: {
77
+ input: userBundlerInput ?? rollupInput,
77
78
  output: {
78
79
  entryFileNames: (chunkInfo) => {
79
80
  if (chunkInfo.facadeModuleId && cssExtensions.test(chunkInfo.facadeModuleId)) {
80
81
  return `${CSS_FACADE_PREFIX}[name].js`;
81
82
  }
82
- return '[name].js';
83
+ return '_[name]-[hash].js';
83
84
  },
84
85
  chunkFileNames: '[name]-[hash].js',
85
86
  assetFileNames: '[name][extname]',
@@ -89,6 +90,7 @@ export default function jsbundling(options = {}) {
89
90
  cssCodeSplit: true,
90
91
  },
91
92
  server: {
93
+ origin: command === 'serve' ? (userConfig.server?.origin ?? ORIGIN_PLACEHOLDER) : undefined,
92
94
  cors: userConfig.server?.cors ?? {
93
95
  origin: userConfig.server?.origin ?? defaultAllowedOrigins,
94
96
  },
@@ -118,19 +120,30 @@ export default function jsbundling(options = {}) {
118
120
  // SSR bundles are Node.js server code — not served to browsers.
119
121
  if (resolvedConfig.build.ssr)
120
122
  return;
121
- // Copy entry files (JS + CSS) to the asset pipeline directory
122
- // so Propshaft/Sprockets can serve them via Rails helpers.
123
- // Chunks stay in outputDir and are served directly by the web server.
124
123
  fs.mkdirSync(assetPipelineDir, { recursive: true });
125
124
  const outDir = resolvedConfig.build.outDir;
126
- // Only copy CSS files that correspond to entries (entry CSS or CSS extracted
127
- // from JS entries). Shared chunk CSS stays in outputDir.
128
125
  const entryNames = new Set(entries.map(e => e.name));
129
126
  for (const [fileName, chunk] of Object.entries(bundle)) {
130
127
  const isEntryJs = chunk.type === 'chunk' && chunk.isEntry;
131
128
  const isEntryCss = chunk.type === 'asset' && cssExtensions.test(fileName)
132
129
  && entryNames.has(fileName.replace(/\.[^.]+$/, ''));
133
- if (isEntryJs || isEntryCss) {
130
+ if (isEntryJs) {
131
+ // JS entries are built as _[name]-[hash].js (content-hashed).
132
+ // Write a thin shim as [name].js for the asset pipeline so that
133
+ // Propshaft/Sprockets can serve it via javascript_include_tag.
134
+ //
135
+ // Why: Propshaft digests entry filenames (inertia.js → inertia-abc123.js)
136
+ // but cannot rewrite import paths inside Vite's chunks. Without the shim,
137
+ // the <script> tag and chunk imports resolve to different ES module
138
+ // instances — duplicating React, Inertia, and other stateful singletons.
139
+ // The shim ensures both paths chain to the same _[name]-[hash].js module.
140
+ const shimName = chunk.name + '.js';
141
+ const shim = `import "./${fileName}";export * from "./${fileName}";\n`;
142
+ fs.writeFileSync(path.join(outDir, shimName), shim);
143
+ fs.writeFileSync(path.join(assetPipelineDir, shimName), shim);
144
+ }
145
+ else if (isEntryCss) {
146
+ // Copy entry CSS to the asset pipeline. Shared chunk CSS stays in outputDir.
134
147
  const src = path.join(outDir, fileName);
135
148
  const dest = path.join(assetPipelineDir, fileName);
136
149
  fs.mkdirSync(path.dirname(dest), { recursive: true });
@@ -138,8 +151,10 @@ export default function jsbundling(options = {}) {
138
151
  }
139
152
  }
140
153
  },
154
+ transform(code) {
155
+ return replaceOriginPlaceholder(code, devServerUrl);
156
+ },
141
157
  configureServer(server) {
142
- let devServerUrl = null;
143
158
  let syncTimer = null;
144
159
  // Re-discover entries from the entrypoints dir and regenerate all stubs.
145
160
  // Called on initial listen and whenever entrypoint files are added/removed.
@@ -162,6 +177,13 @@ export default function jsbundling(options = {}) {
162
177
  clearTimeout(syncTimer);
163
178
  syncTimer = setTimeout(syncStubs, 100);
164
179
  };
180
+ // Write placeholder stubs so Propshaft/Sprockets can discover asset files
181
+ // at boot time, before the Vite dev server is ready with its URL.
182
+ // Only write when httpServer exists — skip for embedded Vite instances
183
+ // (e.g., Storybook) that would otherwise overwrite real dev stubs.
184
+ if (server.httpServer && !server.httpServer.listening) {
185
+ writePlaceholderStubs(entries, assetPipelineDir, writtenStubs);
186
+ }
165
187
  server.httpServer?.once('listening', () => {
166
188
  const address = server.httpServer?.address();
167
189
  if (isAddressInfo(address)) {
@@ -0,0 +1,5 @@
1
+ import type { UserConfig } from 'vite';
2
+ export declare function resolveBundlerOptionsKey(meta: {
3
+ rolldownVersion?: string;
4
+ }): 'rolldownOptions' | 'rollupOptions';
5
+ export declare function getUserBundlerInput(userConfig: UserConfig): unknown;
@@ -0,0 +1,9 @@
1
+ // Vite 8 (Rolldown) uses `rolldownOptions` instead of `rollupOptions`.
2
+ // Detect at runtime to support both Vite 7 and 8.
3
+ export function resolveBundlerOptionsKey(meta) {
4
+ return meta?.rolldownVersion ? 'rolldownOptions' : 'rollupOptions';
5
+ }
6
+ export function getUserBundlerInput(userConfig) {
7
+ // @ts-expect-error -- `rolldownOptions` exists in Vite 8+ only
8
+ return userConfig.build?.rolldownOptions?.input ?? userConfig.build?.rollupOptions?.input;
9
+ }
@@ -3,3 +3,4 @@ import type { ResolvedConfig } from 'vite';
3
3
  import type { DevServerUrl } from './types.js';
4
4
  export declare function resolveDevServerUrl(address: AddressInfo, config: ResolvedConfig): DevServerUrl;
5
5
  export declare function isAddressInfo(x: string | AddressInfo | null | undefined): x is AddressInfo;
6
+ export declare function replaceOriginPlaceholder(code: string, devServerUrl: string | null): string | undefined;
@@ -1,3 +1,4 @@
1
+ import { ORIGIN_PLACEHOLDER } from './types.js';
1
2
  export function resolveDevServerUrl(address, config) {
2
3
  const hmr = typeof config.server.hmr === 'object' ? config.server.hmr : null;
3
4
  const clientProtocol = hmr?.protocol
@@ -18,3 +19,8 @@ export function resolveDevServerUrl(address, config) {
18
19
  export function isAddressInfo(x) {
19
20
  return typeof x === 'object' && x !== null;
20
21
  }
22
+ export function replaceOriginPlaceholder(code, devServerUrl) {
23
+ if (devServerUrl && code.includes(ORIGIN_PLACEHOLDER)) {
24
+ return code.replaceAll(ORIGIN_PLACEHOLDER, devServerUrl);
25
+ }
26
+ }
@@ -1,5 +1,6 @@
1
1
  export type InputOption = string | string[] | Record<string, string>;
2
2
  export type DevServerUrl = `${'http' | 'https'}://${string}:${number}`;
3
+ export declare const ORIGIN_PLACEHOLDER = "http://__rails_vite_placeholder__.test";
3
4
  export interface ResolvedEntry {
4
5
  /** Output name (e.g., 'application', 'admin/index') */
5
6
  name: string;
@@ -1 +1,3 @@
1
- export {};
1
+ // Placeholder URL used as `server.origin` during config resolution.
2
+ // Replaced with the real dev server URL once the server starts listening.
3
+ export const ORIGIN_PLACEHOLDER = 'http://__rails_vite_placeholder__.test';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rails-vite-plugin",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Vite plugin for Rails integration",
5
5
  "author": "Svyatoslav Kryukov <me@skryukov.dev>",
6
6
  "license": "MIT",