@octanejs/vite-plugin 0.1.5 → 0.1.6

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/package.json CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "@octanejs/vite-plugin",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
- "description": "Vite metaframework plugin for the octane renderer (dev SSR + routing + hydrate)",
6
+ "engines": {
7
+ "node": ">=22"
8
+ },
9
+ "description": "Vite integration for Octane (SPA compilation plus optional routing, SSR, and hydration)",
7
10
  "author": {
8
11
  "name": "Dominic Gannaway",
9
12
  "email": "dg@domgan.com"
@@ -16,6 +19,10 @@
16
19
  "url": "git+https://github.com/octanejs/octane.git",
17
20
  "directory": "packages/vite-plugin-octane"
18
21
  },
22
+ "files": [
23
+ "src",
24
+ "types"
25
+ ],
19
26
  "module": "src/index.js",
20
27
  "main": "src/index.js",
21
28
  "bin": {
@@ -40,11 +47,16 @@
40
47
  },
41
48
  "dependencies": {
42
49
  "@ripple-ts/adapter": "^0.3.86",
43
- "octane": "0.1.5"
50
+ "@octanejs/app-core": "0.0.2"
51
+ },
52
+ "peerDependencies": {
53
+ "vite": "^8.0.16",
54
+ "octane": "0.1.6"
44
55
  },
45
56
  "devDependencies": {
46
57
  "@types/node": "^24.3.0",
47
58
  "type-fest": "^5.6.0",
48
- "vite": "^8.0.16"
59
+ "vite": "^8.0.16",
60
+ "octane": "0.1.6"
49
61
  }
50
62
  }
@@ -13,6 +13,21 @@
13
13
 
14
14
  export { RenderRoute, ServerRoute } from './routes.js';
15
15
  export { resolveOctaneConfig } from './resolve-config.js';
16
+ export { OCTANE_NONCE_STATE_KEY } from './constants.js';
17
+ export {
18
+ DEFAULT_OUTDIR,
19
+ ENTRY_FILENAME,
20
+ compose,
21
+ createContext,
22
+ createRouter,
23
+ get_component_export,
24
+ get_route_entry_export_name,
25
+ get_route_entry_id,
26
+ get_route_entry_path,
27
+ handleServerRoute,
28
+ is_rpc_request,
29
+ runMiddlewareChain,
30
+ } from '@octanejs/app-core';
16
31
 
17
32
  // Mirrors src/index.js — enforce types / DX only.
18
33
  export function defineConfig(/** @type {any} */ options) {
package/src/constants.js CHANGED
@@ -1,3 +1 @@
1
- // @ts-check
2
- export const DEFAULT_OUTDIR = 'dist';
3
- export const ENTRY_FILENAME = 'entry.js';
1
+ export { DEFAULT_OUTDIR, ENTRY_FILENAME, OCTANE_NONCE_STATE_KEY } from '@octanejs/app-core';
package/src/index.js CHANGED
@@ -6,6 +6,7 @@ import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { AsyncLocalStorage } from 'node:async_hooks';
9
+ import { createRequire } from 'node:module';
9
10
 
10
11
  import { octane as octaneCompiler } from 'octane/compiler/vite';
11
12
 
@@ -13,12 +14,14 @@ import { createRouter } from './server/router.js';
13
14
  import { createContext, runMiddlewareChain } from './server/middleware.js';
14
15
  import { handleRenderRoute } from './server/render-route.js';
15
16
  import { handleServerRoute } from './server/server-route.js';
17
+ import { HYDRATION_NONCE_PLACEHOLDER, injectHydrationEntry } from './server/html-template.js';
16
18
  import { generateServerEntry } from './server/virtual-entry.js';
17
19
  import { nodeRequestToWebRequest, sendWebResponse } from './server/node-http.js';
18
20
  import { ENTRY_FILENAME } from './constants.js';
19
21
  import {
20
22
  getOctaneConfigPath,
21
23
  loadOctaneConfig,
24
+ loadOctaneConfigWithMetadata,
22
25
  resolveOctaneConfig,
23
26
  octaneConfigExists,
24
27
  } from './load-config.js';
@@ -37,16 +40,35 @@ import { get_route_entry_path } from './routes.js';
37
40
 
38
41
  // Re-export route classes + config helpers (public API surface).
39
42
  export { RenderRoute, ServerRoute } from './routes.js';
43
+ export { OCTANE_NONCE_STATE_KEY } from './constants.js';
44
+ export {
45
+ DEFAULT_OUTDIR,
46
+ ENTRY_FILENAME,
47
+ compose,
48
+ createContext,
49
+ createRouter,
50
+ get_component_export,
51
+ get_route_entry_export_name,
52
+ get_route_entry_id,
53
+ get_route_entry_path,
54
+ handleServerRoute,
55
+ is_rpc_request,
56
+ runMiddlewareChain,
57
+ } from '@octanejs/app-core';
40
58
  export {
41
59
  getOctaneConfigPath,
42
60
  loadOctaneConfig,
61
+ loadOctaneConfigWithMetadata,
43
62
  resolveOctaneConfig,
44
63
  octaneConfigExists,
45
64
  } from './load-config.js';
46
65
 
47
66
  const VIRTUAL_HYDRATE_ID = 'virtual:octane-hydrate';
48
67
  const RESOLVED_VIRTUAL_HYDRATE_ID = '\0virtual:octane-hydrate';
49
- const OCTANE_EXTENSIONS = ['.tsrx'];
68
+ const requireFromPlugin = createRequire(import.meta.url);
69
+ // Mirrors octane/compiler/vite's full-compiler surface. Keeping this list in
70
+ // sync is especially important for production `module server` discovery.
71
+ const OCTANE_EXTENSIONS = ['.tsrx', '.tsx'];
50
72
 
51
73
  /**
52
74
  * @param {string} file_name
@@ -147,7 +169,9 @@ function has_route_config(config) {
147
169
  }
148
170
 
149
171
  /**
150
- * The octane metaframework Vite plugin.
172
+ * The recommended Octane Vite integration. With no octane.config.ts it behaves
173
+ * as a compiler plugin inside a normal Vite SPA; configured routes activate the
174
+ * metaframework layer.
151
175
  *
152
176
  * Returns an ARRAY: `[octaneCompiler({ hmr }), metaPlugin]`. The first element is
153
177
  * octane/compiler's transform plugin — it owns ALL `.tsrx` compilation, picking
@@ -184,6 +208,8 @@ export function octane(inlineOptions = {}) {
184
208
  let buildOctaneConfig = null;
185
209
  /** @type {string[]} Module paths the generated client entry maps statically (build only) */
186
210
  let staticEntries = [];
211
+ /** @type {Set<string>} Vite-root paths of modules containing `module server` */
212
+ const serverModuleModules = new Set();
187
213
 
188
214
  /** @type {Plugin} */
189
215
  const metaPlugin = {
@@ -196,33 +222,31 @@ export function octane(inlineOptions = {}) {
196
222
  async config(userConfig, env) {
197
223
  isBuild = env?.command === 'build';
198
224
  isSSRBuild = !!userConfig.build?.ssr;
225
+ const projectRoot = userConfig.root ? path.resolve(userConfig.root) : process.cwd();
226
+ const hasOctaneConfig = octaneConfigExists(projectRoot);
199
227
 
200
228
  const exclude = userConfig.optimizeDeps?.exclude || [];
201
229
  const base = {
202
- // SSR owns routing, so default appType to 'custom' (no SPA HTML
203
- // fallback masking SSR routes). Respect an explicit user appType, and
204
- // leave `vite preview` alone the production SSR build is previewed
230
+ // A zero-config project is a normal Vite SPA: leave appType unset so
231
+ // Vite retains its HTML transform and history fallback. An Octane app
232
+ // config opts into framework routing, where SSR owns navigation and the
233
+ // SPA fallback must not mask unmatched routes. Respect an explicit user
234
+ // appType, and leave `vite preview` alone — production SSR is previewed
205
235
  // with `octane-preview` (it serves dist/server), not `vite preview`.
206
- ...(userConfig.appType === undefined && !env?.isPreview
236
+ ...(hasOctaneConfig && userConfig.appType === undefined && !env?.isPreview
207
237
  ? { appType: /** @type {const} */ ('custom') }
208
238
  : {}),
209
239
  optimizeDeps: {
210
240
  exclude: [
211
- // `@octanejs/tanstack-query` ships a `.tsrx` provider component, so it must NOT
212
- // be esbuild-prebundled the octane transform owns `.tsrx` compilation.
213
- ...new Set([
214
- ...exclude,
215
- 'octane',
216
- 'octane/compiler',
217
- '@octanejs/tanstack-query',
218
- ...SERVER_ONLY_ADAPTER_IDS,
219
- ]),
241
+ // The compiler plugin has already added every manifest-discovered
242
+ // raw Octane dependency to `exclude`; preserve that list here.
243
+ ...new Set([...exclude, 'octane', 'octane/compiler', ...SERVER_ONLY_ADAPTER_IDS]),
220
244
  ],
221
245
  },
222
- // Workspace packages with TS source must be transformed by Vite's SSR
223
- // pipeline (not require()'d raw) so ssrLoadModule gets transpiled code.
246
+ // Raw binding packages are supplied recursively by the compiler plugin;
247
+ // these core entrypoints remain explicit metaframework dependencies.
224
248
  ssr: {
225
- noExternal: ['octane', 'octane/compiler', '@octanejs/tanstack-query'],
249
+ noExternal: ['octane', 'octane/compiler'],
226
250
  },
227
251
  };
228
252
 
@@ -230,8 +254,7 @@ export function octane(inlineOptions = {}) {
230
254
  // build.ssr, so it skips this): route the client bundle to
231
255
  // `{outDir}/client` and emit a manifest for the server's asset map.
232
256
  if (isBuild && !isSSRBuild) {
233
- const projectRoot = userConfig.root ? path.resolve(userConfig.root) : process.cwd();
234
- if (octaneConfigExists(projectRoot)) {
257
+ if (hasOctaneConfig) {
235
258
  buildOctaneConfig = await loadOctaneConfig(projectRoot);
236
259
  if (has_route_config(buildOctaneConfig)) {
237
260
  if (!fs.existsSync(path.join(projectRoot, 'index.html'))) {
@@ -267,11 +290,16 @@ export function octane(inlineOptions = {}) {
267
290
  */
268
291
  buildStart() {
269
292
  if (!isBuild || isSSRBuild || !has_route_config(buildOctaneConfig)) return;
293
+ serverModuleModules.clear();
270
294
  const cfg = /** @type {ResolvedOctaneConfig} */ (buildOctaneConfig);
271
295
  const entries = cfg.router.routes
272
296
  .filter((r) => r.type === 'render')
273
297
  .flatMap((r) => [get_route_entry_path(/** @type {RenderRoute} */ (r).entry), r.layout]);
274
298
  if (cfg.router.preHydrate) entries.push(cfg.router.preHydrate);
299
+ entries.push(
300
+ get_route_entry_path(cfg.rootBoundary.pending),
301
+ get_route_entry_path(cfg.rootBoundary.catch),
302
+ );
275
303
  staticEntries = [...new Set(entries.filter((e) => typeof e === 'string'))];
276
304
  },
277
305
 
@@ -313,6 +341,22 @@ export function octane(inlineOptions = {}) {
313
341
  return null;
314
342
  },
315
343
 
344
+ /**
345
+ * Observe the compiler's client output after the pre-transform. A generated
346
+ * __serverRpc call is an exact, syntax-level signal that this source owns a
347
+ * `module server`; production uses the collected paths as static SSR imports.
348
+ */
349
+ transform(code, id, options) {
350
+ if (!isBuild || options?.ssr || !is_octane_module_path(id.split('?')[0])) return null;
351
+ if (!code.includes('_$__serverRpc(')) return null;
352
+ const file = id.split('?')[0];
353
+ const relative = path.relative(root, file);
354
+ const isWithinRoot =
355
+ relative !== '..' && !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative);
356
+ serverModuleModules.add(isWithinRoot ? '/' + relative.split(path.sep).join('/') : file);
357
+ return null;
358
+ },
359
+
316
360
  /**
317
361
  * Dev SSR middleware. Registered as a pre-hook (no return) so it runs
318
362
  * BEFORE Vite's HTML fallback. Config is loaded lazily on first request
@@ -521,8 +565,7 @@ export function octane(inlineOptions = {}) {
521
565
  order: 'pre',
522
566
  handler(html) {
523
567
  if (!isBuild || isSSRBuild || !has_route_config(buildOctaneConfig)) return html;
524
- const hydrationScript = `<script type="module" src="${VIRTUAL_HYDRATE_ID}"></script>`;
525
- return html.replace('</body>', `${hydrationScript}\n</body>`);
568
+ return injectHydrationEntry(html, VIRTUAL_HYDRATE_ID, HYDRATION_NONCE_PLACEHOLDER);
526
569
  },
527
570
  },
528
571
 
@@ -605,7 +648,14 @@ export function octane(inlineOptions = {}) {
605
648
  generateServerEntry({
606
649
  routes: cfg.router.routes,
607
650
  octaneConfigPath: getOctaneConfigPath(root),
651
+ rootBoundary: cfg.rootBoundary,
652
+ rpcModulePaths: [...serverModuleModules],
608
653
  clientAssetMap,
654
+ // The virtual entry has no filesystem importer, so resolve app-core
655
+ // from this package before handing source to Vite. This also works
656
+ // when app-core is nested under the plugin by a package manager.
657
+ productionModuleId: requireFromPlugin.resolve('@octanejs/app-core/production'),
658
+ nodeModuleId: requireFromPlugin.resolve('@octanejs/app-core/node'),
609
659
  }),
610
660
  );
611
661
 
@@ -700,8 +750,8 @@ export function octane(inlineOptions = {}) {
700
750
  // compiler's `.ts`/`.js` hook-slotting pass must skip. Hand-slot-forwarding
701
751
  // bindings should not need it: they declare
702
752
  // `"octane": { "hookSlots": { "manual": ["src"] } }` in their own package.json and the
703
- // compiler plugin skips them via a nearest-manifest lookup (published
704
- // bindings live in node_modules and are skipped outright).
753
+ // compiler plugin skips those directories via a nearest-manifest lookup.
754
+ // Other installed raw-source Octane packages are transformed automatically.
705
755
  const compilerOptions = {};
706
756
  if (inlineOptions.hmr !== undefined) compilerOptions.hmr = inlineOptions.hmr;
707
757
  if (inlineOptions.exclude !== undefined) compilerOptions.exclude = inlineOptions.exclude;
@@ -1,95 +1,62 @@
1
1
  // @ts-check
2
- /**
3
- * Shared utility for loading and resolving octane.config.ts.
4
- *
5
- * `resolveOctaneConfig` is the single source of truth for all config
6
- * validation and default values. Every consumer should receive a
7
- * `ResolvedOctaneConfig` rather than applying ad-hoc defaults.
8
- *
9
- * `loadOctaneConfig` is the single entry point for loading the config
10
- * file. It accepts an optional Vite dev server — when provided the
11
- * config is loaded via `ssrLoadModule` (no temp server overhead,
12
- * HMR-aware). Otherwise a temporary Vite server is spun up, used to
13
- * transpile the TypeScript config, and immediately shut down.
14
- *
15
- * Used by the Vite plugin (during dev + build), the preview CLI script,
16
- * and the generated production server entry.
17
- */
18
-
19
- /** @import { OctaneConfigOptions, ResolvedOctaneConfig } from '@octanejs/vite-plugin' */
20
2
 
21
- import path from 'node:path';
22
- import fs from 'node:fs';
3
+ import {
4
+ getOctaneConfigPath,
5
+ loadOctaneConfig as loadCoreOctaneConfig,
6
+ loadOctaneConfigWithMetadata as loadCoreOctaneConfigWithMetadata,
7
+ octaneConfigExists,
8
+ resolveOctaneConfig,
9
+ } from '@octanejs/app-core/config-loader';
23
10
  import { compile } from 'octane/compiler';
24
- import { resolveOctaneConfig } from './resolve-config.js';
25
11
 
26
- const OCTANE_EXTENSION_PATTERN = /\.tsrx$/;
27
-
28
- // Validation + defaults live in resolve-config.js (a module with no vite /
29
- // compiler imports) so the production server bundle can include it without
30
- // dragging the toolchain along. Re-exported here for existing importers.
31
- export { resolveOctaneConfig } from './resolve-config.js';
12
+ export { getOctaneConfigPath, octaneConfigExists, resolveOctaneConfig };
32
13
 
33
14
  /**
34
- * Return the absolute path to octane.config.ts for the given project root.
35
- *
36
- * This is the single source of truth for the config file name / location.
15
+ * Vite compatibility facade over app-core's bundler-neutral config loader.
16
+ * A live dev server is adapted to the neutral module-runner contract. Build
17
+ * and preview calls use a temporary Vite module runner so lazy config imports
18
+ * keep Vite's transform semantics without making app-core depend on Vite.
37
19
  *
38
- * @param {string} projectRoot - Absolute path to the project root
39
- * @returns {string}
20
+ * @param {string} projectRoot
21
+ * @param {{
22
+ * vite?: import('vite').ViteDevServer,
23
+ * moduleRunner?: import('@octanejs/app-core').ConfigModuleRunner | import('@octanejs/app-core').ConfigModuleRunner['loadModule'],
24
+ * requireAdapter?: boolean,
25
+ * configFile?: string,
26
+ * cacheDir?: string,
27
+ * }} [options]
40
28
  */
41
- export function getOctaneConfigPath(projectRoot) {
42
- return path.join(projectRoot, 'octane.config.ts');
29
+ export async function loadOctaneConfig(projectRoot, options = {}) {
30
+ return withDefaultViteRunner(projectRoot, options, loadCoreOctaneConfig);
43
31
  }
44
32
 
45
33
  /**
46
- * Check whether a octane.config.ts file exists in the given root.
47
- *
48
- * Use this before calling `loadOctaneConfig` when the absence of a
49
- * config is a valid state (e.g. the Vite plugin running in SPA mode).
50
- *
51
- * @param {string} projectRoot - Absolute path to the project root
52
- * @returns {boolean}
34
+ * @param {string} projectRoot
35
+ * @param {{
36
+ * vite?: import('vite').ViteDevServer,
37
+ * moduleRunner?: import('@octanejs/app-core').ConfigModuleRunner | import('@octanejs/app-core').ConfigModuleRunner['loadModule'],
38
+ * requireAdapter?: boolean,
39
+ * configFile?: string,
40
+ * cacheDir?: string,
41
+ * }} [options]
53
42
  */
54
- export function octaneConfigExists(projectRoot) {
55
- return fs.existsSync(getOctaneConfigPath(projectRoot));
43
+ export async function loadOctaneConfigWithMetadata(projectRoot, options = {}) {
44
+ return withDefaultViteRunner(projectRoot, options, loadCoreOctaneConfigWithMetadata);
56
45
  }
57
46
 
58
47
  /**
59
- * Load octane.config.ts, validate, and apply defaults via `resolveOctaneConfig`.
60
- *
61
- * When a Vite dev server is provided via `options.vite`, the config is loaded
62
- * through its `ssrLoadModule` avoiding the cost of spinning up a temporary
63
- * server and enabling HMR-aware reloads.
64
- *
65
- * When no dev server is available (build / preview), a temporary Vite server
66
- * is created in middleware mode, used to transpile the config, then shut down.
67
- *
68
- * Throws if the config file does not exist or is invalid.
69
- *
70
- * @param {string} projectRoot - Absolute path to the project root
71
- * @param {{ vite?: import('vite').ViteDevServer, requireAdapter?: boolean }} [options]
72
- * @returns {Promise<ResolvedOctaneConfig>}
48
+ * @template T
49
+ * @param {string} projectRoot
50
+ * @param {Record<string, any>} options
51
+ * @param {(root: string, options: any) => Promise<T>} loader
52
+ * @returns {Promise<T>}
73
53
  */
74
- export async function loadOctaneConfig(projectRoot, options = {}) {
75
- const { vite, requireAdapter } = options;
76
- const configPath = getOctaneConfigPath(projectRoot);
77
-
78
- if (!fs.existsSync(configPath)) {
79
- throw new Error(`[@octanejs/vite-plugin] octane.config.ts not found in ${projectRoot}`);
54
+ async function withDefaultViteRunner(projectRoot, options, loader) {
55
+ if (options.vite || options.moduleRunner) {
56
+ return loader(projectRoot, withViteModuleRunner(options));
80
57
  }
81
58
 
82
- // When a running Vite dev server is available, use it directly.
83
- if (vite) {
84
- const configModule = await vite.ssrLoadModule(configPath);
85
- return resolveOctaneConfig(configModule.default, { requireAdapter });
86
- }
87
-
88
- // Otherwise spin up a temporary Vite server (build / preview).
89
- // The temp server only transpiles octane.config.ts (plain TypeScript) —
90
- // no .tsrx compilation plugin is needed beyond config-referenced helpers.
91
59
  const { createServer } = await import('vite');
92
-
93
60
  const tempVite = await createServer({
94
61
  root: projectRoot,
95
62
  configFile: false,
@@ -99,12 +66,9 @@ export async function loadOctaneConfig(projectRoot, options = {}) {
99
66
  {
100
67
  name: 'octane-config-tsrx-loader',
101
68
  transform(source, id) {
102
- if (!OCTANE_EXTENSION_PATTERN.test(id)) return null;
103
- const filename = id.replace(projectRoot, '');
104
- return compile(source, filename, {
105
- mode: 'server',
106
- hmr: false,
107
- });
69
+ const file = id.split('?')[0];
70
+ if (!file.endsWith('.tsrx')) return null;
71
+ return compile(source, file, { mode: 'server', hmr: false });
108
72
  },
109
73
  },
110
74
  ],
@@ -112,9 +76,25 @@ export async function loadOctaneConfig(projectRoot, options = {}) {
112
76
  });
113
77
 
114
78
  try {
115
- const configModule = await tempVite.ssrLoadModule(configPath);
116
- return resolveOctaneConfig(configModule.default, { requireAdapter });
79
+ return await loader(projectRoot, {
80
+ ...options,
81
+ moduleRunner: {
82
+ loadModule: (/** @type {string} */ id) => tempVite.ssrLoadModule(id),
83
+ },
84
+ });
117
85
  } finally {
118
86
  await tempVite.close();
119
87
  }
120
88
  }
89
+
90
+ /** @param {Record<string, any>} options */
91
+ function withViteModuleRunner(options) {
92
+ if (!options.vite || options.moduleRunner) return options;
93
+ const { vite, ...rest } = options;
94
+ return {
95
+ ...rest,
96
+ moduleRunner: {
97
+ loadModule: (/** @type {string} */ id) => vite.ssrLoadModule(id),
98
+ },
99
+ };
100
+ }