rails-vite-plugin 0.1.1 → 0.2.0-beta.1

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.d.ts CHANGED
@@ -1,14 +1,18 @@
1
1
  import { Plugin } from 'vite';
2
- export type InputOption = string | string[] | Record<string, string>;
2
+ import type { InputOption } from './shared/types.js';
3
+ import { refreshPaths } from './shared/refresh.js';
4
+ export type { InputOption };
5
+ export { refreshPaths };
3
6
  export interface RailsViteOptions {
4
7
  input?: InputOption;
5
8
  sourceDir?: string;
6
9
  ssr?: InputOption;
7
- ssrOutputDirectory?: string;
10
+ ssrOutDir?: string;
8
11
  devMetaFile?: string;
9
- buildDirectory?: string;
10
- publicDirectory?: string;
12
+ /** Directory name under publicDir for Vite build output (default: 'vite') */
13
+ buildDir?: string;
14
+ /** Public directory (default: 'public') */
15
+ publicDir?: string;
11
16
  refresh?: boolean | string | string[];
12
17
  }
13
- export declare const refreshPaths: string[];
14
18
  export default function rails(options?: RailsViteOptions): Plugin;
package/dist/index.js CHANGED
@@ -1,20 +1,24 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
- import { fileURLToPath } from 'url';
4
3
  import picomatch from 'picomatch';
5
4
  import { loadEnv, defaultAllowedOrigins, } from 'vite';
6
- export const refreshPaths = [
7
- 'app/views/**/*.{erb,slim,haml}',
8
- 'app/helpers/**/*.rb',
9
- ];
10
- let exitHandlersBound = false;
5
+ import { resolveInput, detectEntrypointsDir, discoverEntrypointInputs, detectEntrypoint } from './shared/entries.js';
6
+ import { resolveAlias } from './shared/alias.js';
7
+ import { resolveDevServerUrl, isAddressInfo } from './shared/dev-server.js';
8
+ import { ensureCommandShouldRunInEnvironment } from './shared/env-guard.js';
9
+ import { refreshPaths, resolveRefreshPaths } from './shared/refresh.js';
10
+ import { readDevServerIndexHtml } from './shared/dev-server-page.js';
11
+ import { resolveNoExternal } from './shared/ssr.js';
12
+ import { bindExitHandler } from './shared/cleanup.js';
13
+ export { refreshPaths };
11
14
  export default function rails(options = {}) {
12
15
  const sourceDir = options.sourceDir ?? 'app/javascript';
13
- const input = options.input ?? detectEntrypoint(sourceDir);
14
- const publicDirectory = options.publicDirectory ?? 'public';
15
- const buildDirectory = options.buildDirectory ?? 'vite';
16
+ const entrypointsDir = options.input === undefined ? detectEntrypointsDir(sourceDir) : null;
17
+ const input = options.input ?? (entrypointsDir ? discoverEntrypointInputs(sourceDir, entrypointsDir) : detectEntrypoint(sourceDir));
18
+ const publicDir = options.publicDir ?? 'public';
19
+ const buildDir = options.buildDir ?? 'vite';
16
20
  const devMetaPath = options.devMetaFile ?? path.join('tmp', 'rails-vite.json');
17
- const ssrOutputDirectory = options.ssrOutputDirectory ?? 'ssr';
21
+ const ssrOutDir = options.ssrOutDir ?? 'ssr';
18
22
  const resolvedInput = resolveInput(input, sourceDir);
19
23
  const resolvedSsr = options.ssr !== undefined ? resolveInput(options.ssr, sourceDir) : undefined;
20
24
  let resolvedConfig;
@@ -22,19 +26,18 @@ export default function rails(options = {}) {
22
26
  return {
23
27
  name: 'rails-vite',
24
28
  enforce: 'post',
25
- config(userConfig, { command, mode }) {
29
+ config(userConfig, { command, mode, isSsrBuild }) {
26
30
  const env = loadEnv(mode, userConfig.envDir || process.cwd(), '');
27
- const ssr = !!userConfig.build?.ssr;
28
- ensureCommandShouldRunInEnvironment(command, env);
31
+ ensureCommandShouldRunInEnvironment(command, env, 'rails-vite-plugin');
29
32
  return {
30
- base: userConfig.base ?? (command === 'build' ? `/${buildDirectory}/` : ''),
33
+ base: userConfig.base ?? (command === 'build' ? `/${buildDir}/` : ''),
31
34
  publicDir: userConfig.publicDir ?? false,
32
35
  build: {
33
- manifest: userConfig.build?.manifest ?? (ssr ? false : 'manifest.json'),
34
- ssrManifest: userConfig.build?.ssrManifest ?? (ssr ? 'ssr-manifest.json' : false),
35
- outDir: userConfig.build?.outDir ?? (ssr ? ssrOutputDirectory : path.join(publicDirectory, buildDirectory)),
36
+ manifest: userConfig.build?.manifest ?? (isSsrBuild ? false : 'manifest.json'),
37
+ ssrManifest: userConfig.build?.ssrManifest ?? (isSsrBuild ? 'ssr-manifest.json' : false),
38
+ outDir: userConfig.build?.outDir ?? (isSsrBuild ? ssrOutDir : path.join(publicDir, buildDir)),
36
39
  rollupOptions: {
37
- input: userConfig.build?.rollupOptions?.input ?? (ssr ? resolvedSsr : resolvedInput),
40
+ input: userConfig.build?.rollupOptions?.input ?? (isSsrBuild ? resolvedSsr : resolvedInput),
38
41
  },
39
42
  assetsInlineLimit: userConfig.build?.assetsInlineLimit ?? 0,
40
43
  },
@@ -44,15 +47,7 @@ export default function rails(options = {}) {
44
47
  },
45
48
  },
46
49
  resolve: {
47
- alias: Array.isArray(userConfig.resolve?.alias)
48
- ? [
49
- ...(userConfig.resolve?.alias ?? []),
50
- { find: '@', replacement: path.resolve(process.cwd(), sourceDir) },
51
- ]
52
- : {
53
- '@': path.resolve(process.cwd(), sourceDir),
54
- ...userConfig.resolve?.alias,
55
- },
50
+ alias: resolveAlias(userConfig.resolve?.alias, sourceDir),
56
51
  },
57
52
  ssr: {
58
53
  noExternal: resolveNoExternal(userConfig),
@@ -67,7 +62,7 @@ export default function rails(options = {}) {
67
62
  if (resolvedConfig.build.ssr)
68
63
  return;
69
64
  const outDir = resolvedConfig.build.outDir;
70
- fs.writeFileSync(path.join(outDir, 'rails-vite.json'), JSON.stringify({ sourceDir }));
65
+ fs.writeFileSync(path.join(outDir, 'rails-vite.json'), JSON.stringify(entrypointsDir ? { sourceDir, entrypointsDir } : { sourceDir }));
71
66
  },
72
67
  configureServer(server) {
73
68
  server.httpServer?.once('listening', () => {
@@ -76,6 +71,8 @@ export default function rails(options = {}) {
76
71
  const devServerUrl = resolveDevServerUrl(address, resolvedConfig);
77
72
  resolvedConfig.server.origin = devServerUrl;
78
73
  const meta = { url: devServerUrl, sourceDir };
74
+ if (entrypointsDir)
75
+ meta.entrypointsDir = entrypointsDir;
79
76
  if (reactRefresh)
80
77
  meta.reactRefresh = true;
81
78
  fs.writeFileSync(devMetaPath, JSON.stringify(meta));
@@ -84,16 +81,9 @@ export default function rails(options = {}) {
84
81
  }, 100);
85
82
  }
86
83
  });
87
- if (!exitHandlersBound) {
88
- const clean = () => {
89
- fs.rmSync(devMetaPath, { force: true });
90
- };
91
- process.on('exit', clean);
92
- process.on('SIGINT', () => process.exit());
93
- process.on('SIGTERM', () => process.exit());
94
- process.on('SIGHUP', () => process.exit());
95
- exitHandlersBound = true;
96
- }
84
+ bindExitHandler(() => {
85
+ fs.rmSync(devMetaPath, { force: true });
86
+ });
97
87
  // Watch view templates for full-page reload
98
88
  const resolvedRefreshPaths = resolveRefreshPaths(options.refresh);
99
89
  if (resolvedRefreshPaths.length) {
@@ -102,12 +92,11 @@ export default function rails(options = {}) {
102
92
  server.watcher.on('change', (filePath) => {
103
93
  const relativePath = path.relative(process.cwd(), filePath);
104
94
  if (match(relativePath)) {
105
- server.ws.send({ type: 'full-reload', path: '*' });
95
+ server.hot.send({ type: 'full-reload', path: '*' });
106
96
  }
107
97
  });
108
98
  }
109
- // Serve a helpful page at the dev server root
110
- const devServerIndexHtml = fs.readFileSync(path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'dev-server-index.html'), 'utf-8');
99
+ const devServerIndexHtml = readDevServerIndexHtml();
111
100
  return () => server.middlewares.use((req, res, next) => {
112
101
  if (req.url === '/index.html') {
113
102
  res.statusCode = 404;
@@ -119,100 +108,3 @@ export default function rails(options = {}) {
119
108
  },
120
109
  };
121
110
  }
122
- function resolveInput(input, sourceDir) {
123
- if (typeof input === 'object' && !Array.isArray(input)) {
124
- return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, prefixWithSourceDir(value, sourceDir)]));
125
- }
126
- if (Array.isArray(input)) {
127
- return input.map((entry) => prefixWithSourceDir(entry, sourceDir));
128
- }
129
- return prefixWithSourceDir(input, sourceDir);
130
- }
131
- function prefixWithSourceDir(entry, sourceDir) {
132
- if (entry.startsWith(sourceDir + '/') || entry.startsWith('/')) {
133
- return entry;
134
- }
135
- return `${sourceDir}/${entry}`;
136
- }
137
- const entrypointExtensions = /\.(mjs|js|mts|ts|jsx|tsx|css|scss|sass|less|styl|pcss)$/;
138
- function detectEntrypoint(sourceDir) {
139
- const entrypointsDir = path.join(sourceDir, 'entrypoints');
140
- if (fs.existsSync(entrypointsDir)) {
141
- return discoverEntrypoints(entrypointsDir).map((entry) => `entrypoints/${entry}`);
142
- }
143
- for (const ext of ['.js', '.mjs', '.ts', '.mts', '.jsx', '.tsx']) {
144
- const candidate = path.join(sourceDir, `application${ext}`);
145
- if (fs.existsSync(candidate)) {
146
- return `application${ext}`;
147
- }
148
- }
149
- return 'application.js';
150
- }
151
- function discoverEntrypoints(dir, base = dir) {
152
- const entries = [];
153
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
154
- if (entry.isDirectory()) {
155
- entries.push(...discoverEntrypoints(path.join(dir, entry.name), base));
156
- }
157
- else if (entrypointExtensions.test(entry.name)) {
158
- entries.push(path.relative(base, path.join(dir, entry.name)));
159
- }
160
- }
161
- return entries;
162
- }
163
- function resolveRefreshPaths(refresh) {
164
- if (refresh === false)
165
- return [];
166
- if (!refresh || refresh === true)
167
- return refreshPaths;
168
- if (typeof refresh === 'string')
169
- return [refresh];
170
- return refresh;
171
- }
172
- function resolveDevServerUrl(address, config) {
173
- const hmr = typeof config.server.hmr === 'object' ? config.server.hmr : null;
174
- const clientProtocol = hmr?.protocol
175
- ? hmr.protocol === 'wss'
176
- ? 'https'
177
- : 'http'
178
- : null;
179
- const serverProtocol = config.server.https ? 'https' : 'http';
180
- const protocol = clientProtocol ?? serverProtocol;
181
- const configHost = typeof config.server.host === 'string' ? config.server.host : null;
182
- const serverAddress = address.family === 'IPv6' || address.family === 6
183
- ? `[${address.address}]`
184
- : address.address;
185
- const host = hmr?.host ?? configHost ?? serverAddress;
186
- const port = hmr?.clientPort ?? address.port;
187
- return `${protocol}://${host}:${port}`;
188
- }
189
- function isAddressInfo(x) {
190
- return typeof x === 'object' && x !== null;
191
- }
192
- function ensureCommandShouldRunInEnvironment(command, env) {
193
- if (command === 'build') {
194
- return;
195
- }
196
- if (env.CI !== undefined) {
197
- throw new Error('rails-vite-plugin: You should not run the Vite dev server in CI. ' +
198
- 'Run `rake vite:build` instead.');
199
- }
200
- if (env.RAILS_ENV === 'production') {
201
- throw new Error('rails-vite-plugin: You should not run the Vite dev server in production. ' +
202
- 'Run `rake vite:build` instead.');
203
- }
204
- }
205
- function resolveNoExternal(config) {
206
- const userNoExternal = config.ssr?.noExternal;
207
- const pluginNoExternal = ['rails-vite-plugin'];
208
- if (userNoExternal === true) {
209
- return true;
210
- }
211
- if (userNoExternal === undefined) {
212
- return pluginNoExternal;
213
- }
214
- return [
215
- ...(Array.isArray(userNoExternal) ? userNoExternal : [userNoExternal]),
216
- ...pluginNoExternal,
217
- ];
218
- }
@@ -0,0 +1,27 @@
1
+ import { Plugin } from 'vite';
2
+ import type { InputOption } from './shared/types.js';
3
+ import { refreshPaths } from './shared/refresh.js';
4
+ export type { InputOption };
5
+ export { refreshPaths };
6
+ export interface JsbundlingOptions {
7
+ input?: InputOption;
8
+ sourceDir?: string;
9
+ /** Directory where Propshaft/Sprockets picks up entry files for Rails helpers.
10
+ * Default: 'app/assets/builds' */
11
+ assetPipelineDir?: string;
12
+ /** Public directory for the full Vite build output (chunks, assets).
13
+ * Default: 'public/assets' */
14
+ outputDir?: string;
15
+ /** SSR entry point for Inertia server-side rendering.
16
+ * String is a path relative to sourceDir (e.g., 'ssr/ssr.tsx').
17
+ * Object form allows customizing the output directory. */
18
+ ssr?: string | {
19
+ entry: string;
20
+ outDir?: string;
21
+ };
22
+ refresh?: boolean | string | string[];
23
+ /** Path to write dev server metadata JSON (default: 'tmp/rails-vite.json').
24
+ * Set to false to disable. Useful as a bridge for progressive upgrade to the rails_vite gem. */
25
+ devMetaFile?: string | false;
26
+ }
27
+ export default function jsbundling(options?: JsbundlingOptions): Plugin;
@@ -0,0 +1,307 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import picomatch from 'picomatch';
4
+ import { loadEnv, defaultAllowedOrigins, } from 'vite';
5
+ import { resolveEntries, entriesToRollupInput, prefixWithSourceDir, detectEntrypointsDir, discoverEntrypointInputs, detectEntrypoint, isEntrypointFile } from './shared/entries.js';
6
+ import { resolveAlias } from './shared/alias.js';
7
+ import { resolveDevServerUrl, isAddressInfo } from './shared/dev-server.js';
8
+ import { ensureCommandShouldRunInEnvironment } from './shared/env-guard.js';
9
+ import { refreshPaths, resolveRefreshPaths } from './shared/refresh.js';
10
+ import { cssExtensions } from './shared/css.js';
11
+ import { readDevServerIndexHtml } from './shared/dev-server-page.js';
12
+ import { resolveNoExternal } from './shared/ssr.js';
13
+ import { bindExitHandler } from './shared/cleanup.js';
14
+ export { refreshPaths };
15
+ const CSS_FACADE_PREFIX = '_css_';
16
+ export default function jsbundling(options = {}) {
17
+ const sourceDir = options.sourceDir ?? 'app/javascript';
18
+ const epDir = detectEntrypointsDir(sourceDir);
19
+ const input = options.input ?? (epDir ? discoverEntrypointInputs(sourceDir, epDir) : detectEntrypoint(sourceDir));
20
+ const assetPipelineDir = options.assetPipelineDir ?? 'app/assets/builds';
21
+ const outputDir = options.outputDir ?? 'public/assets';
22
+ // The URL prefix matching outputDir under public/ — used as Vite's `base` in production
23
+ // so that dynamic import() URLs resolve to the right place.
24
+ const buildBase = '/' + path.relative('public', outputDir) + '/';
25
+ const devMetaPath = options.devMetaFile !== false
26
+ ? (options.devMetaFile ?? path.join('tmp', 'rails-vite.json'))
27
+ : null;
28
+ const entries = resolveEntries(input, sourceDir);
29
+ const rollupInput = entriesToRollupInput(entries, typeof options.input === 'object' && !Array.isArray(options.input));
30
+ const ssrConfig = resolveSsrConfig(options.ssr, sourceDir, outputDir);
31
+ let resolvedConfig;
32
+ let reactRefresh = false;
33
+ // Track stubs written in dev so we can clean them up
34
+ const writtenStubs = [];
35
+ return {
36
+ name: 'rails-vite-jsbundling',
37
+ enforce: 'post',
38
+ config(userConfig, { command, mode, isSsrBuild }) {
39
+ const env = loadEnv(mode, userConfig.envDir || process.cwd(), '');
40
+ ensureCommandShouldRunInEnvironment(command, env, 'rails-vite-plugin/jsbundling');
41
+ // SSR builds get minimal config — just entry, outDir, base, and alias.
42
+ // No asset pipeline stubs or client-specific settings.
43
+ if (isSsrBuild) {
44
+ return {
45
+ base: userConfig.base ?? (command === 'build' ? buildBase : ''),
46
+ publicDir: userConfig.publicDir ?? false,
47
+ ...(ssrConfig ? {
48
+ build: {
49
+ ssrManifest: userConfig.build?.ssrManifest ?? 'ssr-manifest.json',
50
+ outDir: userConfig.build?.outDir ?? ssrConfig.outDir,
51
+ rollupOptions: {
52
+ input: userConfig.build?.rollupOptions?.input ?? ssrConfig.entry,
53
+ },
54
+ },
55
+ } : {}),
56
+ resolve: {
57
+ alias: resolveAlias(userConfig.resolve?.alias, sourceDir),
58
+ },
59
+ ssr: {
60
+ noExternal: resolveNoExternal(userConfig),
61
+ },
62
+ };
63
+ }
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
+ return {
70
+ base: userConfig.base ?? (command === 'build' ? buildBase : ''),
71
+ publicDir: userConfig.publicDir ?? false,
72
+ build: {
73
+ manifest: userConfig.build?.manifest ?? false,
74
+ outDir: userConfig.build?.outDir ?? outputDir,
75
+ rollupOptions: {
76
+ input: userConfig.build?.rollupOptions?.input ?? rollupInput,
77
+ output: {
78
+ entryFileNames: (chunkInfo) => {
79
+ if (chunkInfo.facadeModuleId && cssExtensions.test(chunkInfo.facadeModuleId)) {
80
+ return `${CSS_FACADE_PREFIX}[name].js`;
81
+ }
82
+ return '[name].js';
83
+ },
84
+ chunkFileNames: '[name]-[hash].js',
85
+ assetFileNames: '[name][extname]',
86
+ },
87
+ },
88
+ assetsInlineLimit: userConfig.build?.assetsInlineLimit ?? 0,
89
+ cssCodeSplit: true,
90
+ },
91
+ server: {
92
+ cors: userConfig.server?.cors ?? {
93
+ origin: userConfig.server?.origin ?? defaultAllowedOrigins,
94
+ },
95
+ },
96
+ resolve: {
97
+ alias: resolveAlias(userConfig.resolve?.alias, sourceDir),
98
+ },
99
+ ssr: {
100
+ noExternal: resolveNoExternal(userConfig),
101
+ },
102
+ };
103
+ },
104
+ configResolved(config) {
105
+ resolvedConfig = config;
106
+ reactRefresh = config.plugins.some((p) => p.name === 'vite:react-babel' || p.name === 'vite:react-swc');
107
+ },
108
+ generateBundle(_, bundle) {
109
+ // Rollup wraps CSS entries in empty JS facade chunks. Delete them —
110
+ // the real CSS is emitted as an asset by Vite's CSS pipeline.
111
+ for (const fileName of Object.keys(bundle)) {
112
+ if (fileName.startsWith(CSS_FACADE_PREFIX)) {
113
+ delete bundle[fileName];
114
+ }
115
+ }
116
+ },
117
+ writeBundle(_, bundle) {
118
+ // SSR bundles are Node.js server code — not served to browsers.
119
+ if (resolvedConfig.build.ssr)
120
+ 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
+ fs.mkdirSync(assetPipelineDir, { recursive: true });
125
+ 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
+ const entryNames = new Set(entries.map(e => e.name));
129
+ for (const [fileName, chunk] of Object.entries(bundle)) {
130
+ const isEntryJs = chunk.type === 'chunk' && chunk.isEntry;
131
+ const isEntryCss = chunk.type === 'asset' && cssExtensions.test(fileName)
132
+ && entryNames.has(fileName.replace(/\.[^.]+$/, ''));
133
+ if (isEntryJs || isEntryCss) {
134
+ const src = path.join(outDir, fileName);
135
+ const dest = path.join(assetPipelineDir, fileName);
136
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
137
+ fs.copyFileSync(src, dest);
138
+ }
139
+ }
140
+ },
141
+ configureServer(server) {
142
+ let devServerUrl = null;
143
+ let syncTimer = null;
144
+ // Re-discover entries from the entrypoints dir and regenerate all stubs.
145
+ // Called on initial listen and whenever entrypoint files are added/removed.
146
+ const syncStubs = () => {
147
+ if (!devServerUrl)
148
+ return;
149
+ const freshInput = epDir ? discoverEntrypointInputs(sourceDir, epDir) : input;
150
+ const freshEntries = resolveEntries(freshInput, sourceDir);
151
+ // Clear old stubs
152
+ for (const stub of writtenStubs) {
153
+ fs.rmSync(stub, { force: true });
154
+ }
155
+ writtenStubs.length = 0;
156
+ writeDevStubs(freshEntries, assetPipelineDir, devServerUrl, reactRefresh, writtenStubs);
157
+ };
158
+ // Debounced version for watcher events — collapses burst operations
159
+ // (e.g., pasting multiple files, git checkout) into a single rescan.
160
+ const debouncedSyncStubs = () => {
161
+ if (syncTimer)
162
+ clearTimeout(syncTimer);
163
+ syncTimer = setTimeout(syncStubs, 100);
164
+ };
165
+ server.httpServer?.once('listening', () => {
166
+ const address = server.httpServer?.address();
167
+ if (isAddressInfo(address)) {
168
+ devServerUrl = resolveDevServerUrl(address, resolvedConfig);
169
+ syncStubs();
170
+ // Write dev meta file for progressive upgrade to the rails_vite gem
171
+ if (devMetaPath) {
172
+ const meta = { url: devServerUrl, sourceDir };
173
+ if (epDir)
174
+ meta.entrypointsDir = epDir;
175
+ if (reactRefresh)
176
+ meta.reactRefresh = true;
177
+ meta.jsbundling = true;
178
+ fs.mkdirSync(path.dirname(devMetaPath), { recursive: true });
179
+ fs.writeFileSync(devMetaPath, JSON.stringify(meta));
180
+ }
181
+ // Watch entrypoints dir for new/removed files and regenerate stubs
182
+ if (epDir) {
183
+ const epAbsDir = path.resolve(sourceDir, epDir);
184
+ server.watcher.add(epAbsDir);
185
+ const epAbsDirPrefix = epAbsDir + '/';
186
+ server.watcher.on('add', (filePath) => {
187
+ if (filePath.startsWith(epAbsDirPrefix) && isEntrypointFile(filePath)) {
188
+ server.config.logger.info(` RAILS new entrypoint: ${path.relative(sourceDir, filePath)}`);
189
+ debouncedSyncStubs();
190
+ }
191
+ });
192
+ server.watcher.on('unlink', (filePath) => {
193
+ if (filePath.startsWith(epAbsDirPrefix) && isEntrypointFile(filePath)) {
194
+ server.config.logger.info(` RAILS removed entrypoint: ${path.relative(sourceDir, filePath)}`);
195
+ debouncedSyncStubs();
196
+ }
197
+ });
198
+ }
199
+ server.config.logger.info(`\n RAILS rails-vite-plugin/jsbundling → ${assetPipelineDir}`);
200
+ }
201
+ });
202
+ bindExitHandler(() => {
203
+ for (const stub of writtenStubs) {
204
+ fs.rmSync(stub, { force: true });
205
+ }
206
+ writtenStubs.length = 0;
207
+ if (devMetaPath) {
208
+ fs.rmSync(devMetaPath, { force: true });
209
+ }
210
+ });
211
+ // Watch view templates for full-page reload
212
+ const resolvedRefreshPaths = resolveRefreshPaths(options.refresh);
213
+ if (resolvedRefreshPaths.length) {
214
+ const match = picomatch(resolvedRefreshPaths);
215
+ server.watcher.add(resolvedRefreshPaths);
216
+ server.watcher.on('change', (filePath) => {
217
+ const relativePath = path.relative(process.cwd(), filePath);
218
+ if (match(relativePath)) {
219
+ server.hot.send({ type: 'full-reload', path: '*' });
220
+ }
221
+ });
222
+ }
223
+ const devServerIndexHtml = readDevServerIndexHtml();
224
+ return () => server.middlewares.use((req, res, next) => {
225
+ if (req.url === '/index.html') {
226
+ res.statusCode = 404;
227
+ res.setHeader('Content-Type', 'text/html');
228
+ res.end(devServerIndexHtml);
229
+ return;
230
+ }
231
+ next();
232
+ });
233
+ },
234
+ };
235
+ }
236
+ function resolveSsrConfig(ssr, sourceDir, outputDir) {
237
+ if (!ssr)
238
+ return null;
239
+ const entry = typeof ssr === 'string' ? ssr : ssr.entry;
240
+ const outDir = (typeof ssr === 'object' ? ssr.outDir : undefined) ?? outputDir + '-ssr';
241
+ return {
242
+ entry: prefixWithSourceDir(entry, sourceDir),
243
+ outDir,
244
+ };
245
+ }
246
+ /**
247
+ * Write placeholder stubs so Propshaft/Sprockets can discover asset files
248
+ * at boot time, before the Vite dev server is ready with its URL.
249
+ * Writes unconditionally — stubs are overwritten by writeDevStubs once the server is listening.
250
+ */
251
+ function writePlaceholderStubs(entries, outDir, writtenStubs) {
252
+ fs.mkdirSync(outDir, { recursive: true });
253
+ const seen = new Set();
254
+ for (const { name } of entries) {
255
+ if (seen.has(name))
256
+ continue;
257
+ seen.add(name);
258
+ const jsStub = path.join(outDir, `${name}.js`);
259
+ fs.mkdirSync(path.dirname(jsStub), { recursive: true });
260
+ fs.writeFileSync(jsStub, '// rails-vite placeholder – loading…\n');
261
+ writtenStubs.push(jsStub);
262
+ const cssStub = path.join(outDir, `${name}.css`);
263
+ fs.writeFileSync(cssStub, '/* rails-vite placeholder – loading… */\n');
264
+ writtenStubs.push(cssStub);
265
+ }
266
+ }
267
+ function writeDevStubs(entries, outDir, devServerUrl, reactRefresh, writtenStubs) {
268
+ fs.mkdirSync(outDir, { recursive: true });
269
+ const importLine = (url) => reactRefresh ? `await import("${url}");` : `import "${url}";`;
270
+ const jsEntries = entries.filter(e => !cssExtensions.test(e.sourcePath));
271
+ const cssEntries = entries.filter(e => cssExtensions.test(e.sourcePath));
272
+ // JS stubs: import @vite/client + all CSS entries (for HMR) + the JS entry itself.
273
+ for (const { name, sourcePath } of jsEntries) {
274
+ const stubPath = path.join(outDir, `${name}.js`);
275
+ fs.mkdirSync(path.dirname(stubPath), { recursive: true });
276
+ const lines = ['// rails-vite dev stub – DO NOT EDIT'];
277
+ if (reactRefresh) {
278
+ // Static import for the refresh runtime — just exports a function, safe to hoist.
279
+ // Everything else uses dynamic import() so the preamble executes first
280
+ // (static imports hoist, so injectIntoGlobalHook would run after all modules).
281
+ lines.push(`import { injectIntoGlobalHook } from "${devServerUrl}/@react-refresh";`);
282
+ lines.push('injectIntoGlobalHook(window);');
283
+ lines.push('window.$RefreshReg$ = () => {};');
284
+ lines.push('window.$RefreshSig$ = () => (type) => type;');
285
+ }
286
+ lines.push(importLine(`${devServerUrl}/@vite/client`));
287
+ // Import CSS entries so Vite's module graph tracks them for HMR
288
+ for (const { sourcePath: cssPath } of cssEntries) {
289
+ lines.push(importLine(`${devServerUrl}/${cssPath}`));
290
+ }
291
+ lines.push(importLine(`${devServerUrl}/${sourcePath}`));
292
+ fs.writeFileSync(stubPath, lines.join('\n') + '\n');
293
+ writtenStubs.push(stubPath);
294
+ }
295
+ // Empty CSS stubs so stylesheet_link_tag doesn't error.
296
+ // Actual CSS is injected by @vite/client through the JS imports above.
297
+ const seen = new Set();
298
+ for (const { name } of entries) {
299
+ if (seen.has(name))
300
+ continue;
301
+ seen.add(name);
302
+ const cssStubPath = path.join(outDir, `${name}.css`);
303
+ fs.mkdirSync(path.dirname(cssStubPath), { recursive: true });
304
+ fs.writeFileSync(cssStubPath, '/* rails-vite dev stub */\n');
305
+ writtenStubs.push(cssStubPath);
306
+ }
307
+ }
@@ -0,0 +1,2 @@
1
+ import type { AliasOptions } from 'vite';
2
+ export declare function resolveAlias(userAlias: AliasOptions | undefined, sourceDir: string): AliasOptions;
@@ -0,0 +1,12 @@
1
+ import path from 'path';
2
+ export function resolveAlias(userAlias, sourceDir) {
3
+ const atReplacement = path.resolve(process.cwd(), sourceDir);
4
+ if (Array.isArray(userAlias)) {
5
+ const hasAt = userAlias.some(a => a.find === '@');
6
+ return hasAt ? userAlias : [...userAlias, { find: '@', replacement: atReplacement }];
7
+ }
8
+ return {
9
+ '@': atReplacement,
10
+ ...userAlias,
11
+ };
12
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Register a cleanup function that runs on process exit.
3
+ * Signal handlers are only registered once per process to avoid duplicates.
4
+ */
5
+ export declare function bindExitHandler(cleanupFn: () => void): void;
@@ -0,0 +1,14 @@
1
+ let exitHandlersBound = false;
2
+ /**
3
+ * Register a cleanup function that runs on process exit.
4
+ * Signal handlers are only registered once per process to avoid duplicates.
5
+ */
6
+ export function bindExitHandler(cleanupFn) {
7
+ process.on('exit', cleanupFn);
8
+ if (!exitHandlersBound) {
9
+ process.on('SIGINT', () => process.exit());
10
+ process.on('SIGTERM', () => process.exit());
11
+ process.on('SIGHUP', () => process.exit());
12
+ exitHandlersBound = true;
13
+ }
14
+ }
@@ -0,0 +1,2 @@
1
+ export declare const cssExtensionList: string[];
2
+ export declare const cssExtensions: RegExp;
@@ -0,0 +1,2 @@
1
+ export const cssExtensionList = ['css', 'scss', 'sass', 'less', 'styl', 'pcss'];
2
+ export const cssExtensions = new RegExp(`\\.(${cssExtensionList.join('|')})$`);
@@ -0,0 +1 @@
1
+ export declare function readDevServerIndexHtml(): string;
@@ -0,0 +1,6 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ export function readDevServerIndexHtml() {
5
+ return fs.readFileSync(path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'dev-server-index.html'), 'utf-8');
6
+ }
@@ -0,0 +1,5 @@
1
+ import { AddressInfo } from 'net';
2
+ import type { ResolvedConfig } from 'vite';
3
+ import type { DevServerUrl } from './types.js';
4
+ export declare function resolveDevServerUrl(address: AddressInfo, config: ResolvedConfig): DevServerUrl;
5
+ export declare function isAddressInfo(x: string | AddressInfo | null | undefined): x is AddressInfo;
@@ -0,0 +1,20 @@
1
+ export function resolveDevServerUrl(address, config) {
2
+ const hmr = typeof config.server.hmr === 'object' ? config.server.hmr : null;
3
+ const clientProtocol = hmr?.protocol
4
+ ? hmr.protocol === 'wss'
5
+ ? 'https'
6
+ : 'http'
7
+ : null;
8
+ const serverProtocol = config.server.https ? 'https' : 'http';
9
+ const protocol = clientProtocol ?? serverProtocol;
10
+ const configHost = typeof config.server.host === 'string' ? config.server.host : null;
11
+ const serverAddress = address.family === 'IPv6' || address.family === 6
12
+ ? `[${address.address}]`
13
+ : address.address;
14
+ const host = hmr?.host ?? configHost ?? serverAddress;
15
+ const port = hmr?.clientPort ?? address.port;
16
+ return `${protocol}://${host}:${port}`;
17
+ }
18
+ export function isAddressInfo(x) {
19
+ return typeof x === 'object' && x !== null;
20
+ }
@@ -0,0 +1,13 @@
1
+ import type { InputOption, ResolvedEntry } from './types.js';
2
+ export declare function isEntrypointFile(filePath: string): boolean;
3
+ export declare function resolveEntries(input: InputOption, sourceDir: string): ResolvedEntry[];
4
+ /**
5
+ * Convert resolved entries to Rollup-compatible input.
6
+ * Array form for string/array inputs; object form preserves explicit user names.
7
+ */
8
+ export declare function entriesToRollupInput(entries: ResolvedEntry[], isNamedInput: boolean): InputOption;
9
+ export declare function resolveInput(input: InputOption, sourceDir: string): InputOption;
10
+ export declare function prefixWithSourceDir(entry: string, sourceDir: string): string;
11
+ export declare function detectEntrypointsDir(sourceDir: string): string | null;
12
+ export declare function discoverEntrypointInputs(sourceDir: string, entrypointsDir: string): string[];
13
+ export declare function detectEntrypoint(sourceDir: string): string;
@@ -0,0 +1,123 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { cssExtensionList } from './css.js';
4
+ const jsExtensions = ['mjs', 'js', 'mts', 'ts', 'jsx', 'tsx'];
5
+ const entrypointExtensions = new RegExp(`\\.(${[...jsExtensions, ...cssExtensionList].join('|')})$`);
6
+ export function isEntrypointFile(filePath) {
7
+ return entrypointExtensions.test(filePath);
8
+ }
9
+ export function resolveEntries(input, sourceDir) {
10
+ if (typeof input === 'object' && !Array.isArray(input)) {
11
+ return Object.entries(input).map(([name, value]) => ({
12
+ name,
13
+ sourcePath: prefixWithSourceDir(value, sourceDir),
14
+ }));
15
+ }
16
+ const inputs = Array.isArray(input) ? input : [input];
17
+ const sourcePaths = inputs.map(entry => prefixWithSourceDir(entry, sourceDir));
18
+ const commonPrefix = detectCommonEntryPrefix(sourcePaths, sourceDir);
19
+ return sourcePaths.map(sourcePath => ({
20
+ name: entrypointName(sourcePath, sourceDir, commonPrefix),
21
+ sourcePath,
22
+ }));
23
+ }
24
+ /**
25
+ * Convert resolved entries to Rollup-compatible input.
26
+ * Array form for string/array inputs; object form preserves explicit user names.
27
+ */
28
+ export function entriesToRollupInput(entries, isNamedInput) {
29
+ if (isNamedInput) {
30
+ return Object.fromEntries(entries.map(e => [e.name, e.sourcePath]));
31
+ }
32
+ if (entries.length === 1)
33
+ return entries[0].sourcePath;
34
+ return entries.map(e => e.sourcePath);
35
+ }
36
+ export function resolveInput(input, sourceDir) {
37
+ if (typeof input === 'object' && !Array.isArray(input)) {
38
+ return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, prefixWithSourceDir(value, sourceDir)]));
39
+ }
40
+ if (Array.isArray(input)) {
41
+ return input.map((entry) => prefixWithSourceDir(entry, sourceDir));
42
+ }
43
+ return prefixWithSourceDir(input, sourceDir);
44
+ }
45
+ export function prefixWithSourceDir(entry, sourceDir) {
46
+ if (entry.startsWith(sourceDir + '/') || entry.startsWith('/')) {
47
+ return entry;
48
+ }
49
+ return `${sourceDir}/${entry}`;
50
+ }
51
+ export function detectEntrypointsDir(sourceDir) {
52
+ const entrypointsDir = path.join(sourceDir, 'entrypoints');
53
+ return fs.existsSync(entrypointsDir) ? 'entrypoints' : null;
54
+ }
55
+ export function discoverEntrypointInputs(sourceDir, entrypointsDir) {
56
+ const absDir = path.join(sourceDir, entrypointsDir);
57
+ return discoverEntrypoints(absDir).map((entry) => `${entrypointsDir}/${entry}`);
58
+ }
59
+ export function detectEntrypoint(sourceDir) {
60
+ for (const ext of ['.js', '.mjs', '.ts', '.mts', '.jsx', '.tsx']) {
61
+ const candidate = path.join(sourceDir, `application${ext}`);
62
+ if (fs.existsSync(candidate)) {
63
+ return `application${ext}`;
64
+ }
65
+ }
66
+ return 'application.js';
67
+ }
68
+ function discoverEntrypoints(dir, base = dir) {
69
+ const entries = [];
70
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
71
+ if (entry.isDirectory()) {
72
+ entries.push(...discoverEntrypoints(path.join(dir, entry.name), base));
73
+ }
74
+ else if (entrypointExtensions.test(entry.name)) {
75
+ entries.push(path.relative(base, path.join(dir, entry.name)));
76
+ }
77
+ }
78
+ return entries;
79
+ }
80
+ function entrypointName(sourcePath, sourceDir, commonPrefix) {
81
+ let name = sourcePath;
82
+ if (name.startsWith(sourceDir + '/')) {
83
+ name = name.slice(sourceDir.length + 1);
84
+ }
85
+ if (commonPrefix && name.startsWith(commonPrefix + '/')) {
86
+ name = name.slice(commonPrefix.length + 1);
87
+ }
88
+ return name.replace(/\.[^.]+$/, '');
89
+ }
90
+ /**
91
+ * Detect the common directory prefix among all input entries (after stripping sourceDir).
92
+ * This mirrors how Rollup computes entry chunk names and ensures dev stubs match build output.
93
+ */
94
+ function detectCommonEntryPrefix(sourcePaths, sourceDir) {
95
+ if (sourcePaths.length === 0)
96
+ return null;
97
+ const dirs = sourcePaths.map((entry) => {
98
+ let name = entry;
99
+ if (name.startsWith(sourceDir + '/')) {
100
+ name = name.slice(sourceDir.length + 1);
101
+ }
102
+ const dir = path.dirname(name);
103
+ return dir === '.' ? '' : dir;
104
+ });
105
+ const first = dirs[0];
106
+ if (!first)
107
+ return null;
108
+ if (dirs.every((d) => d === first || d.startsWith(first + '/'))) {
109
+ return first;
110
+ }
111
+ const parts = first.split('/');
112
+ let common = '';
113
+ for (const part of parts) {
114
+ const candidate = common ? `${common}/${part}` : part;
115
+ if (dirs.every((d) => d === candidate || d.startsWith(candidate + '/'))) {
116
+ common = candidate;
117
+ }
118
+ else {
119
+ break;
120
+ }
121
+ }
122
+ return common || null;
123
+ }
@@ -0,0 +1 @@
1
+ export declare function ensureCommandShouldRunInEnvironment(command: string, env: Record<string, string>, pluginName: string): void;
@@ -0,0 +1,13 @@
1
+ export function ensureCommandShouldRunInEnvironment(command, env, pluginName) {
2
+ if (command === 'build') {
3
+ return;
4
+ }
5
+ if (env.CI !== undefined) {
6
+ throw new Error(`${pluginName}: You should not run the Vite dev server in CI. ` +
7
+ 'Run the build command instead.');
8
+ }
9
+ if (env.RAILS_ENV === 'production') {
10
+ throw new Error(`${pluginName}: You should not run the Vite dev server in production. ` +
11
+ 'Run the build command instead.');
12
+ }
13
+ }
@@ -0,0 +1,2 @@
1
+ export declare const refreshPaths: string[];
2
+ export declare function resolveRefreshPaths(refresh: boolean | string | string[] | undefined): string[];
@@ -0,0 +1,13 @@
1
+ export const refreshPaths = [
2
+ 'app/views/**/*.{erb,slim,haml}',
3
+ 'app/helpers/**/*.rb',
4
+ ];
5
+ export function resolveRefreshPaths(refresh) {
6
+ if (refresh === false)
7
+ return [];
8
+ if (!refresh || refresh === true)
9
+ return refreshPaths;
10
+ if (typeof refresh === 'string')
11
+ return [refresh];
12
+ return refresh;
13
+ }
@@ -0,0 +1,2 @@
1
+ import type { UserConfig } from 'vite';
2
+ export declare function resolveNoExternal(config: UserConfig): true | Array<string | RegExp>;
@@ -0,0 +1,14 @@
1
+ export function resolveNoExternal(config) {
2
+ const userNoExternal = config.ssr?.noExternal;
3
+ const pluginNoExternal = ['rails-vite-plugin'];
4
+ if (userNoExternal === true) {
5
+ return true;
6
+ }
7
+ if (userNoExternal === undefined) {
8
+ return pluginNoExternal;
9
+ }
10
+ return [
11
+ ...(Array.isArray(userNoExternal) ? userNoExternal : [userNoExternal]),
12
+ ...pluginNoExternal,
13
+ ];
14
+ }
@@ -0,0 +1,8 @@
1
+ export type InputOption = string | string[] | Record<string, string>;
2
+ export type DevServerUrl = `${'http' | 'https'}://${string}:${number}`;
3
+ export interface ResolvedEntry {
4
+ /** Output name (e.g., 'application', 'admin/index') */
5
+ name: string;
6
+ /** Full path relative to project root */
7
+ sourcePath: string;
8
+ }
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rails-vite-plugin",
3
- "version": "0.1.1",
3
+ "version": "0.2.0-beta.1",
4
4
  "description": "Vite plugin for Rails integration",
5
5
  "author": "Svyatoslav Kryukov <me@skryukov.dev>",
6
6
  "license": "MIT",
@@ -11,6 +11,10 @@
11
11
  ".": {
12
12
  "import": "./dist/index.js",
13
13
  "types": "./dist/index.d.ts"
14
+ },
15
+ "./jsbundling": {
16
+ "import": "./dist/jsbundling.js",
17
+ "types": "./dist/jsbundling.d.ts"
14
18
  }
15
19
  },
16
20
  "files": [
@@ -43,6 +47,8 @@
43
47
  "keywords": [
44
48
  "vite",
45
49
  "rails",
50
+ "jsbundling",
51
+ "jsbundling-rails",
46
52
  "vite-plugin"
47
53
  ],
48
54
  "homepage": "https://github.com/skryukov/rails_vite",