@octanejs/vite-plugin 0.1.3 → 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.
@@ -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 dynamic-imports the page/layout
71
- * from there. (A static, build-analyzable import map is a Phase-2 concern.)
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[] }} [_options]
114
+ * @param {{ configPath?: string, staticEntries?: string[] }} [options]
89
115
  * @returns {string}
90
116
  */
91
- export function create_client_entry_source(_options) {
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 import(/* @vite-ignore */ data.entry);
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 import(/* @vite-ignore */ data.layout);
189
+ const layoutMod = await importModule(data.layout);
130
190
  const Layout = getComponentExport(layoutMod);
131
191
  if (Layout) {
132
- // children is a ComponentBody closing over params; octane's childSlot
133
- // invokes a function child PROPS-FIRST as \`({}, block, extra)\`, so we
134
- // ignore the empty props and render the page with its real \`{ params }\`,
135
- // threading the scope + extra — mirroring the server createLayoutWrapper
136
- // (which is scope-first on the server ABI) so the markers line up.
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
- /** @type {RenderRouteEntry | undefined} */
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
 
@@ -1,3 +1,4 @@
1
+ // @ts-check
1
2
  /**
2
3
  * Server component composition for the octane renderer.
3
4
  *
@@ -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
@@ -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
+ }