@octanejs/vite-plugin 0.1.1 → 0.1.4
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/CHANGELOG.md +234 -0
- package/package.json +8 -3
- package/src/bin/preview.js +20 -5
- package/src/config-entry.js +31 -0
- package/src/constants.js +1 -0
- package/src/index.js +362 -73
- package/src/load-config.js +6 -126
- package/src/project-codegen.js +75 -15
- package/src/resolve-config.js +171 -0
- package/src/routes.js +7 -1
- package/src/server/component-wrappers.js +1 -0
- package/src/server/middleware.js +1 -0
- package/src/server/node-http.js +188 -0
- package/src/server/production.js +276 -16
- package/src/server/render-route.js +86 -38
- package/src/server/router.js +1 -0
- package/src/server/server-route.js +1 -0
- package/src/server/virtual-entry.js +176 -7
- package/tests/_fixtures/app/index.html +11 -0
- package/tests/_fixtures/app/octane.config.ts +14 -0
- package/tests/_fixtures/app/package.json +7 -0
- package/tests/_fixtures/app/src/Layout.tsrx +6 -0
- package/tests/_fixtures/app/src/Page.tsrx +17 -0
- package/tests/_fixtures/app/vite.config.ts +12 -0
- package/tests/handler.test.ts +134 -0
- package/tests/plugin.test.ts +155 -0
- package/tests/production.test.ts +157 -0
- package/tsconfig.typecheck.json +2 -2
- package/types/index.d.ts +102 -13
- package/types/node.d.ts +31 -0
- package/types/production.d.ts +34 -22
package/src/index.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
/** @import {Plugin, ResolvedConfig, ViteDevServer, UserConfig} from 'vite' */
|
|
2
3
|
/** @import {OctaneConfigOptions, ResolvedOctaneConfig, RenderRoute} from '@octanejs/vite-plugin' */
|
|
3
4
|
|
|
4
5
|
import fs from 'node:fs';
|
|
5
|
-
import
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
6
8
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
7
9
|
|
|
8
10
|
import { octane as octaneCompiler } from 'octane/compiler/vite';
|
|
@@ -11,6 +13,9 @@ import { createRouter } from './server/router.js';
|
|
|
11
13
|
import { createContext, runMiddlewareChain } from './server/middleware.js';
|
|
12
14
|
import { handleRenderRoute } from './server/render-route.js';
|
|
13
15
|
import { handleServerRoute } from './server/server-route.js';
|
|
16
|
+
import { generateServerEntry } from './server/virtual-entry.js';
|
|
17
|
+
import { nodeRequestToWebRequest, sendWebResponse } from './server/node-http.js';
|
|
18
|
+
import { ENTRY_FILENAME } from './constants.js';
|
|
14
19
|
import {
|
|
15
20
|
getOctaneConfigPath,
|
|
16
21
|
loadOctaneConfig,
|
|
@@ -28,6 +33,8 @@ import {
|
|
|
28
33
|
|
|
29
34
|
import { patch_global_fetch, is_rpc_request, handle_rpc_request } from '@ripple-ts/adapter/rpc';
|
|
30
35
|
|
|
36
|
+
import { get_route_entry_path } from './routes.js';
|
|
37
|
+
|
|
31
38
|
// Re-export route classes + config helpers (public API surface).
|
|
32
39
|
export { RenderRoute, ServerRoute } from './routes.js';
|
|
33
40
|
export {
|
|
@@ -49,6 +56,59 @@ function is_octane_module_path(file_name) {
|
|
|
49
56
|
return OCTANE_EXTENSIONS.some((extension) => file_name.endsWith(extension));
|
|
50
57
|
}
|
|
51
58
|
|
|
59
|
+
// Query params that mark a Vite transform request (`/src/x.svg?url`,
|
|
60
|
+
// `/src/worker?worker`, …). `?v=`/`?t=` module URLs always carry an extension,
|
|
61
|
+
// so the extension check below already covers them.
|
|
62
|
+
const VITE_QUERY_MARKERS = ['import', 'direct', 'raw', 'url', 'worker', 'sharedworker', 'inline'];
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Is this a request the Vite dev server owns (module/asset/internal), as
|
|
66
|
+
* opposed to a page navigation? A catch-all RenderRoute ('/*splat') must not
|
|
67
|
+
* SSR `/@vite/client` or `/src/main.ts` — those must fall through to Vite's
|
|
68
|
+
* transform middleware. Exported for tests.
|
|
69
|
+
*
|
|
70
|
+
* A dot in the last path segment is NOT enough to claim a request: page URLs
|
|
71
|
+
* like `/docs/v2.0` or `/users/jane.doe` carry one too. So an extension only
|
|
72
|
+
* marks a Vite-owned request when the path names a REAL file under one of
|
|
73
|
+
* `fileRoots` (the Vite root + publicDir — what the dev server can actually
|
|
74
|
+
* serve). With no `fileRoots` the check stays the conservative heuristic
|
|
75
|
+
* (any extension → Vite-owned).
|
|
76
|
+
*
|
|
77
|
+
* @param {URL} url
|
|
78
|
+
* @param {string[]} [fileRoots]
|
|
79
|
+
* @returns {boolean}
|
|
80
|
+
*/
|
|
81
|
+
export function isViteOwnedUrl(url, fileRoots) {
|
|
82
|
+
const pathname = url.pathname;
|
|
83
|
+
// Vite-internal namespaces: /@vite/client, /@id/…, /@fs/…, /@react-refresh.
|
|
84
|
+
if (pathname.startsWith('/@')) return true;
|
|
85
|
+
// Vite/devtools internals: /__open-in-editor, /__inspect, …
|
|
86
|
+
if (pathname.startsWith('/__')) return true;
|
|
87
|
+
if (pathname.includes('/node_modules/')) return true;
|
|
88
|
+
// Vite emits its transform queries as BARE markers (`?url`, `?raw`,
|
|
89
|
+
// `?worker`, `&import`) — only an EMPTY value counts. A valued param
|
|
90
|
+
// (`/docs?url=https://example.com`) is an app query string on a page
|
|
91
|
+
// navigation, not a transform request.
|
|
92
|
+
for (const marker of VITE_QUERY_MARKERS) {
|
|
93
|
+
if (url.searchParams.get(marker) === '') return true;
|
|
94
|
+
}
|
|
95
|
+
// A file extension marks a module/asset request (/src/main.ts, /favicon.svg)
|
|
96
|
+
// — but only when a real file backs it (see above). (dot > 0 so a bare
|
|
97
|
+
// dotfile segment like '/.well-known' doesn't count.)
|
|
98
|
+
const lastSegment = pathname.slice(pathname.lastIndexOf('/') + 1);
|
|
99
|
+
if (lastSegment.lastIndexOf('.') > 0) {
|
|
100
|
+
if (fileRoots === undefined) return true;
|
|
101
|
+
let relPath;
|
|
102
|
+
try {
|
|
103
|
+
relPath = decodeURIComponent(pathname);
|
|
104
|
+
} catch {
|
|
105
|
+
relPath = pathname;
|
|
106
|
+
}
|
|
107
|
+
return fileRoots.some((root) => fs.existsSync(path.join(root, relPath)));
|
|
108
|
+
}
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
|
|
52
112
|
/** @type {import('@ripple-ts/adapter/rpc').AsyncContext | null} */
|
|
53
113
|
let devAsyncContext = null;
|
|
54
114
|
|
|
@@ -96,10 +156,15 @@ function has_route_config(config) {
|
|
|
96
156
|
* server body when pulled via `ssrLoadModule`). The metaPlugin owns config,
|
|
97
157
|
* routing, dev SSR, the client hydrate virtual module, and dev RPC.
|
|
98
158
|
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
159
|
+
* PRODUCTION (`vite build`, when octane.config.ts has routes): the client
|
|
160
|
+
* build is redirected to `{outDir}/client` with a manifest, the hydrate entry
|
|
161
|
+
* is injected into index.html (so Vite bundles + hashes it), and closeBundle
|
|
162
|
+
* runs a second, `ssr: true` build of a generated server entry to
|
|
163
|
+
* `{outDir}/server/entry.js` — a self-contained module (app + octane bundled,
|
|
164
|
+
* node builtins external) exporting `handler`/`nodeHandler` and auto-booting
|
|
165
|
+
* under `node`. See server/virtual-entry.js and server/production.js.
|
|
101
166
|
*
|
|
102
|
-
* @param {{ hmr?: boolean }} [inlineOptions]
|
|
167
|
+
* @param {{ hmr?: boolean, exclude?: string[] }} [inlineOptions]
|
|
103
168
|
* @returns {Plugin[]}
|
|
104
169
|
*/
|
|
105
170
|
export function octane(inlineOptions = {}) {
|
|
@@ -111,6 +176,14 @@ export function octane(inlineOptions = {}) {
|
|
|
111
176
|
let octaneConfig = null;
|
|
112
177
|
/** @type {ReturnType<typeof createRouter> | null} */
|
|
113
178
|
let router = null;
|
|
179
|
+
/** @type {boolean} */
|
|
180
|
+
let isBuild = false;
|
|
181
|
+
/** @type {boolean} Is this the SSR sub-build closeBundle launches? */
|
|
182
|
+
let isSSRBuild = false;
|
|
183
|
+
/** @type {ResolvedOctaneConfig | null} Config loaded for the build (config hook, reused in closeBundle) */
|
|
184
|
+
let buildOctaneConfig = null;
|
|
185
|
+
/** @type {string[]} Module paths the generated client entry maps statically (build only) */
|
|
186
|
+
let staticEntries = [];
|
|
114
187
|
|
|
115
188
|
/** @type {Plugin} */
|
|
116
189
|
const metaPlugin = {
|
|
@@ -118,21 +191,30 @@ export function octane(inlineOptions = {}) {
|
|
|
118
191
|
|
|
119
192
|
/**
|
|
120
193
|
* @param {UserConfig} userConfig
|
|
194
|
+
* @param {import('vite').ConfigEnv} env
|
|
121
195
|
*/
|
|
122
|
-
config(userConfig) {
|
|
196
|
+
async config(userConfig, env) {
|
|
197
|
+
isBuild = env?.command === 'build';
|
|
198
|
+
isSSRBuild = !!userConfig.build?.ssr;
|
|
199
|
+
|
|
123
200
|
const exclude = userConfig.optimizeDeps?.exclude || [];
|
|
124
|
-
|
|
125
|
-
// SSR owns routing
|
|
126
|
-
appType
|
|
201
|
+
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
|
|
205
|
+
// with `octane-preview` (it serves dist/server), not `vite preview`.
|
|
206
|
+
...(userConfig.appType === undefined && !env?.isPreview
|
|
207
|
+
? { appType: /** @type {const} */ ('custom') }
|
|
208
|
+
: {}),
|
|
127
209
|
optimizeDeps: {
|
|
128
210
|
exclude: [
|
|
129
|
-
// `@octanejs/query` ships a `.tsrx` provider component, so it must NOT
|
|
211
|
+
// `@octanejs/tanstack-query` ships a `.tsrx` provider component, so it must NOT
|
|
130
212
|
// be esbuild-prebundled — the octane transform owns `.tsrx` compilation.
|
|
131
213
|
...new Set([
|
|
132
214
|
...exclude,
|
|
133
215
|
'octane',
|
|
134
216
|
'octane/compiler',
|
|
135
|
-
'@octanejs/query',
|
|
217
|
+
'@octanejs/tanstack-query',
|
|
136
218
|
...SERVER_ONLY_ADAPTER_IDS,
|
|
137
219
|
]),
|
|
138
220
|
],
|
|
@@ -140,9 +222,57 @@ export function octane(inlineOptions = {}) {
|
|
|
140
222
|
// Workspace packages with TS source must be transformed by Vite's SSR
|
|
141
223
|
// pipeline (not require()'d raw) so ssrLoadModule gets transpiled code.
|
|
142
224
|
ssr: {
|
|
143
|
-
noExternal: ['octane', 'octane/compiler', '@octanejs/query'],
|
|
225
|
+
noExternal: ['octane', 'octane/compiler', '@octanejs/tanstack-query'],
|
|
144
226
|
},
|
|
145
227
|
};
|
|
228
|
+
|
|
229
|
+
// Production CLIENT build (the SSR sub-build closeBundle launches passes
|
|
230
|
+
// build.ssr, so it skips this): route the client bundle to
|
|
231
|
+
// `{outDir}/client` and emit a manifest for the server's asset map.
|
|
232
|
+
if (isBuild && !isSSRBuild) {
|
|
233
|
+
const projectRoot = userConfig.root ? path.resolve(userConfig.root) : process.cwd();
|
|
234
|
+
if (octaneConfigExists(projectRoot)) {
|
|
235
|
+
buildOctaneConfig = await loadOctaneConfig(projectRoot);
|
|
236
|
+
if (has_route_config(buildOctaneConfig)) {
|
|
237
|
+
if (!fs.existsSync(path.join(projectRoot, 'index.html'))) {
|
|
238
|
+
throw new Error(
|
|
239
|
+
'[@octanejs/vite-plugin] index.html not found — required for SSR builds with octane.config.ts routes.',
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
/** @type {import('vite').UserConfig['build']} */
|
|
243
|
+
const buildConfig = {
|
|
244
|
+
outDir: `${buildOctaneConfig.build.outDir}/client`,
|
|
245
|
+
emptyOutDir: true,
|
|
246
|
+
manifest: true,
|
|
247
|
+
};
|
|
248
|
+
if (buildOctaneConfig.build.minify !== undefined) {
|
|
249
|
+
buildConfig.minify = buildOctaneConfig.build.minify;
|
|
250
|
+
}
|
|
251
|
+
if (buildOctaneConfig.build.target !== undefined) {
|
|
252
|
+
buildConfig.target = buildOctaneConfig.build.target;
|
|
253
|
+
}
|
|
254
|
+
return { ...base, build: buildConfig };
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return base;
|
|
260
|
+
},
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Production client build: collect every module path the server can name
|
|
264
|
+
* in #__octane_data (page entries, layouts, the preHydrate hook) so the
|
|
265
|
+
* generated hydrate entry maps them as STATIC dynamic imports Rollup can
|
|
266
|
+
* chunk and hash.
|
|
267
|
+
*/
|
|
268
|
+
buildStart() {
|
|
269
|
+
if (!isBuild || isSSRBuild || !has_route_config(buildOctaneConfig)) return;
|
|
270
|
+
const cfg = /** @type {ResolvedOctaneConfig} */ (buildOctaneConfig);
|
|
271
|
+
const entries = cfg.router.routes
|
|
272
|
+
.filter((r) => r.type === 'render')
|
|
273
|
+
.flatMap((r) => [get_route_entry_path(/** @type {RenderRoute} */ (r).entry), r.layout]);
|
|
274
|
+
if (cfg.router.preHydrate) entries.push(cfg.router.preHydrate);
|
|
275
|
+
staticEntries = [...new Set(entries.filter((e) => typeof e === 'string'))];
|
|
146
276
|
},
|
|
147
277
|
|
|
148
278
|
async configResolved(resolvedConfig) {
|
|
@@ -167,14 +297,15 @@ export function octane(inlineOptions = {}) {
|
|
|
167
297
|
}
|
|
168
298
|
if (id === RESOLVED_VIRTUAL_HYDRATE_ID) {
|
|
169
299
|
// Dev: dynamic import() of the route entry works through Vite, so the
|
|
170
|
-
// static import map
|
|
171
|
-
// import per entry).
|
|
300
|
+
// static import map stays empty (the codegen falls back to a dynamic
|
|
301
|
+
// import per entry). Production builds pass the routes' module paths
|
|
302
|
+
// (collected in buildStart) so Rollup bundles them.
|
|
172
303
|
const file = write_project_generated_file(
|
|
173
304
|
config,
|
|
174
305
|
'client-entry.js',
|
|
175
306
|
create_client_entry_source({
|
|
176
307
|
configPath: to_vite_root_import(getOctaneConfigPath(root), root),
|
|
177
|
-
staticEntries
|
|
308
|
+
staticEntries,
|
|
178
309
|
}),
|
|
179
310
|
);
|
|
180
311
|
return fs.readFileSync(file, 'utf-8');
|
|
@@ -273,6 +404,22 @@ export function octane(inlineOptions = {}) {
|
|
|
273
404
|
return;
|
|
274
405
|
}
|
|
275
406
|
|
|
407
|
+
// A catch-all RenderRoute ('/*splat') also matches every module /
|
|
408
|
+
// asset / Vite-internal request. Those belong to Vite's transform
|
|
409
|
+
// middleware, not SSR — skip them BEFORE the (per-request) config
|
|
410
|
+
// reload below so module requests stay cheap. ServerRoutes are not
|
|
411
|
+
// filtered: an explicit '/api/data.json' endpoint is legitimate.
|
|
412
|
+
// The file roots let extension-bearing PAGE urls (/docs/v2.0,
|
|
413
|
+
// /users/jane.doe) SSR — only paths naming a real file are Vite's.
|
|
414
|
+
const fileRoots = [config.root];
|
|
415
|
+
if (typeof config.publicDir === 'string' && config.publicDir !== '') {
|
|
416
|
+
fileRoots.push(config.publicDir);
|
|
417
|
+
}
|
|
418
|
+
if (match.route.type === 'render' && isViteOwnedUrl(url, fileRoots)) {
|
|
419
|
+
next();
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
|
|
276
423
|
try {
|
|
277
424
|
// Reload config so route edits are picked up (dev HMR for routes).
|
|
278
425
|
const previousRoutes = octaneConfig.router.routes;
|
|
@@ -360,10 +507,206 @@ export function octane(inlineOptions = {}) {
|
|
|
360
507
|
return [];
|
|
361
508
|
},
|
|
362
509
|
},
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Production client build: inject the hydrate entry into index.html so
|
|
513
|
+
* Vite bundles it into the html chunk graph (the built template then
|
|
514
|
+
* carries the hashed script tag — the production server injects nothing
|
|
515
|
+
* per-request). `order: 'pre'` is required: Vite's build-html plugin
|
|
516
|
+
* collects module scripts BEFORE default-order transforms run, so a
|
|
517
|
+
* post-injected script would ship unbundled. Dev never runs this branch:
|
|
518
|
+
* the dev middleware injects `/@id/virtual:octane-hydrate` itself.
|
|
519
|
+
*/
|
|
520
|
+
transformIndexHtml: {
|
|
521
|
+
order: 'pre',
|
|
522
|
+
handler(html) {
|
|
523
|
+
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>`);
|
|
526
|
+
},
|
|
527
|
+
},
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Production: after the client build completes, build the server bundle.
|
|
531
|
+
* Reads the client manifest into a per-route asset map, generates the
|
|
532
|
+
* server entry (server/virtual-entry.js), and runs a second Vite build
|
|
533
|
+
* with `ssr: true` into `{outDir}/server`. The built index.html MOVES
|
|
534
|
+
* from dist/client to dist/server — it is the SSR template (with
|
|
535
|
+
* unresolved `<!--ssr-*-->` placeholders), and leaving it in the static
|
|
536
|
+
* dir would shadow the SSR handler at `/` on filesystem-first platforms.
|
|
537
|
+
*/
|
|
538
|
+
async closeBundle() {
|
|
539
|
+
if (!isBuild || isSSRBuild || !has_route_config(buildOctaneConfig)) return;
|
|
540
|
+
const cfg = /** @type {ResolvedOctaneConfig} */ (buildOctaneConfig);
|
|
541
|
+
|
|
542
|
+
console.log('[@octanejs/vite-plugin] Client build done. Building the server bundle…');
|
|
543
|
+
|
|
544
|
+
const outDir = cfg.build.outDir;
|
|
545
|
+
const clientOutDir = path.join(root, outDir, 'client');
|
|
546
|
+
const serverOutDir = path.join(root, outDir, 'server');
|
|
547
|
+
|
|
548
|
+
// ------------------------------------------------------------------
|
|
549
|
+
// Client manifest → per-route asset map (stylesheet + modulepreload
|
|
550
|
+
// tags the production server emits for the matched route).
|
|
551
|
+
// ------------------------------------------------------------------
|
|
552
|
+
const manifestPath = path.join(clientOutDir, '.vite', 'manifest.json');
|
|
553
|
+
/** @type {Record<string, { file: string, css?: string[], imports?: string[] }>} */
|
|
554
|
+
let clientManifest = {};
|
|
555
|
+
if (fs.existsSync(manifestPath)) {
|
|
556
|
+
clientManifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
557
|
+
} else {
|
|
558
|
+
console.warn(
|
|
559
|
+
`[@octanejs/vite-plugin] Client manifest not found at ${manifestPath} — per-route asset preloading disabled`,
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* All CSS a manifest entry needs, transitively (cycle-safe).
|
|
565
|
+
* @param {string} key
|
|
566
|
+
* @param {Set<string>} [visited]
|
|
567
|
+
* @returns {string[]}
|
|
568
|
+
*/
|
|
569
|
+
const collectCss = (key, visited = new Set()) => {
|
|
570
|
+
if (visited.has(key)) return [];
|
|
571
|
+
visited.add(key);
|
|
572
|
+
const entry = clientManifest[key];
|
|
573
|
+
if (!entry) return [];
|
|
574
|
+
const css = [...(entry.css || [])];
|
|
575
|
+
for (const imp of entry.imports || []) css.push(...collectCss(imp, visited));
|
|
576
|
+
return css;
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
/** @type {Record<string, { js: string, css: string[] }>} */
|
|
580
|
+
const clientAssetMap = {};
|
|
581
|
+
for (const moduleId of staticEntries) {
|
|
582
|
+
// Manifest keys are root-relative without the leading slash.
|
|
583
|
+
const manifestKey = moduleId.startsWith('/') ? moduleId.slice(1) : moduleId;
|
|
584
|
+
const manifestEntry = clientManifest[manifestKey];
|
|
585
|
+
if (manifestEntry) {
|
|
586
|
+
clientAssetMap[moduleId] = {
|
|
587
|
+
js: manifestEntry.file,
|
|
588
|
+
css: [...new Set(collectCss(manifestKey))],
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// The manifest was only needed here; leaving .vite/ in dist/client would
|
|
594
|
+
// publish source file paths through the static server.
|
|
595
|
+
fs.rmSync(path.join(clientOutDir, '.vite'), { recursive: true, force: true });
|
|
596
|
+
|
|
597
|
+
// ------------------------------------------------------------------
|
|
598
|
+
// Generate the server entry and build it (ssr: true). The build loads
|
|
599
|
+
// the app's vite.config from `root`, so the octane compiler instance
|
|
600
|
+
// there compiles .tsrx in server mode — do NOT add octane() here.
|
|
601
|
+
// ------------------------------------------------------------------
|
|
602
|
+
const serverEntryFile = write_project_generated_file(
|
|
603
|
+
config,
|
|
604
|
+
'server-entry.js',
|
|
605
|
+
generateServerEntry({
|
|
606
|
+
routes: cfg.router.routes,
|
|
607
|
+
octaneConfigPath: getOctaneConfigPath(root),
|
|
608
|
+
clientAssetMap,
|
|
609
|
+
}),
|
|
610
|
+
);
|
|
611
|
+
|
|
612
|
+
const VIRTUAL_SERVER_ENTRY_ID = 'virtual:octane-server-entry';
|
|
613
|
+
const RESOLVED_VIRTUAL_SERVER_ENTRY_ID = '\0' + VIRTUAL_SERVER_ENTRY_ID;
|
|
614
|
+
/** @type {Plugin} */
|
|
615
|
+
const virtualEntryPlugin = {
|
|
616
|
+
name: 'octane-virtual-server-entry',
|
|
617
|
+
resolveId(id) {
|
|
618
|
+
if (id === VIRTUAL_SERVER_ENTRY_ID) return RESOLVED_VIRTUAL_SERVER_ENTRY_ID;
|
|
619
|
+
},
|
|
620
|
+
load(id) {
|
|
621
|
+
if (id === RESOLVED_VIRTUAL_SERVER_ENTRY_ID) {
|
|
622
|
+
return fs.readFileSync(serverEntryFile, 'utf-8');
|
|
623
|
+
}
|
|
624
|
+
},
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
const { build: viteBuild } = await import('vite');
|
|
628
|
+
await viteBuild({
|
|
629
|
+
root,
|
|
630
|
+
appType: 'custom',
|
|
631
|
+
plugins: [virtualEntryPlugin],
|
|
632
|
+
resolve: {
|
|
633
|
+
alias: [
|
|
634
|
+
// octane.config.ts imports RenderRoute/defineConfig from the BARE
|
|
635
|
+
// package — alias it to the config-surface facade so the bundle
|
|
636
|
+
// carries neither the compiler nor the dev middleware. Subpaths
|
|
637
|
+
// ('/production', '/node') resolve normally and ARE bundled.
|
|
638
|
+
{
|
|
639
|
+
find: /^@octanejs\/vite-plugin$/,
|
|
640
|
+
replacement: fileURLToPath(new URL('./config-entry.js', import.meta.url)),
|
|
641
|
+
},
|
|
642
|
+
],
|
|
643
|
+
},
|
|
644
|
+
build: {
|
|
645
|
+
outDir: serverOutDir,
|
|
646
|
+
emptyOutDir: true,
|
|
647
|
+
ssr: true,
|
|
648
|
+
target: cfg.build.target,
|
|
649
|
+
minify: cfg.build.minify ?? false,
|
|
650
|
+
rollupOptions: {
|
|
651
|
+
input: VIRTUAL_SERVER_ENTRY_ID,
|
|
652
|
+
output: {
|
|
653
|
+
entryFileNames: ENTRY_FILENAME,
|
|
654
|
+
format: 'esm',
|
|
655
|
+
},
|
|
656
|
+
},
|
|
657
|
+
},
|
|
658
|
+
ssr: {
|
|
659
|
+
// Self-contained server bundle: everything except node builtins is
|
|
660
|
+
// bundled, so dist/server deploys without node_modules. 'vite'
|
|
661
|
+
// stays external as a guard — nothing should reach it (the facade
|
|
662
|
+
// alias keeps load-config's dynamic import out of the graph).
|
|
663
|
+
noExternal: true,
|
|
664
|
+
external: ['vite'],
|
|
665
|
+
},
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
// The built index.html is the SSR template — move it out of the static
|
|
669
|
+
// client dir (see hook doc above).
|
|
670
|
+
const clientHtml = path.join(clientOutDir, 'index.html');
|
|
671
|
+
if (fs.existsSync(clientHtml)) {
|
|
672
|
+
fs.renameSync(clientHtml, path.join(serverOutDir, 'index.html'));
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
console.log(`[@octanejs/vite-plugin] Server build complete: ${path.join(outDir, 'server')}`);
|
|
676
|
+
console.log(
|
|
677
|
+
`[@octanejs/vite-plugin] Start with: node ${outDir}/server/${ENTRY_FILENAME} (or octane-preview)`,
|
|
678
|
+
);
|
|
679
|
+
|
|
680
|
+
// ------------------------------------------------------------------
|
|
681
|
+
// Deploy adapter (SvelteKit-style): with both bundles on disk, let the
|
|
682
|
+
// config's adapter restructure them for its platform (e.g.
|
|
683
|
+
// @octanejs/adapter-vercel emits `.vercel/output`).
|
|
684
|
+
// ------------------------------------------------------------------
|
|
685
|
+
if (cfg.adapter?.adapt) {
|
|
686
|
+
const adapterName = cfg.adapter.name ?? 'adapter';
|
|
687
|
+
console.log(`[@octanejs/vite-plugin] Running ${adapterName} adapt()…`);
|
|
688
|
+
await cfg.adapter.adapt({
|
|
689
|
+
root,
|
|
690
|
+
outDir,
|
|
691
|
+
clientDir: clientOutDir,
|
|
692
|
+
serverDir: serverOutDir,
|
|
693
|
+
log: (message) => console.log(message),
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
},
|
|
363
697
|
};
|
|
364
698
|
|
|
365
|
-
|
|
366
|
-
|
|
699
|
+
// Forward compiler options. `exclude` lists ad-hoc path fragments the
|
|
700
|
+
// compiler's `.ts`/`.js` hook-slotting pass must skip. Hand-slot-forwarding
|
|
701
|
+
// bindings should not need it: they declare
|
|
702
|
+
// `"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).
|
|
705
|
+
const compilerOptions = {};
|
|
706
|
+
if (inlineOptions.hmr !== undefined) compilerOptions.hmr = inlineOptions.hmr;
|
|
707
|
+
if (inlineOptions.exclude !== undefined) compilerOptions.exclude = inlineOptions.exclude;
|
|
708
|
+
// The compiler plugin is untyped JS (its `enforce` infers as `string`).
|
|
709
|
+
return [/** @type {Plugin} */ (octaneCompiler(compilerOptions)), metaPlugin];
|
|
367
710
|
}
|
|
368
711
|
|
|
369
712
|
// Mainly to enforce types / DX.
|
|
@@ -372,64 +715,10 @@ export function defineConfig(/** @type {OctaneConfigOptions} */ options) {
|
|
|
372
715
|
}
|
|
373
716
|
|
|
374
717
|
// ============================================================================
|
|
375
|
-
// Dev-server HTTP helpers
|
|
718
|
+
// Dev-server HTTP helpers — nodeRequestToWebRequest / sendWebResponse live in
|
|
719
|
+
// server/node-http.js (shared with the production server + serverless wrapper).
|
|
376
720
|
// ============================================================================
|
|
377
721
|
|
|
378
|
-
/**
|
|
379
|
-
* Convert a Node.js IncomingMessage to a Web Request.
|
|
380
|
-
* @param {import('node:http').IncomingMessage} nodeRequest
|
|
381
|
-
* @returns {Request}
|
|
382
|
-
*/
|
|
383
|
-
function nodeRequestToWebRequest(nodeRequest) {
|
|
384
|
-
const host = nodeRequest.headers.host || 'localhost';
|
|
385
|
-
const url = new URL(nodeRequest.url || '/', `http://${host}`);
|
|
386
|
-
|
|
387
|
-
const headers = new Headers();
|
|
388
|
-
for (const [key, value] of Object.entries(nodeRequest.headers)) {
|
|
389
|
-
if (value == null) continue;
|
|
390
|
-
if (Array.isArray(value)) {
|
|
391
|
-
for (const v of value) headers.append(key, v);
|
|
392
|
-
} else {
|
|
393
|
-
headers.set(key, value);
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
const method = (nodeRequest.method || 'GET').toUpperCase();
|
|
398
|
-
/** @type {RequestInit & { duplex?: 'half' }} */
|
|
399
|
-
const init = { method, headers };
|
|
400
|
-
if (method !== 'GET' && method !== 'HEAD') {
|
|
401
|
-
init.body = Readable.toWeb(nodeRequest);
|
|
402
|
-
init.duplex = 'half';
|
|
403
|
-
}
|
|
404
|
-
return new Request(url, init);
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
/**
|
|
408
|
-
* Pipe a Web Response to a Node.js ServerResponse.
|
|
409
|
-
* @param {import('node:http').ServerResponse} nodeResponse
|
|
410
|
-
* @param {Response} webResponse
|
|
411
|
-
*/
|
|
412
|
-
async function sendWebResponse(nodeResponse, webResponse) {
|
|
413
|
-
nodeResponse.statusCode = webResponse.status;
|
|
414
|
-
if (webResponse.statusText) nodeResponse.statusMessage = webResponse.statusText;
|
|
415
|
-
webResponse.headers.forEach((value, key) => {
|
|
416
|
-
nodeResponse.setHeader(key, value);
|
|
417
|
-
});
|
|
418
|
-
if (webResponse.body) {
|
|
419
|
-
const reader = webResponse.body.getReader();
|
|
420
|
-
try {
|
|
421
|
-
while (true) {
|
|
422
|
-
const { done, value } = await reader.read();
|
|
423
|
-
if (done) break;
|
|
424
|
-
nodeResponse.write(value);
|
|
425
|
-
}
|
|
426
|
-
} finally {
|
|
427
|
-
reader.releaseLock();
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
nodeResponse.end();
|
|
431
|
-
}
|
|
432
|
-
|
|
433
722
|
/**
|
|
434
723
|
* Handle a dev RPC request for `module server` declarations.
|
|
435
724
|
*
|
|
@@ -446,7 +735,7 @@ async function handleRpcRequest(req, res, vite, trustProxy, config) {
|
|
|
446
735
|
|
|
447
736
|
const response = await handle_rpc_request(webRequest, {
|
|
448
737
|
async resolveFunction(hash) {
|
|
449
|
-
const rpcModules = globalThis.rpc_modules;
|
|
738
|
+
const rpcModules = /** @type {any} */ (globalThis).rpc_modules;
|
|
450
739
|
if (!rpcModules) return null;
|
|
451
740
|
const moduleInfo = rpcModules.get(hash);
|
|
452
741
|
if (!moduleInfo) return null;
|
package/src/load-config.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
/**
|
|
2
3
|
* Shared utility for loading and resolving octane.config.ts.
|
|
3
4
|
*
|
|
@@ -20,135 +21,14 @@
|
|
|
20
21
|
import path from 'node:path';
|
|
21
22
|
import fs from 'node:fs';
|
|
22
23
|
import { compile } from 'octane/compiler';
|
|
23
|
-
import {
|
|
24
|
+
import { resolveOctaneConfig } from './resolve-config.js';
|
|
24
25
|
|
|
25
26
|
const OCTANE_EXTENSION_PATTERN = /\.tsrx$/;
|
|
26
27
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
function validate_render_route(route) {
|
|
32
|
-
if (
|
|
33
|
-
!route ||
|
|
34
|
-
typeof route !== 'object' ||
|
|
35
|
-
/** @type {{ type?: unknown }} */ (route).type !== 'render'
|
|
36
|
-
) {
|
|
37
|
-
return;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const render_route = /** @type {{ entry?: unknown, layout?: unknown }} */ (route);
|
|
41
|
-
const has_entry =
|
|
42
|
-
typeof render_route.entry === 'string' ||
|
|
43
|
-
(Array.isArray(render_route.entry) &&
|
|
44
|
-
render_route.entry.length === 2 &&
|
|
45
|
-
typeof render_route.entry[0] === 'string' &&
|
|
46
|
-
typeof render_route.entry[1] === 'string');
|
|
47
|
-
|
|
48
|
-
if (!has_entry) {
|
|
49
|
-
throw new Error('[@octanejs/vite-plugin] RenderRoute requires a string/tuple `entry`.');
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
if (render_route.layout !== undefined && typeof render_route.layout !== 'string') {
|
|
53
|
-
throw new Error('[@octanejs/vite-plugin] RenderRoute `layout` must be a string path.');
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* @param {unknown} rootBoundary
|
|
59
|
-
* @returns {void}
|
|
60
|
-
*/
|
|
61
|
-
function validate_root_boundary(rootBoundary) {
|
|
62
|
-
if (rootBoundary === undefined) {
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
if (!rootBoundary || typeof rootBoundary !== 'object') {
|
|
66
|
-
throw new Error('[@octanejs/vite-plugin] rootBoundary must be an object when provided.');
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const boundary = /** @type {{ pending?: unknown, catch?: unknown }} */ (rootBoundary);
|
|
70
|
-
if (boundary.pending !== undefined && typeof boundary.pending !== 'function') {
|
|
71
|
-
throw new Error('[@octanejs/vite-plugin] rootBoundary.pending must be a component function.');
|
|
72
|
-
}
|
|
73
|
-
if (boundary.catch !== undefined && typeof boundary.catch !== 'function') {
|
|
74
|
-
throw new Error('[@octanejs/vite-plugin] rootBoundary.catch must be a component function.');
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Validate a raw octane config and apply all defaults.
|
|
80
|
-
*
|
|
81
|
-
* After this function returns every optional field carries its default
|
|
82
|
-
* value so callers never need to use `??` / `||` fallbacks.
|
|
83
|
-
*
|
|
84
|
-
* The function is idempotent — passing an already-resolved config
|
|
85
|
-
* through it again is safe and produces the same result.
|
|
86
|
-
*
|
|
87
|
-
* @param {OctaneConfigOptions} raw - The user-provided config (from octane.config.ts)
|
|
88
|
-
* @param {{ requireAdapter?: boolean }} [options]
|
|
89
|
-
* @returns {ResolvedOctaneConfig}
|
|
90
|
-
*/
|
|
91
|
-
export function resolveOctaneConfig(raw, options = {}) {
|
|
92
|
-
const { requireAdapter = false } = options;
|
|
93
|
-
|
|
94
|
-
// ------------------------------------------------------------------
|
|
95
|
-
// Validate
|
|
96
|
-
// ------------------------------------------------------------------
|
|
97
|
-
if (!raw) {
|
|
98
|
-
throw new Error(
|
|
99
|
-
'[@octanejs/vite-plugin] octane.config.ts must export a default config object.',
|
|
100
|
-
);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
if (requireAdapter) {
|
|
104
|
-
if (!raw.adapter) {
|
|
105
|
-
throw new Error(
|
|
106
|
-
'[@octanejs/vite-plugin] Production builds require an `adapter` in octane.config.ts. ' +
|
|
107
|
-
'Install an adapter package (e.g. @ripple-ts/adapter-node) and set the `adapter` property.',
|
|
108
|
-
);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
if (!raw.adapter.runtime) {
|
|
112
|
-
throw new Error(
|
|
113
|
-
'[@octanejs/vite-plugin] The adapter in octane.config.ts is missing the `runtime` property. ' +
|
|
114
|
-
'Make sure your adapter exports runtime primitives.',
|
|
115
|
-
);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
if (raw.router?.routes !== undefined && !Array.isArray(raw.router.routes)) {
|
|
120
|
-
throw new Error('[@octanejs/vite-plugin] router.routes must be an array.');
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
for (const route of raw.router?.routes ?? []) {
|
|
124
|
-
validate_render_route(route);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
validate_root_boundary(raw.rootBoundary);
|
|
128
|
-
|
|
129
|
-
// ------------------------------------------------------------------
|
|
130
|
-
// Apply defaults
|
|
131
|
-
// ------------------------------------------------------------------
|
|
132
|
-
return {
|
|
133
|
-
build: {
|
|
134
|
-
outDir: raw.build?.outDir ?? DEFAULT_OUTDIR,
|
|
135
|
-
minify: raw.build?.minify,
|
|
136
|
-
target: raw.build?.target,
|
|
137
|
-
},
|
|
138
|
-
adapter: raw.adapter,
|
|
139
|
-
router: {
|
|
140
|
-
routes: raw.router?.routes ?? [],
|
|
141
|
-
},
|
|
142
|
-
rootBoundary: raw.rootBoundary ?? {},
|
|
143
|
-
middlewares: raw.middlewares ?? [],
|
|
144
|
-
platform: {
|
|
145
|
-
env: raw.platform?.env ?? {},
|
|
146
|
-
},
|
|
147
|
-
server: {
|
|
148
|
-
trustProxy: raw.server?.trustProxy ?? false,
|
|
149
|
-
},
|
|
150
|
-
};
|
|
151
|
-
}
|
|
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';
|
|
152
32
|
|
|
153
33
|
/**
|
|
154
34
|
* Return the absolute path to octane.config.ts for the given project root.
|