@octanejs/app-core 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +34 -0
- package/package.json +88 -0
- package/src/codegen.js +297 -0
- package/src/config-loader.js +307 -0
- package/src/config.js +14 -0
- package/src/constants.js +5 -0
- package/src/html.js +10 -0
- package/src/index.js +18 -0
- package/src/middleware.js +3 -0
- package/src/resolve-config.js +177 -0
- package/src/routes.js +135 -0
- package/src/server/component-wrappers.js +99 -0
- package/src/server/html-stream.js +74 -0
- package/src/server/html-template.js +136 -0
- package/src/server/middleware.js +127 -0
- package/src/server/node-http.js +262 -0
- package/src/server/production.js +335 -0
- package/src/server/router.js +123 -0
- package/src/server/server-entry.js +436 -0
- package/src/server/server-route.js +47 -0
- package/types/codegen.d.ts +67 -0
- package/types/config-loader.d.ts +26 -0
- package/types/config.d.ts +26 -0
- package/types/html.d.ts +14 -0
- package/types/index.d.ts +319 -0
- package/types/middleware.d.ts +3 -0
- package/types/node.d.ts +31 -0
- package/types/production.d.ts +98 -0
- package/types/routes.d.ts +17 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Server component composition for the octane renderer.
|
|
4
|
+
*
|
|
5
|
+
* octane's server ABI is PROPS-FIRST (matching the client): a component body is
|
|
6
|
+
* `(props, scope, extra) => string`. `render(Component, props)` invokes the ROOT
|
|
7
|
+
* directly as `Component(props, rootScope, undefined)` and does NOT wrap it in
|
|
8
|
+
* block markers — and `hydrateRoot()` adopts the container's FIRST CHILD as the
|
|
9
|
+
* root's own node. So the wrapper must call the top-level component DIRECTLY
|
|
10
|
+
* (wrapping it in `ssrComponent` would add an extra `<!--[-->…<!--]-->` layer that
|
|
11
|
+
* `clone()` then mis-adopts on hydrate).
|
|
12
|
+
*
|
|
13
|
+
* Only the layout's `{children}` is a nested hole: the compiled layout emits
|
|
14
|
+
* `ssrChild(props.children, scope)`, and `ssrChild` invokes a FUNCTION child as
|
|
15
|
+
* `children({}, scope, undefined)` wrapped in one `<!--[-->…<!--]-->` range. So
|
|
16
|
+
* `children` is a ComponentBody that calls the page directly, and any page data
|
|
17
|
+
* (params) rides its CLOSURE — `ssrChild` supplies only `{}`. The client
|
|
18
|
+
* `childSlot` applies the identical rule (bare function = ComponentBody, `{}`
|
|
19
|
+
* props, one marker range), so server markers and client adoption line up.
|
|
20
|
+
*
|
|
21
|
+
* @typedef {(props?: any, scope?: any, extra?: any) => string | void} ServerComponent
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Wrap a page component, baking in its route props.
|
|
26
|
+
*
|
|
27
|
+
* @param {ServerComponent} Page
|
|
28
|
+
* @param {Record<string, unknown>} pageProps
|
|
29
|
+
* @returns {ServerComponent}
|
|
30
|
+
*/
|
|
31
|
+
export function createPropsWrapper(Page, pageProps) {
|
|
32
|
+
return function Root(_props, scope) {
|
|
33
|
+
return Page(pageProps, scope, undefined);
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Compose a layout with a page: the layout's `{children}` renders the page.
|
|
39
|
+
*
|
|
40
|
+
* @param {ServerComponent} Layout
|
|
41
|
+
* @param {ServerComponent} Page
|
|
42
|
+
* @param {Record<string, unknown>} pageProps
|
|
43
|
+
* @returns {ServerComponent}
|
|
44
|
+
*/
|
|
45
|
+
export function createLayoutWrapper(Layout, Page, pageProps) {
|
|
46
|
+
return function Root(_props, scope) {
|
|
47
|
+
// `children` is a ComponentBody closing over pageProps; the layout's
|
|
48
|
+
// `{children}` hole runs it via ssrChild (which supplies `{}` props and
|
|
49
|
+
// wraps the output in one block range), so the page still gets its real
|
|
50
|
+
// route props through the closure. PROPS-FIRST: childSlot/ssrChild call it
|
|
51
|
+
// as `({}, scope, extra)`, so the page's real props are passed explicitly.
|
|
52
|
+
const children = (/** @type {any} */ _cprops, /** @type {any} */ cscope) =>
|
|
53
|
+
Page(pageProps, cscope, undefined);
|
|
54
|
+
return Layout({ ...pageProps, children }, scope, undefined);
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Add the configured app-wide Suspense/ErrorBoundary around a route root.
|
|
60
|
+
* The same composition helper is used by dev SSR, production SSR, and the
|
|
61
|
+
* generated browser hydrate entry, so all three produce/adopt the same nested
|
|
62
|
+
* marker shape.
|
|
63
|
+
*
|
|
64
|
+
* @param {ServerComponent} Content
|
|
65
|
+
* @param {{ pending?: ServerComponent | null, catch?: ServerComponent | null }} boundary
|
|
66
|
+
* @param {{ Suspense: ServerComponent, ErrorBoundary: ServerComponent, createElement: Function }} runtime
|
|
67
|
+
* @returns {ServerComponent}
|
|
68
|
+
*/
|
|
69
|
+
export function createRootBoundaryWrapper(Content, boundary, runtime) {
|
|
70
|
+
let body = Content;
|
|
71
|
+
|
|
72
|
+
if (boundary.pending) {
|
|
73
|
+
const child = body;
|
|
74
|
+
const Pending = boundary.pending;
|
|
75
|
+
body = function RootSuspense(props, scope) {
|
|
76
|
+
const children = (/** @type {any} */ _props, /** @type {any} */ childScope) =>
|
|
77
|
+
child(props, childScope, undefined);
|
|
78
|
+
return runtime.Suspense(
|
|
79
|
+
{ fallback: runtime.createElement(Pending, {}), children },
|
|
80
|
+
scope,
|
|
81
|
+
undefined,
|
|
82
|
+
);
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (boundary.catch) {
|
|
87
|
+
const child = body;
|
|
88
|
+
const Catch = boundary.catch;
|
|
89
|
+
body = function RootErrorBoundary(props, scope) {
|
|
90
|
+
const children = (/** @type {any} */ _props, /** @type {any} */ childScope) =>
|
|
91
|
+
child(props, childScope, undefined);
|
|
92
|
+
const fallback = (/** @type {unknown} */ error, /** @type {() => void} */ reset) =>
|
|
93
|
+
runtime.createElement(Catch, { error, reset });
|
|
94
|
+
return runtime.ErrorBoundary({ fallback, children }, scope, undefined);
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return body;
|
|
99
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Compose an HTML template around the renderer without draining it eagerly.
|
|
5
|
+
* Each consumer pull advances at most one visible chunk (prefix, one render
|
|
6
|
+
* chunk, suffix), so backpressure reaches the core stream. Cancellation goes
|
|
7
|
+
* through the locked reader and therefore reaches the renderer's source.
|
|
8
|
+
*
|
|
9
|
+
* @param {string} prefix
|
|
10
|
+
* @param {ReadableStream<Uint8Array>} renderStream
|
|
11
|
+
* @param {string} suffix
|
|
12
|
+
* @returns {ReadableStream<Uint8Array>}
|
|
13
|
+
*/
|
|
14
|
+
export function composeHtmlStream(prefix, renderStream, suffix) {
|
|
15
|
+
const encoder = new TextEncoder();
|
|
16
|
+
const reader = renderStream.getReader();
|
|
17
|
+
let phase = 0; // 0 prefix, 1 renderer, 2 suffix, 3 closed
|
|
18
|
+
let released = false;
|
|
19
|
+
|
|
20
|
+
function release() {
|
|
21
|
+
if (released) return;
|
|
22
|
+
released = true;
|
|
23
|
+
reader.releaseLock();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return new ReadableStream(
|
|
27
|
+
{
|
|
28
|
+
async pull(controller) {
|
|
29
|
+
try {
|
|
30
|
+
for (;;) {
|
|
31
|
+
if (phase === 0) {
|
|
32
|
+
phase = 1;
|
|
33
|
+
if (prefix !== '') {
|
|
34
|
+
controller.enqueue(encoder.encode(prefix));
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (phase === 1) {
|
|
40
|
+
const { done, value } = await reader.read();
|
|
41
|
+
if (!done) {
|
|
42
|
+
controller.enqueue(value);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
release();
|
|
46
|
+
phase = 2;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (phase === 2) {
|
|
50
|
+
phase = 3;
|
|
51
|
+
if (suffix !== '') controller.enqueue(encoder.encode(suffix));
|
|
52
|
+
controller.close();
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
} catch (error) {
|
|
58
|
+
release();
|
|
59
|
+
phase = 3;
|
|
60
|
+
controller.error(error);
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
async cancel(reason) {
|
|
64
|
+
phase = 3;
|
|
65
|
+
try {
|
|
66
|
+
await reader.cancel(reason);
|
|
67
|
+
} finally {
|
|
68
|
+
release();
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
{ highWaterMark: 0 },
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { OCTANE_NONCE_STATE_KEY } from '../constants.js';
|
|
4
|
+
|
|
5
|
+
const HEAD_MARKER = '<!--ssr-head-->';
|
|
6
|
+
const BODY_MARKER = '<!--ssr-body-->';
|
|
7
|
+
export const HYDRATION_NONCE_PLACEHOLDER = '__OCTANE_REQUEST_NONCE__';
|
|
8
|
+
// A real (hidden) element survives bundler HTML module-script rewrites, unlike
|
|
9
|
+
// attributes on the script itself and empty template/comment sentinels. It is
|
|
10
|
+
// removed per request before the document is served.
|
|
11
|
+
const HYDRATION_MARKER = '<div hidden data-octane-hydrate-marker></div>';
|
|
12
|
+
|
|
13
|
+
/** @param {string} value */
|
|
14
|
+
function escapeAttribute(value) {
|
|
15
|
+
return value
|
|
16
|
+
.replace(/&/g, '&')
|
|
17
|
+
.replace(/"/g, '"')
|
|
18
|
+
.replace(/</g, '<')
|
|
19
|
+
.replace(/>/g, '>');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Validate the structural contract needed by SSR and hydration.
|
|
24
|
+
* @param {string} html
|
|
25
|
+
*/
|
|
26
|
+
export function validateSsrTemplate(html) {
|
|
27
|
+
const headMarkerCount = html.split(HEAD_MARKER).length - 1;
|
|
28
|
+
if (headMarkerCount !== 1) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
`[octane] index.html must contain exactly one ${HEAD_MARKER}; found ${headMarkerCount}.`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
validateSsrBodyContract(html);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** @param {string} html */
|
|
37
|
+
function validateSsrBodyContract(html) {
|
|
38
|
+
const markerCount = html.split(BODY_MARKER).length - 1;
|
|
39
|
+
if (markerCount !== 1) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`[octane] index.html must contain exactly one ${BODY_MARKER}; found ${markerCount}.`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
const bodyCloseCount = html.match(/<\/body\s*>/gi)?.length ?? 0;
|
|
45
|
+
if (bodyCloseCount !== 1) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`[octane] index.html must contain exactly one closing </body> tag for hydration injection; found ${bodyCloseCount}.`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {string} html
|
|
54
|
+
* @param {string} source
|
|
55
|
+
* @param {string | null} nonce
|
|
56
|
+
*/
|
|
57
|
+
export function injectHydrationEntry(html, source, nonce) {
|
|
58
|
+
validateSsrTemplate(html);
|
|
59
|
+
const nonceAttr = nonce === null ? '' : ` nonce="${escapeAttribute(nonce)}"`;
|
|
60
|
+
const script = `<script type="module" data-octane-hydrate src="${escapeAttribute(source)}"${nonceAttr}></script>`;
|
|
61
|
+
const marker = nonce === HYDRATION_NONCE_PLACEHOLDER ? HYDRATION_MARKER : '';
|
|
62
|
+
return html.replace(/<\/body\s*>/i, `${marker}${script}\n</body>`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Split a validated template around its one SSR body marker.
|
|
67
|
+
* @param {string} html
|
|
68
|
+
*/
|
|
69
|
+
export function splitSsrTemplate(html) {
|
|
70
|
+
// The head marker has already been replaced with request data at this point.
|
|
71
|
+
validateSsrBodyContract(html);
|
|
72
|
+
const at = html.indexOf(BODY_MARKER);
|
|
73
|
+
return [html.slice(0, at), html.slice(at + BODY_MARKER.length)];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Add the request nonce to the production hydrate script the integration built. The
|
|
78
|
+
* data attribute is inserted before build so this remains robust after the
|
|
79
|
+
* script src is hashed/reordered.
|
|
80
|
+
* @param {string} html
|
|
81
|
+
* @param {string | null} nonce
|
|
82
|
+
*/
|
|
83
|
+
export function applyHydrationNonce(html, nonce) {
|
|
84
|
+
const tags = html.match(/<script\b[^>]*>/gi) ?? [];
|
|
85
|
+
let hydrationTags = tags.filter(
|
|
86
|
+
(tag) => /\bdata-octane-hydrate\b/i.test(tag) || tag.includes(HYDRATION_NONCE_PLACEHOLDER),
|
|
87
|
+
);
|
|
88
|
+
const markerCount = html.split(HYDRATION_MARKER).length - 1;
|
|
89
|
+
if (hydrationTags.length === 0 && markerCount === 1) {
|
|
90
|
+
// Bundlers may coalesce/hoist HTML module entries into the head, so the hydrate
|
|
91
|
+
// module may no longer be adjacent to its marker (and may share an entry
|
|
92
|
+
// chunk with user scripts). Nonce every built module-entry tag: they all
|
|
93
|
+
// participate in the same CSP-protected bootstrap graph.
|
|
94
|
+
hydrationTags = tags.filter((tag) => /\btype\s*=\s*(?:"module"|'module'|module)/i.test(tag));
|
|
95
|
+
}
|
|
96
|
+
const invalidMarkerContract =
|
|
97
|
+
markerCount > 0 ? markerCount !== 1 || hydrationTags.length === 0 : hydrationTags.length !== 1;
|
|
98
|
+
if (invalidMarkerContract) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`[octane] Built SSR template must identify its hydration module script; found ${hydrationTags.length}.`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
let output = html.replace(HYDRATION_MARKER, '');
|
|
104
|
+
for (const oldTag of hydrationTags) {
|
|
105
|
+
let nextTag = oldTag.replace(/\snonce\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/i, '');
|
|
106
|
+
if (!/\bdata-octane-hydrate\b/i.test(nextTag)) {
|
|
107
|
+
nextTag = nextTag.replace(/^<script\b/i, '<script data-octane-hydrate');
|
|
108
|
+
}
|
|
109
|
+
if (nonce !== null) {
|
|
110
|
+
nextTag = nextTag.replace(/^<script\b/i, `<script nonce="${escapeAttribute(nonce)}"`);
|
|
111
|
+
}
|
|
112
|
+
output = output.replace(oldTag, nextTag);
|
|
113
|
+
}
|
|
114
|
+
return output;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** @param {string | null} nonce */
|
|
118
|
+
export function nonceAttribute(nonce) {
|
|
119
|
+
return nonce === null ? '' : ` nonce="${escapeAttribute(nonce)}"`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Read and validate the documented middleware state key.
|
|
124
|
+
* @param {{ state: Map<string, unknown> }} context
|
|
125
|
+
* @returns {string | null}
|
|
126
|
+
*/
|
|
127
|
+
export function getContextNonce(context) {
|
|
128
|
+
const value = context.state.get(OCTANE_NONCE_STATE_KEY);
|
|
129
|
+
if (value === undefined) return null;
|
|
130
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
`[octane] Context.state.get('${OCTANE_NONCE_STATE_KEY}') must be a non-empty string.`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
return value;
|
|
136
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @typedef {import('@octanejs/app-core').Context} Context
|
|
4
|
+
* @typedef {import('@octanejs/app-core').Middleware} Middleware
|
|
5
|
+
* @typedef {import('@octanejs/app-core').NextFunction} NextFunction
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Compose multiple middlewares into a single middleware
|
|
10
|
+
* Follows Koa-style execution: request flows down, response flows back up
|
|
11
|
+
*
|
|
12
|
+
* @param {Middleware[]} middlewares
|
|
13
|
+
* @returns {(context: Context, finalHandler: () => Promise<Response>) => Promise<Response>}
|
|
14
|
+
*/
|
|
15
|
+
export function compose(middlewares) {
|
|
16
|
+
return function composed(context, finalHandler) {
|
|
17
|
+
let index = -1;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {number} i
|
|
21
|
+
* @returns {Promise<Response>}
|
|
22
|
+
*/
|
|
23
|
+
function dispatch(i) {
|
|
24
|
+
if (i <= index) {
|
|
25
|
+
return Promise.reject(new Error('next() called multiple times'));
|
|
26
|
+
}
|
|
27
|
+
index = i;
|
|
28
|
+
|
|
29
|
+
/** @type {Middleware | (() => Promise<Response>) | undefined} */
|
|
30
|
+
let fn;
|
|
31
|
+
|
|
32
|
+
if (i < middlewares.length) {
|
|
33
|
+
fn = middlewares[i];
|
|
34
|
+
} else if (i === middlewares.length) {
|
|
35
|
+
fn = finalHandler;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!fn) {
|
|
39
|
+
return Promise.reject(new Error('No handler provided'));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
// For the final handler, we don't pass next
|
|
44
|
+
if (i === middlewares.length) {
|
|
45
|
+
return Promise.resolve(/** @type {() => Promise<Response>} */ (fn)());
|
|
46
|
+
}
|
|
47
|
+
// For middlewares, pass context and next
|
|
48
|
+
return Promise.resolve(/** @type {Middleware} */ (fn)(context, () => dispatch(i + 1)));
|
|
49
|
+
} catch (err) {
|
|
50
|
+
return Promise.reject(err);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return dispatch(0);
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Create a context object for the request
|
|
60
|
+
* @param {Request} request
|
|
61
|
+
* @param {Record<string, string>} params
|
|
62
|
+
* @returns {Context}
|
|
63
|
+
*/
|
|
64
|
+
export function createContext(request, params) {
|
|
65
|
+
return {
|
|
66
|
+
request,
|
|
67
|
+
params,
|
|
68
|
+
url: new URL(request.url),
|
|
69
|
+
state: new Map(),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Run middlewares with a final handler
|
|
75
|
+
* Combines global middlewares, route-level before/after, and the handler
|
|
76
|
+
*
|
|
77
|
+
* @param {Context} context
|
|
78
|
+
* @param {Middleware[]} globalMiddlewares
|
|
79
|
+
* @param {Middleware[]} beforeMiddlewares
|
|
80
|
+
* @param {() => Promise<Response>} handler
|
|
81
|
+
* @param {Middleware[]} afterMiddlewares
|
|
82
|
+
* @returns {Promise<Response>}
|
|
83
|
+
*/
|
|
84
|
+
export async function runMiddlewareChain(
|
|
85
|
+
context,
|
|
86
|
+
globalMiddlewares,
|
|
87
|
+
beforeMiddlewares,
|
|
88
|
+
handler,
|
|
89
|
+
afterMiddlewares = [],
|
|
90
|
+
) {
|
|
91
|
+
// Combine global + before middlewares
|
|
92
|
+
const allMiddlewares = [...globalMiddlewares, ...beforeMiddlewares];
|
|
93
|
+
|
|
94
|
+
// If there are after middlewares, wrap the handler to run them
|
|
95
|
+
const wrappedHandler =
|
|
96
|
+
afterMiddlewares.length > 0
|
|
97
|
+
? async () => {
|
|
98
|
+
const response = await handler();
|
|
99
|
+
// After middlewares can inspect/modify the response
|
|
100
|
+
// but have limited ability to change it in our model
|
|
101
|
+
// We run them for side-effects (logging, etc.)
|
|
102
|
+
return runAfterMiddlewares(context, afterMiddlewares, response);
|
|
103
|
+
}
|
|
104
|
+
: handler;
|
|
105
|
+
|
|
106
|
+
const composed = compose(allMiddlewares);
|
|
107
|
+
return composed(context, wrappedHandler);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Run after middlewares with the response
|
|
112
|
+
* After middlewares run in order and can intercept/modify the response
|
|
113
|
+
*
|
|
114
|
+
* @param {Context} context
|
|
115
|
+
* @param {Middleware[]} middlewares
|
|
116
|
+
* @param {Response} response
|
|
117
|
+
* @returns {Promise<Response>}
|
|
118
|
+
*/
|
|
119
|
+
async function runAfterMiddlewares(context, middlewares, response) {
|
|
120
|
+
let currentResponse = response;
|
|
121
|
+
|
|
122
|
+
for (const middleware of middlewares) {
|
|
123
|
+
currentResponse = await middleware(context, async () => currentResponse);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return currentResponse;
|
|
127
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
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/app-core/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
|
+
const abortController = new AbortController();
|
|
40
|
+
const abortRequest = () => {
|
|
41
|
+
if (!abortController.signal.aborted) {
|
|
42
|
+
abortController.abort(new Error('The client disconnected before the request completed.'));
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
if (nodeRequest.aborted || (nodeRequest.destroyed && !nodeRequest.complete)) {
|
|
46
|
+
abortRequest();
|
|
47
|
+
} else {
|
|
48
|
+
nodeRequest.once('aborted', abortRequest);
|
|
49
|
+
nodeRequest.once('close', () => {
|
|
50
|
+
if (!nodeRequest.complete) abortRequest();
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
init.signal = abortController.signal;
|
|
54
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
55
|
+
// node:stream/web's ReadableStream and the DOM lib's are structurally the
|
|
56
|
+
// same at runtime; the lib types disagree on BYOB details.
|
|
57
|
+
init.body = /** @type {ReadableStream} */ (
|
|
58
|
+
/** @type {unknown} */ (Readable.toWeb(nodeRequest))
|
|
59
|
+
);
|
|
60
|
+
init.duplex = 'half';
|
|
61
|
+
}
|
|
62
|
+
return new Request(url, init);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Pipe a Web Response to a Node.js ServerResponse. Streams chunk-by-chunk so a
|
|
67
|
+
* streaming SSR body flushes as it renders (no buffering).
|
|
68
|
+
*
|
|
69
|
+
* @param {import('node:http').ServerResponse} nodeResponse
|
|
70
|
+
* @param {Response} webResponse
|
|
71
|
+
*/
|
|
72
|
+
export async function sendWebResponse(nodeResponse, webResponse) {
|
|
73
|
+
nodeResponse.statusCode = webResponse.status;
|
|
74
|
+
if (webResponse.statusText) nodeResponse.statusMessage = webResponse.statusText;
|
|
75
|
+
webResponse.headers.forEach((value, key) => {
|
|
76
|
+
nodeResponse.setHeader(key, value);
|
|
77
|
+
});
|
|
78
|
+
if (webResponse.body) {
|
|
79
|
+
const reader = webResponse.body.getReader();
|
|
80
|
+
let disconnected = nodeResponse.destroyed;
|
|
81
|
+
let disconnectReason = new Error('The client disconnected while streaming the response.');
|
|
82
|
+
/** @type {Promise<void> | null} */
|
|
83
|
+
let cancelPromise = null;
|
|
84
|
+
const cancelReader = (/** @type {unknown} */ reason) => {
|
|
85
|
+
disconnectReason = reason instanceof Error ? reason : disconnectReason;
|
|
86
|
+
return (cancelPromise ??= reader.cancel(reason).catch(() => {}));
|
|
87
|
+
};
|
|
88
|
+
const onClose = () => {
|
|
89
|
+
if (nodeResponse.writableEnded) return;
|
|
90
|
+
disconnected = true;
|
|
91
|
+
void cancelReader(disconnectReason);
|
|
92
|
+
};
|
|
93
|
+
const onError = (/** @type {Error} */ error) => {
|
|
94
|
+
disconnected = true;
|
|
95
|
+
void cancelReader(error);
|
|
96
|
+
};
|
|
97
|
+
nodeResponse.once('close', onClose);
|
|
98
|
+
nodeResponse.once('error', onError);
|
|
99
|
+
try {
|
|
100
|
+
if (disconnected) await cancelReader(disconnectReason);
|
|
101
|
+
while (!disconnected) {
|
|
102
|
+
const { done, value } = await reader.read();
|
|
103
|
+
if (done) break;
|
|
104
|
+
const accepted = nodeResponse.write(value);
|
|
105
|
+
if (!accepted && !disconnected) await waitForDrain(nodeResponse);
|
|
106
|
+
}
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (!disconnected) throw error;
|
|
109
|
+
} finally {
|
|
110
|
+
nodeResponse.off('close', onClose);
|
|
111
|
+
nodeResponse.off('error', onError);
|
|
112
|
+
if (disconnected) await cancelReader(disconnectReason);
|
|
113
|
+
reader.releaseLock();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (!nodeResponse.destroyed) nodeResponse.end();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Pause source reads until Node's writable buffer drains. A disconnect/error
|
|
121
|
+
* rejects the wait; sendWebResponse's socket listeners cancel the web reader.
|
|
122
|
+
* @param {import('node:http').ServerResponse} response
|
|
123
|
+
*/
|
|
124
|
+
function waitForDrain(response) {
|
|
125
|
+
if (response.destroyed) {
|
|
126
|
+
return Promise.reject(new Error('The client disconnected while waiting for drain.'));
|
|
127
|
+
}
|
|
128
|
+
return new Promise((resolve, reject) => {
|
|
129
|
+
const cleanup = () => {
|
|
130
|
+
response.off('drain', onDrain);
|
|
131
|
+
response.off('close', onClose);
|
|
132
|
+
response.off('error', onError);
|
|
133
|
+
};
|
|
134
|
+
const onDrain = () => {
|
|
135
|
+
cleanup();
|
|
136
|
+
resolve(undefined);
|
|
137
|
+
};
|
|
138
|
+
const onClose = () => {
|
|
139
|
+
cleanup();
|
|
140
|
+
reject(new Error('The client disconnected while waiting for drain.'));
|
|
141
|
+
};
|
|
142
|
+
const onError = (/** @type {Error} */ error) => {
|
|
143
|
+
cleanup();
|
|
144
|
+
reject(error);
|
|
145
|
+
};
|
|
146
|
+
response.once('drain', onDrain);
|
|
147
|
+
response.once('close', onClose);
|
|
148
|
+
response.once('error', onError);
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Static-file MIME map (the common web set; anything else falls back to
|
|
153
|
+
// octet-stream, which is correct for downloads).
|
|
154
|
+
/** @type {Record<string, string>} */
|
|
155
|
+
const MIME_TYPES = {
|
|
156
|
+
'.html': 'text/html; charset=utf-8',
|
|
157
|
+
'.css': 'text/css; charset=utf-8',
|
|
158
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
159
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
160
|
+
'.json': 'application/json; charset=utf-8',
|
|
161
|
+
'.png': 'image/png',
|
|
162
|
+
'.jpg': 'image/jpeg',
|
|
163
|
+
'.jpeg': 'image/jpeg',
|
|
164
|
+
'.gif': 'image/gif',
|
|
165
|
+
'.svg': 'image/svg+xml',
|
|
166
|
+
'.ico': 'image/x-icon',
|
|
167
|
+
'.woff': 'font/woff',
|
|
168
|
+
'.woff2': 'font/woff2',
|
|
169
|
+
'.ttf': 'font/ttf',
|
|
170
|
+
'.otf': 'font/otf',
|
|
171
|
+
'.webp': 'image/webp',
|
|
172
|
+
'.avif': 'image/avif',
|
|
173
|
+
'.mp4': 'video/mp4',
|
|
174
|
+
'.webm': 'video/webm',
|
|
175
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
176
|
+
'.xml': 'application/xml',
|
|
177
|
+
'.wasm': 'application/wasm',
|
|
178
|
+
'.map': 'application/json',
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Serve a static file from `staticDir` if the request path maps to one.
|
|
183
|
+
* Hash-named build assets (Vite's /assets/ and Rsbuild's /static/) get
|
|
184
|
+
* immutable caching; other files (favicon, robots.txt, …) revalidate.
|
|
185
|
+
*
|
|
186
|
+
* @param {import('node:http').IncomingMessage} req
|
|
187
|
+
* @param {import('node:http').ServerResponse} res
|
|
188
|
+
* @param {string} staticDir
|
|
189
|
+
* @returns {boolean} true when the request was handled as a static file
|
|
190
|
+
*/
|
|
191
|
+
export function serveStaticFile(req, res, staticDir) {
|
|
192
|
+
const method = (req.method || 'GET').toUpperCase();
|
|
193
|
+
if (method !== 'GET' && method !== 'HEAD') return false;
|
|
194
|
+
|
|
195
|
+
const pathname = decodeURIComponent(new URL(req.url || '/', 'http://localhost').pathname);
|
|
196
|
+
// Resolve inside staticDir only — a `..` escape must not leave the client dir.
|
|
197
|
+
const filePath = path.normalize(path.join(staticDir, pathname));
|
|
198
|
+
if (!filePath.startsWith(path.normalize(staticDir + path.sep))) return false;
|
|
199
|
+
|
|
200
|
+
/** @type {fs.Stats} */
|
|
201
|
+
let stat;
|
|
202
|
+
try {
|
|
203
|
+
stat = fs.statSync(filePath);
|
|
204
|
+
} catch {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
if (!stat.isFile()) return false;
|
|
208
|
+
|
|
209
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
210
|
+
res.statusCode = 200;
|
|
211
|
+
res.setHeader('Content-Type', MIME_TYPES[ext] || 'application/octet-stream');
|
|
212
|
+
res.setHeader('Content-Length', stat.size);
|
|
213
|
+
res.setHeader(
|
|
214
|
+
'Cache-Control',
|
|
215
|
+
pathname.startsWith('/assets/') || pathname.startsWith('/static/')
|
|
216
|
+
? 'public, max-age=31536000, immutable'
|
|
217
|
+
: 'public, max-age=0, must-revalidate',
|
|
218
|
+
);
|
|
219
|
+
if (method === 'HEAD') {
|
|
220
|
+
res.end();
|
|
221
|
+
} else {
|
|
222
|
+
fs.createReadStream(filePath).pipe(res);
|
|
223
|
+
}
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Minimal production HTTP server: static files from `staticDir` first (built
|
|
229
|
+
* client assets), then the fetch-style SSR handler. This is the DEFAULT boot
|
|
230
|
+
* when octane.config.ts has no adapter — an adapter's `serve()` replaces it.
|
|
231
|
+
*
|
|
232
|
+
* @param {(request: Request) => Response | Promise<Response>} handler
|
|
233
|
+
* @param {{ staticDir?: string }} [options]
|
|
234
|
+
* @returns {{ listen: (port?: number) => import('node:http').Server, close: () => void }}
|
|
235
|
+
*/
|
|
236
|
+
export function createNodeServer(handler, options = {}) {
|
|
237
|
+
const staticDir = options.staticDir;
|
|
238
|
+
|
|
239
|
+
const server = http.createServer((req, res) => {
|
|
240
|
+
(async () => {
|
|
241
|
+
if (staticDir && serveStaticFile(req, res, staticDir)) return;
|
|
242
|
+
const response = await handler(nodeRequestToWebRequest(req));
|
|
243
|
+
await sendWebResponse(res, response);
|
|
244
|
+
})().catch((error) => {
|
|
245
|
+
console.error('[octane] Request error:', error);
|
|
246
|
+
if (!res.headersSent) {
|
|
247
|
+
res.statusCode = 500;
|
|
248
|
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
249
|
+
}
|
|
250
|
+
res.end('Internal Server Error');
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
listen(port = 3000) {
|
|
256
|
+
return server.listen(port);
|
|
257
|
+
},
|
|
258
|
+
close() {
|
|
259
|
+
server.close();
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
}
|