@octanejs/vite-plugin 0.1.3 → 0.1.5
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 +83 -1
- package/package.json +7 -2
- 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 -37
- 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 -21
package/src/project-codegen.js
CHANGED
|
@@ -1,19 +1,28 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
/** @import { ResolvedConfig } from 'vite' */
|
|
2
3
|
|
|
3
4
|
import fs from 'node:fs';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
|
|
6
7
|
export const RESOLVED_ADAPTER_BROWSER_STUB_ID = '\0octane:adapter-browser-stub';
|
|
8
|
+
// Server-only deploy/adapter packages: client-side imports of these specifiers
|
|
9
|
+
// resolve to the browser stub below instead of the real module (whose graph
|
|
10
|
+
// pulls node builtins). Every published adapter package MUST be listed here,
|
|
11
|
+
// and its public exports added to the stub.
|
|
7
12
|
export const SERVER_ONLY_ADAPTER_IDS = new Set([
|
|
8
13
|
'@ripple-ts/adapter-node',
|
|
9
14
|
'@ripple-ts/adapter-bun',
|
|
10
15
|
'@ripple-ts/adapter-vercel',
|
|
16
|
+
'@octanejs/adapter-vercel',
|
|
11
17
|
]);
|
|
12
18
|
|
|
13
19
|
/** @type {Map<string, string>} */
|
|
14
20
|
const generated_file_cache = new Map();
|
|
15
21
|
|
|
16
22
|
/**
|
|
23
|
+
* The browser stand-in shared by every SERVER_ONLY_ADAPTER_IDS package — it
|
|
24
|
+
* must export the UNION of their public names, each failing loudly on use
|
|
25
|
+
* (never at import, so merely reaching the module keeps the app alive).
|
|
17
26
|
* @returns {string}
|
|
18
27
|
*/
|
|
19
28
|
export function create_adapter_browser_stub_source() {
|
|
@@ -27,6 +36,12 @@ export function nodeRequestToWebRequest() {
|
|
|
27
36
|
export function webResponseToNodeResponse() {
|
|
28
37
|
throw new Error('[octane] Node response helpers cannot run in the browser.');
|
|
29
38
|
}
|
|
39
|
+
export function vercel() {
|
|
40
|
+
throw new Error('[octane] Deploy adapters cannot run in the browser.');
|
|
41
|
+
}
|
|
42
|
+
export function adapt() {
|
|
43
|
+
throw new Error('[octane] Deploy adapters cannot run in the browser.');
|
|
44
|
+
}
|
|
30
45
|
`;
|
|
31
46
|
}
|
|
32
47
|
|
|
@@ -67,8 +82,15 @@ export function write_project_generated_file(viteConfig, name, source) {
|
|
|
67
82
|
* the browser would drag the plugin (and the server adapter) — with their
|
|
68
83
|
* `node:fs` imports — into the client graph and throw at module-eval. Instead
|
|
69
84
|
* the server serializes everything needed into #__octane_data ({ entry,
|
|
70
|
-
* exportName, layout, params }), and this entry
|
|
71
|
-
*
|
|
85
|
+
* exportName, layout, params, url, preHydrate }), and this entry
|
|
86
|
+
* dynamic-imports the page/layout from there.
|
|
87
|
+
*
|
|
88
|
+
* `staticEntries` (production builds) lists every module path the server can
|
|
89
|
+
* name in #__octane_data — page entries, layouts, and the preHydrate hook.
|
|
90
|
+
* Each becomes a STATIC `() => import('/src/…')` in a lookup map, so Rollup
|
|
91
|
+
* sees, chunks, and hashes them; the runtime falls back to the hidden dynamic
|
|
92
|
+
* import only for paths outside the map (the dev case, where the map is empty
|
|
93
|
+
* and Vite serves any module by URL).
|
|
72
94
|
*
|
|
73
95
|
* octane specifics:
|
|
74
96
|
* - `import { hydrateRoot } from 'octane'` (NO `mount`).
|
|
@@ -80,20 +102,47 @@ export function write_project_generated_file(viteConfig, name, source) {
|
|
|
80
102
|
* closure — mirroring the server `createLayoutWrapper`.
|
|
81
103
|
* - hydrateRoot() itself locates/consumes the <script data-octane-suspense>
|
|
82
104
|
* seed inside #root, so the entry does nothing special for suspense.
|
|
105
|
+
* - `preHydrate` (config `router.preHydrate`, a Vite-root module path) is
|
|
106
|
+
* imported and its default export awaited BEFORE hydrateRoot — the hook an
|
|
107
|
+
* app-level client router uses to commit its match tree so the first
|
|
108
|
+
* hydration pass adopts the same resolved tree the server rendered.
|
|
83
109
|
*
|
|
84
110
|
* `getComponentExport` mirrors routes.js `get_component_export` (route named
|
|
85
111
|
* export > default > first PascalCase) so server and client pick the SAME
|
|
86
112
|
* component.
|
|
87
113
|
*
|
|
88
|
-
* @param {{ configPath?: string, staticEntries?: string[] }} [
|
|
114
|
+
* @param {{ configPath?: string, staticEntries?: string[] }} [options]
|
|
89
115
|
* @returns {string}
|
|
90
116
|
*/
|
|
91
|
-
export function create_client_entry_source(
|
|
117
|
+
export function create_client_entry_source(options = {}) {
|
|
118
|
+
const staticEntries = [...new Set(options.staticEntries ?? [])];
|
|
119
|
+
const static_map_lines = staticEntries
|
|
120
|
+
.map((entry) => ` ${JSON.stringify(entry)}: () => import(${JSON.stringify(entry)}),`)
|
|
121
|
+
.join('\n');
|
|
122
|
+
|
|
92
123
|
return `// Auto-generated by @octanejs/vite-plugin.
|
|
93
124
|
// This file is written to Vite's cacheDir/project folder.
|
|
94
125
|
|
|
95
126
|
import { hydrateRoot } from 'octane';
|
|
96
127
|
|
|
128
|
+
// Static import map (production): every module the server may name in
|
|
129
|
+
// #__octane_data, as bundle-analyzable dynamic imports. Empty in dev.
|
|
130
|
+
const routeModules = {
|
|
131
|
+
${static_map_lines}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// Dynamic import Vite's import-analysis can NOT see: its rewrite appends
|
|
135
|
+
// '?import' to variable dynamic imports, and a queried URL evaluates as a
|
|
136
|
+
// SECOND browser module instance — so the page (or the preHydrate hook) would
|
|
137
|
+
// no longer share module singletons with statically-imported copies of the
|
|
138
|
+
// same files (e.g. a client router the app also imports directly).
|
|
139
|
+
const dynamicImport = new Function('specifier', 'return import(specifier)');
|
|
140
|
+
|
|
141
|
+
function importModule(path) {
|
|
142
|
+
const loader = routeModules[path];
|
|
143
|
+
return loader ? loader() : dynamicImport(path);
|
|
144
|
+
}
|
|
145
|
+
|
|
97
146
|
function getComponentExport(module, exportName) {
|
|
98
147
|
// Explicit export name requires an exact match; do NOT fall back, so a
|
|
99
148
|
// typo'd route renders nothing rather than the wrong component.
|
|
@@ -110,13 +159,13 @@ function getComponentExport(module, exportName) {
|
|
|
110
159
|
console.error('[octane] Unable to hydrate: missing #__octane_data or #root.');
|
|
111
160
|
return;
|
|
112
161
|
}
|
|
113
|
-
const data = JSON.parse(el.textContent || '{}'); // { entry, exportName, layout, params }
|
|
162
|
+
const data = JSON.parse(el.textContent || '{}'); // { entry, exportName, layout, params, url, preHydrate }
|
|
114
163
|
if (!data.entry) {
|
|
115
164
|
console.error('[octane] Unable to hydrate: no route entry in #__octane_data.');
|
|
116
165
|
return;
|
|
117
166
|
}
|
|
118
167
|
|
|
119
|
-
const pageMod = await
|
|
168
|
+
const pageMod = await importModule(data.entry);
|
|
120
169
|
const Component = getComponentExport(pageMod, data.exportName ?? undefined);
|
|
121
170
|
if (!Component) {
|
|
122
171
|
console.error('[octane] Unable to hydrate: no component export for', data.entry);
|
|
@@ -124,23 +173,34 @@ function getComponentExport(module, exportName) {
|
|
|
124
173
|
}
|
|
125
174
|
|
|
126
175
|
const params = data.params;
|
|
176
|
+
const url = data.url;
|
|
177
|
+
|
|
178
|
+
// Run the app's pre-hydrate hook (config \`router.preHydrate\`) before the
|
|
179
|
+
// first hydration render — e.g. a client router committing its match tree
|
|
180
|
+
// so hydration adopts the same resolved tree the server rendered.
|
|
181
|
+
if (data.preHydrate) {
|
|
182
|
+
const preMod = await importModule(data.preHydrate);
|
|
183
|
+
const hook = preMod.default;
|
|
184
|
+
if (typeof hook === 'function') await hook({ url, params });
|
|
185
|
+
}
|
|
127
186
|
|
|
187
|
+
// Props mirror the server render exactly: { params, url }.
|
|
128
188
|
if (data.layout) {
|
|
129
|
-
const layoutMod = await
|
|
189
|
+
const layoutMod = await importModule(data.layout);
|
|
130
190
|
const Layout = getComponentExport(layoutMod);
|
|
131
191
|
if (Layout) {
|
|
132
|
-
// children is a ComponentBody closing over
|
|
133
|
-
// invokes a function child PROPS-FIRST as \`({}, block, extra)\`,
|
|
134
|
-
// ignore the empty props and render the page with its real
|
|
135
|
-
// threading the scope + extra — mirroring the server
|
|
136
|
-
//
|
|
137
|
-
const children = (_props, scope, extra) => Component({ params }, scope, extra);
|
|
138
|
-
hydrateRoot(target, Layout, { params, children });
|
|
192
|
+
// children is a ComponentBody closing over the page props; octane's
|
|
193
|
+
// childSlot invokes a function child PROPS-FIRST as \`({}, block, extra)\`,
|
|
194
|
+
// so we ignore the empty props and render the page with its real
|
|
195
|
+
// \`{ params, url }\`, threading the scope + extra — mirroring the server
|
|
196
|
+
// createLayoutWrapper so the markers line up.
|
|
197
|
+
const children = (_props, scope, extra) => Component({ params, url }, scope, extra);
|
|
198
|
+
hydrateRoot(target, Layout, { params, url, children });
|
|
139
199
|
return;
|
|
140
200
|
}
|
|
141
201
|
}
|
|
142
202
|
|
|
143
|
-
hydrateRoot(target, Component, { params });
|
|
203
|
+
hydrateRoot(target, Component, { params, url });
|
|
144
204
|
} catch (error) {
|
|
145
205
|
console.error('[octane] Failed to bootstrap client hydration.', error);
|
|
146
206
|
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Config validation + defaults — `resolveOctaneConfig` and its validators.
|
|
4
|
+
*
|
|
5
|
+
* Kept in a module with NO heavy imports (no vite, no octane/compiler) because
|
|
6
|
+
* it is part of the PRODUCTION server bundle's graph: the generated server
|
|
7
|
+
* entry re-resolves octane.config.ts through it at boot, and the whole
|
|
8
|
+
* `@octanejs/vite-plugin/production` graph is bundled into dist/server/entry.js.
|
|
9
|
+
* The file-loading half (`loadOctaneConfig`, which spins up Vite) lives in
|
|
10
|
+
* `load-config.js` and re-exports everything here.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** @import { OctaneConfigOptions, ResolvedOctaneConfig } from '@octanejs/vite-plugin' */
|
|
14
|
+
|
|
15
|
+
import { DEFAULT_OUTDIR } from './constants.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {unknown} route
|
|
19
|
+
* @returns {void}
|
|
20
|
+
*/
|
|
21
|
+
function validate_render_route(route) {
|
|
22
|
+
if (
|
|
23
|
+
!route ||
|
|
24
|
+
typeof route !== 'object' ||
|
|
25
|
+
/** @type {{ type?: unknown }} */ (route).type !== 'render'
|
|
26
|
+
) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const render_route = /** @type {{ entry?: unknown, layout?: unknown }} */ (route);
|
|
31
|
+
const has_entry =
|
|
32
|
+
typeof render_route.entry === 'string' ||
|
|
33
|
+
(Array.isArray(render_route.entry) &&
|
|
34
|
+
render_route.entry.length === 2 &&
|
|
35
|
+
typeof render_route.entry[0] === 'string' &&
|
|
36
|
+
typeof render_route.entry[1] === 'string');
|
|
37
|
+
|
|
38
|
+
if (!has_entry) {
|
|
39
|
+
throw new Error('[@octanejs/vite-plugin] RenderRoute requires a string/tuple `entry`.');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (render_route.layout !== undefined && typeof render_route.layout !== 'string') {
|
|
43
|
+
throw new Error('[@octanejs/vite-plugin] RenderRoute `layout` must be a string path.');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const status = /** @type {{ status?: unknown }} */ (route).status;
|
|
47
|
+
if (status !== undefined && (typeof status !== 'number' || !Number.isInteger(status))) {
|
|
48
|
+
throw new Error('[@octanejs/vite-plugin] RenderRoute `status` must be an integer.');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {unknown} rootBoundary
|
|
54
|
+
* @returns {void}
|
|
55
|
+
*/
|
|
56
|
+
function validate_root_boundary(rootBoundary) {
|
|
57
|
+
if (rootBoundary === undefined) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (!rootBoundary || typeof rootBoundary !== 'object') {
|
|
61
|
+
throw new Error('[@octanejs/vite-plugin] rootBoundary must be an object when provided.');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const boundary = /** @type {{ pending?: unknown, catch?: unknown }} */ (rootBoundary);
|
|
65
|
+
if (boundary.pending !== undefined && typeof boundary.pending !== 'function') {
|
|
66
|
+
throw new Error('[@octanejs/vite-plugin] rootBoundary.pending must be a component function.');
|
|
67
|
+
}
|
|
68
|
+
if (boundary.catch !== undefined && typeof boundary.catch !== 'function') {
|
|
69
|
+
throw new Error('[@octanejs/vite-plugin] rootBoundary.catch must be a component function.');
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Validate a raw octane config and apply all defaults.
|
|
75
|
+
*
|
|
76
|
+
* After this function returns every optional field carries its default
|
|
77
|
+
* value so callers never need to use `??` / `||` fallbacks.
|
|
78
|
+
*
|
|
79
|
+
* The function is idempotent — passing an already-resolved config
|
|
80
|
+
* through it again is safe and produces the same result.
|
|
81
|
+
*
|
|
82
|
+
* @param {OctaneConfigOptions} raw - The user-provided config (from octane.config.ts)
|
|
83
|
+
* @param {{ requireAdapter?: boolean }} [options]
|
|
84
|
+
* @returns {ResolvedOctaneConfig}
|
|
85
|
+
*/
|
|
86
|
+
export function resolveOctaneConfig(raw, options = {}) {
|
|
87
|
+
const { requireAdapter = false } = options;
|
|
88
|
+
|
|
89
|
+
// ------------------------------------------------------------------
|
|
90
|
+
// Validate
|
|
91
|
+
// ------------------------------------------------------------------
|
|
92
|
+
if (!raw) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
'[@octanejs/vite-plugin] octane.config.ts must export a default config object.',
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (requireAdapter && !raw.adapter) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
'[@octanejs/vite-plugin] This build requires an `adapter` in octane.config.ts. ' +
|
|
101
|
+
'Install an adapter package (e.g. @octanejs/adapter-vercel) and set the `adapter` property.',
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (raw.adapter !== undefined) {
|
|
106
|
+
if (typeof raw.adapter !== 'object' || raw.adapter === null) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
'[@octanejs/vite-plugin] adapter must be an adapter object (e.g. `adapter: vercel()`).',
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
if (raw.adapter.adapt !== undefined && typeof raw.adapter.adapt !== 'function') {
|
|
112
|
+
throw new Error('[@octanejs/vite-plugin] adapter.adapt must be a function.');
|
|
113
|
+
}
|
|
114
|
+
if (raw.adapter.serve !== undefined && typeof raw.adapter.serve !== 'function') {
|
|
115
|
+
throw new Error('[@octanejs/vite-plugin] adapter.serve must be a function.');
|
|
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
|
+
if (raw.router?.preHydrate !== undefined) {
|
|
124
|
+
// A Vite-root module path: the client hydrate entry dynamic-imports it in
|
|
125
|
+
// the browser, so it must be root-absolute ('/src/…'), not relative or fs.
|
|
126
|
+
if (typeof raw.router.preHydrate !== 'string' || !raw.router.preHydrate.startsWith('/')) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
"[@octanejs/vite-plugin] router.preHydrate must be a Vite-root module path (e.g. '/src/pre-hydrate.ts').",
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
for (const route of raw.router?.routes ?? []) {
|
|
134
|
+
validate_render_route(route);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
validate_root_boundary(raw.rootBoundary);
|
|
138
|
+
|
|
139
|
+
if (
|
|
140
|
+
raw.server?.render !== undefined &&
|
|
141
|
+
raw.server.render !== 'streaming' &&
|
|
142
|
+
raw.server.render !== 'buffered'
|
|
143
|
+
) {
|
|
144
|
+
throw new Error("[@octanejs/vite-plugin] server.render must be 'streaming' or 'buffered'.");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ------------------------------------------------------------------
|
|
148
|
+
// Apply defaults
|
|
149
|
+
// ------------------------------------------------------------------
|
|
150
|
+
return {
|
|
151
|
+
build: {
|
|
152
|
+
outDir: raw.build?.outDir ?? DEFAULT_OUTDIR,
|
|
153
|
+
minify: raw.build?.minify,
|
|
154
|
+
target: raw.build?.target,
|
|
155
|
+
},
|
|
156
|
+
adapter: raw.adapter,
|
|
157
|
+
router: {
|
|
158
|
+
routes: raw.router?.routes ?? [],
|
|
159
|
+
preHydrate: raw.router?.preHydrate,
|
|
160
|
+
},
|
|
161
|
+
rootBoundary: raw.rootBoundary ?? {},
|
|
162
|
+
middlewares: raw.middlewares ?? [],
|
|
163
|
+
platform: {
|
|
164
|
+
env: raw.platform?.env ?? {},
|
|
165
|
+
},
|
|
166
|
+
server: {
|
|
167
|
+
trustProxy: raw.server?.trustProxy ?? false,
|
|
168
|
+
render: raw.server?.render ?? 'streaming',
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|
package/src/routes.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
/**
|
|
2
3
|
* @typedef {import('@octanejs/vite-plugin').Context} Context
|
|
3
4
|
* @typedef {import('@octanejs/vite-plugin').Middleware} Middleware
|
|
@@ -68,7 +69,8 @@ export class RenderRoute {
|
|
|
68
69
|
/** @type {string} */
|
|
69
70
|
path;
|
|
70
71
|
|
|
71
|
-
|
|
72
|
+
// Non-optional: the constructor throws without one (matches types/index.d.ts).
|
|
73
|
+
/** @type {RenderRouteEntry} */
|
|
72
74
|
entry;
|
|
73
75
|
|
|
74
76
|
/** @type {string | undefined} */
|
|
@@ -77,6 +79,9 @@ export class RenderRoute {
|
|
|
77
79
|
/** @type {Middleware[]} */
|
|
78
80
|
before;
|
|
79
81
|
|
|
82
|
+
/** @type {number | undefined} */
|
|
83
|
+
status;
|
|
84
|
+
|
|
80
85
|
/**
|
|
81
86
|
* @param {RenderRouteOptions} options
|
|
82
87
|
*/
|
|
@@ -89,6 +94,7 @@ export class RenderRoute {
|
|
|
89
94
|
this.entry = options.entry;
|
|
90
95
|
this.layout = options.layout;
|
|
91
96
|
this.before = options.before ?? [];
|
|
97
|
+
this.status = options.status;
|
|
92
98
|
}
|
|
93
99
|
}
|
|
94
100
|
|
package/src/server/middleware.js
CHANGED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Node HTTP glue — shared by the dev middleware (src/index.js), the generated
|
|
4
|
+
* production server entry (its `nodeHandler` export for serverless wrappers),
|
|
5
|
+
* and the built-in production server (`createNodeServer`, the no-adapter
|
|
6
|
+
* default boot). Exported as '@octanejs/vite-plugin/node'.
|
|
7
|
+
*
|
|
8
|
+
* This module may import node builtins (unlike server/production.js, which is
|
|
9
|
+
* platform-agnostic) — it IS the Node platform layer.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import http from 'node:http';
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { Readable } from 'node:stream';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Convert a Node.js IncomingMessage to a Web Request.
|
|
19
|
+
* @param {import('node:http').IncomingMessage} nodeRequest
|
|
20
|
+
* @returns {Request}
|
|
21
|
+
*/
|
|
22
|
+
export function nodeRequestToWebRequest(nodeRequest) {
|
|
23
|
+
const host = nodeRequest.headers.host || 'localhost';
|
|
24
|
+
const url = new URL(nodeRequest.url || '/', `http://${host}`);
|
|
25
|
+
|
|
26
|
+
const headers = new Headers();
|
|
27
|
+
for (const [key, value] of Object.entries(nodeRequest.headers)) {
|
|
28
|
+
if (value == null) continue;
|
|
29
|
+
if (Array.isArray(value)) {
|
|
30
|
+
for (const v of value) headers.append(key, v);
|
|
31
|
+
} else {
|
|
32
|
+
headers.set(key, value);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const method = (nodeRequest.method || 'GET').toUpperCase();
|
|
37
|
+
/** @type {RequestInit & { duplex?: 'half' }} */
|
|
38
|
+
const init = { method, headers };
|
|
39
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
40
|
+
// node:stream/web's ReadableStream and the DOM lib's are structurally the
|
|
41
|
+
// same at runtime; the lib types disagree on BYOB details.
|
|
42
|
+
init.body = /** @type {ReadableStream} */ (
|
|
43
|
+
/** @type {unknown} */ (Readable.toWeb(nodeRequest))
|
|
44
|
+
);
|
|
45
|
+
init.duplex = 'half';
|
|
46
|
+
}
|
|
47
|
+
return new Request(url, init);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Pipe a Web Response to a Node.js ServerResponse. Streams chunk-by-chunk so a
|
|
52
|
+
* streaming SSR body flushes as it renders (no buffering).
|
|
53
|
+
*
|
|
54
|
+
* @param {import('node:http').ServerResponse} nodeResponse
|
|
55
|
+
* @param {Response} webResponse
|
|
56
|
+
*/
|
|
57
|
+
export async function sendWebResponse(nodeResponse, webResponse) {
|
|
58
|
+
nodeResponse.statusCode = webResponse.status;
|
|
59
|
+
if (webResponse.statusText) nodeResponse.statusMessage = webResponse.statusText;
|
|
60
|
+
webResponse.headers.forEach((value, key) => {
|
|
61
|
+
nodeResponse.setHeader(key, value);
|
|
62
|
+
});
|
|
63
|
+
if (webResponse.body) {
|
|
64
|
+
const reader = webResponse.body.getReader();
|
|
65
|
+
try {
|
|
66
|
+
while (true) {
|
|
67
|
+
const { done, value } = await reader.read();
|
|
68
|
+
if (done) break;
|
|
69
|
+
nodeResponse.write(value);
|
|
70
|
+
}
|
|
71
|
+
} finally {
|
|
72
|
+
reader.releaseLock();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
nodeResponse.end();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Static-file MIME map (the common web set; anything else falls back to
|
|
79
|
+
// octet-stream, which is correct for downloads).
|
|
80
|
+
/** @type {Record<string, string>} */
|
|
81
|
+
const MIME_TYPES = {
|
|
82
|
+
'.html': 'text/html; charset=utf-8',
|
|
83
|
+
'.css': 'text/css; charset=utf-8',
|
|
84
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
85
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
86
|
+
'.json': 'application/json; charset=utf-8',
|
|
87
|
+
'.png': 'image/png',
|
|
88
|
+
'.jpg': 'image/jpeg',
|
|
89
|
+
'.jpeg': 'image/jpeg',
|
|
90
|
+
'.gif': 'image/gif',
|
|
91
|
+
'.svg': 'image/svg+xml',
|
|
92
|
+
'.ico': 'image/x-icon',
|
|
93
|
+
'.woff': 'font/woff',
|
|
94
|
+
'.woff2': 'font/woff2',
|
|
95
|
+
'.ttf': 'font/ttf',
|
|
96
|
+
'.otf': 'font/otf',
|
|
97
|
+
'.webp': 'image/webp',
|
|
98
|
+
'.avif': 'image/avif',
|
|
99
|
+
'.mp4': 'video/mp4',
|
|
100
|
+
'.webm': 'video/webm',
|
|
101
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
102
|
+
'.xml': 'application/xml',
|
|
103
|
+
'.wasm': 'application/wasm',
|
|
104
|
+
'.map': 'application/json',
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Serve a static file from `staticDir` if the request path maps to one.
|
|
109
|
+
* Hash-named build assets (everything under /assets/) get immutable caching;
|
|
110
|
+
* other files (favicon, robots.txt, …) revalidate.
|
|
111
|
+
*
|
|
112
|
+
* @param {import('node:http').IncomingMessage} req
|
|
113
|
+
* @param {import('node:http').ServerResponse} res
|
|
114
|
+
* @param {string} staticDir
|
|
115
|
+
* @returns {boolean} true when the request was handled as a static file
|
|
116
|
+
*/
|
|
117
|
+
export function serveStaticFile(req, res, staticDir) {
|
|
118
|
+
const method = (req.method || 'GET').toUpperCase();
|
|
119
|
+
if (method !== 'GET' && method !== 'HEAD') return false;
|
|
120
|
+
|
|
121
|
+
const pathname = decodeURIComponent(new URL(req.url || '/', 'http://localhost').pathname);
|
|
122
|
+
// Resolve inside staticDir only — a `..` escape must not leave the client dir.
|
|
123
|
+
const filePath = path.normalize(path.join(staticDir, pathname));
|
|
124
|
+
if (!filePath.startsWith(path.normalize(staticDir + path.sep))) return false;
|
|
125
|
+
|
|
126
|
+
/** @type {fs.Stats} */
|
|
127
|
+
let stat;
|
|
128
|
+
try {
|
|
129
|
+
stat = fs.statSync(filePath);
|
|
130
|
+
} catch {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
if (!stat.isFile()) return false;
|
|
134
|
+
|
|
135
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
136
|
+
res.statusCode = 200;
|
|
137
|
+
res.setHeader('Content-Type', MIME_TYPES[ext] || 'application/octet-stream');
|
|
138
|
+
res.setHeader('Content-Length', stat.size);
|
|
139
|
+
res.setHeader(
|
|
140
|
+
'Cache-Control',
|
|
141
|
+
pathname.startsWith('/assets/')
|
|
142
|
+
? 'public, max-age=31536000, immutable'
|
|
143
|
+
: 'public, max-age=0, must-revalidate',
|
|
144
|
+
);
|
|
145
|
+
if (method === 'HEAD') {
|
|
146
|
+
res.end();
|
|
147
|
+
} else {
|
|
148
|
+
fs.createReadStream(filePath).pipe(res);
|
|
149
|
+
}
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Minimal production HTTP server: static files from `staticDir` first (built
|
|
155
|
+
* client assets), then the fetch-style SSR handler. This is the DEFAULT boot
|
|
156
|
+
* when octane.config.ts has no adapter — an adapter's `serve()` replaces it.
|
|
157
|
+
*
|
|
158
|
+
* @param {(request: Request) => Response | Promise<Response>} handler
|
|
159
|
+
* @param {{ staticDir?: string }} [options]
|
|
160
|
+
* @returns {{ listen: (port?: number) => import('node:http').Server, close: () => void }}
|
|
161
|
+
*/
|
|
162
|
+
export function createNodeServer(handler, options = {}) {
|
|
163
|
+
const staticDir = options.staticDir;
|
|
164
|
+
|
|
165
|
+
const server = http.createServer((req, res) => {
|
|
166
|
+
(async () => {
|
|
167
|
+
if (staticDir && serveStaticFile(req, res, staticDir)) return;
|
|
168
|
+
const response = await handler(nodeRequestToWebRequest(req));
|
|
169
|
+
await sendWebResponse(res, response);
|
|
170
|
+
})().catch((error) => {
|
|
171
|
+
console.error('[@octanejs/vite-plugin] Request error:', error);
|
|
172
|
+
if (!res.headersSent) {
|
|
173
|
+
res.statusCode = 500;
|
|
174
|
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
175
|
+
}
|
|
176
|
+
res.end('Internal Server Error');
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
listen(port = 3000) {
|
|
182
|
+
return server.listen(port);
|
|
183
|
+
},
|
|
184
|
+
close() {
|
|
185
|
+
server.close();
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
}
|