@octanejs/app-core 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/resolve-config.js +4 -0
- package/src/server/node-http.js +127 -10
- package/src/server/server-entry.js +4 -2
- package/types/index.d.ts +4 -1
- package/types/node.d.ts +25 -2
- package/types/production.d.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@octanejs/app-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -79,10 +79,10 @@
|
|
|
79
79
|
"esbuild": "^0.28.1"
|
|
80
80
|
},
|
|
81
81
|
"peerDependencies": {
|
|
82
|
-
"octane": "^0.
|
|
82
|
+
"octane": "^0.10.0"
|
|
83
83
|
},
|
|
84
84
|
"devDependencies": {
|
|
85
85
|
"@types/node": "^24.13.3",
|
|
86
|
-
"octane": "0.
|
|
86
|
+
"octane": "0.10.0"
|
|
87
87
|
}
|
|
88
88
|
}
|
package/src/resolve-config.js
CHANGED
|
@@ -203,6 +203,10 @@ export function resolveOctaneConfig(raw, options = {}) {
|
|
|
203
203
|
|
|
204
204
|
validate_root_boundary(raw.rootBoundary);
|
|
205
205
|
|
|
206
|
+
if (raw.server?.trustProxy !== undefined && typeof raw.server.trustProxy !== 'boolean') {
|
|
207
|
+
throw new Error('[octane] server.trustProxy must be a boolean when provided.');
|
|
208
|
+
}
|
|
209
|
+
|
|
206
210
|
if (
|
|
207
211
|
raw.server?.render !== undefined &&
|
|
208
212
|
raw.server.render !== 'streaming' &&
|
package/src/server/node-http.js
CHANGED
|
@@ -128,17 +128,53 @@ function shouldGzip(request, status, headers, hasBody) {
|
|
|
128
128
|
return encodingQuality(getRequestHeader(request, 'accept-encoding'), 'gzip') > 0;
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
// A host name, IPv4, or bracketed IPv6 authority with an optional port. No URL
|
|
132
|
+
// delimiter can pass, so a forwarded host never reaches the path or userinfo.
|
|
133
|
+
const FORWARDED_HOST = /^(?:[a-z0-9_-]+(?:\.[a-z0-9_-]+)*|\[[0-9a-f:.]+\])(?::\d{1,5})?$/i;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* @param {import('node:http').IncomingMessage} request
|
|
137
|
+
* @param {string} name
|
|
138
|
+
*/
|
|
139
|
+
function firstForwardedValue(request, name) {
|
|
140
|
+
const value = getRequestHeader(request, name);
|
|
141
|
+
if (value === null) return '';
|
|
142
|
+
const comma = value.indexOf(',');
|
|
143
|
+
return (comma === -1 ? value : value.slice(0, comma)).trim();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The scheme and authority a request target resolves against. Forwarded
|
|
148
|
+
* headers are client-controlled unless a trusted proxy overwrites them, so they
|
|
149
|
+
* apply only on opt-in, and a malformed one keeps the direct connection's value.
|
|
150
|
+
* @param {import('node:http').IncomingMessage} request
|
|
151
|
+
* @param {boolean} trustProxy
|
|
152
|
+
*/
|
|
153
|
+
function requestBase(request, trustProxy) {
|
|
154
|
+
const host = request.headers.host || 'localhost';
|
|
155
|
+
if (!trustProxy) return `http://${host}`;
|
|
156
|
+
const proto = firstForwardedValue(request, 'x-forwarded-proto').toLowerCase();
|
|
157
|
+
const scheme = proto === 'https' || proto === 'http' ? proto : 'http';
|
|
158
|
+
const forwardedHost = firstForwardedValue(request, 'x-forwarded-host');
|
|
159
|
+
const forwardedBase = `${scheme}://${forwardedHost}`;
|
|
160
|
+
return FORWARDED_HOST.test(forwardedHost) && URL.canParse(forwardedBase)
|
|
161
|
+
? forwardedBase
|
|
162
|
+
: `${scheme}://${host}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
131
165
|
/**
|
|
132
166
|
* Convert a Node.js IncomingMessage to a Web Request. Passing its response
|
|
133
167
|
* keeps request.signal active through streaming and cancels it on disconnect.
|
|
134
168
|
* Without a response, cancellation only covers interrupted request uploads.
|
|
169
|
+
* With `trustProxy`, the URL's scheme and host come from the first
|
|
170
|
+
* `X-Forwarded-Proto` and `X-Forwarded-Host` entries when they are valid.
|
|
135
171
|
* @param {import('node:http').IncomingMessage} nodeRequest
|
|
136
172
|
* @param {import('node:http').ServerResponse} [nodeResponse]
|
|
173
|
+
* @param {{ trustProxy?: boolean }} [options]
|
|
137
174
|
* @returns {Request}
|
|
138
175
|
*/
|
|
139
|
-
export function nodeRequestToWebRequest(nodeRequest, nodeResponse) {
|
|
140
|
-
const
|
|
141
|
-
const url = new URL(nodeRequest.url || '/', `http://${host}`);
|
|
176
|
+
export function nodeRequestToWebRequest(nodeRequest, nodeResponse, options) {
|
|
177
|
+
const url = nodeRequestUrl(nodeRequest, options);
|
|
142
178
|
|
|
143
179
|
const headers = new Headers();
|
|
144
180
|
for (const [key, value] of Object.entries(nodeRequest.headers)) {
|
|
@@ -210,6 +246,33 @@ export function nodeRequestToWebRequest(nodeRequest, nodeResponse) {
|
|
|
210
246
|
return request;
|
|
211
247
|
}
|
|
212
248
|
|
|
249
|
+
/**
|
|
250
|
+
* The URL a Node request targets, as `nodeRequestToWebRequest` builds it: the
|
|
251
|
+
* request target on the origin from `requestBase`.
|
|
252
|
+
* @param {import('node:http').IncomingMessage} nodeRequest
|
|
253
|
+
* @param {{ trustProxy?: boolean }} [options]
|
|
254
|
+
* @returns {URL}
|
|
255
|
+
*/
|
|
256
|
+
export function nodeRequestUrl(nodeRequest, options) {
|
|
257
|
+
const { origin } = new URL(requestBase(nodeRequest, options?.trustProxy === true));
|
|
258
|
+
return resolveRequestTarget(nodeRequest.url || '/', origin);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const ABSOLUTE_FORM_TARGET = /^https?:\/\//i;
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Origin-form targets are joined to the origin, never resolved against it:
|
|
265
|
+
* URL resolution reads a leading `//` or `/\` as another host. An http(s)
|
|
266
|
+
* absolute-form target keeps its own origin (RFC 9112 section 3.2.2). Any
|
|
267
|
+
* other target, such as `*` or another scheme, becomes a path under the root.
|
|
268
|
+
* @param {string} target
|
|
269
|
+
* @param {string} origin
|
|
270
|
+
*/
|
|
271
|
+
function resolveRequestTarget(target, origin) {
|
|
272
|
+
if (ABSOLUTE_FORM_TARGET.test(target)) return new URL(target);
|
|
273
|
+
return new URL(target.startsWith('/') ? origin + target : `${origin}/${target}`);
|
|
274
|
+
}
|
|
275
|
+
|
|
213
276
|
/**
|
|
214
277
|
* Pipe a Web Response to a Node.js ServerResponse. Streams chunk-by-chunk so a
|
|
215
278
|
* streaming SSR body flushes as it renders (no buffering). A HEAD response ends
|
|
@@ -390,10 +453,38 @@ const MIME_TYPES = {
|
|
|
390
453
|
'.map': 'application/json',
|
|
391
454
|
};
|
|
392
455
|
|
|
456
|
+
const BYTE_RANGE = /^bytes=(\d*)-(\d*)$/i;
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* The one `bytes=` range of a non-empty file a request selects (RFC 9110
|
|
460
|
+
* section 14.1.2). `null` means the header is ignored and the whole file is
|
|
461
|
+
* served: several ranges, another unit, or a malformed or invalid range.
|
|
462
|
+
* `false` means the range is unsatisfiable.
|
|
463
|
+
*
|
|
464
|
+
* @param {string | undefined} header
|
|
465
|
+
* @param {number} size
|
|
466
|
+
* @returns {{ start: number, end: number } | false | null}
|
|
467
|
+
*/
|
|
468
|
+
function byteRange(header, size) {
|
|
469
|
+
const match = BYTE_RANGE.exec(header?.trim() ?? '');
|
|
470
|
+
if (!match) return null;
|
|
471
|
+
const [, first, last] = match;
|
|
472
|
+
if (first === '') {
|
|
473
|
+
if (last === '') return null;
|
|
474
|
+
const suffix = Number(last);
|
|
475
|
+
return suffix === 0 ? false : { start: Math.max(0, size - suffix), end: size - 1 };
|
|
476
|
+
}
|
|
477
|
+
const start = Number(first);
|
|
478
|
+
if (last !== '' && Number(last) < start) return null;
|
|
479
|
+
if (start >= size) return false;
|
|
480
|
+
return { start, end: last === '' ? size - 1 : Math.min(Number(last), size - 1) };
|
|
481
|
+
}
|
|
482
|
+
|
|
393
483
|
/**
|
|
394
484
|
* Serve a static file from `staticDir` if the request path maps to one.
|
|
395
485
|
* Hash-named build assets (Vite's /assets/ and Rsbuild's /static/) get
|
|
396
486
|
* immutable caching; other files (favicon, robots.txt, …) revalidate.
|
|
487
|
+
* A GET for one byte range gets 206, or 416 past the end of the file.
|
|
397
488
|
*
|
|
398
489
|
* @param {import('node:http').IncomingMessage} req
|
|
399
490
|
* @param {import('node:http').ServerResponse} res
|
|
@@ -414,7 +505,9 @@ function serveStaticFileFromRoot(req, res, staticDir, configuredRoot) {
|
|
|
414
505
|
const method = (req.method || 'GET').toUpperCase();
|
|
415
506
|
if (method !== 'GET' && method !== 'HEAD') return false;
|
|
416
507
|
|
|
417
|
-
const pathname = decodeURIComponent(
|
|
508
|
+
const pathname = decodeURIComponent(
|
|
509
|
+
resolveRequestTarget(req.url || '/', 'http://localhost').pathname,
|
|
510
|
+
);
|
|
418
511
|
// Resolve inside staticDir only — a `..` escape must not leave the client dir.
|
|
419
512
|
const filePath = path.normalize(path.join(staticDir, pathname));
|
|
420
513
|
if (!filePath.startsWith(path.normalize(staticDir + path.sep))) return false;
|
|
@@ -450,21 +543,39 @@ function serveStaticFileFromRoot(req, res, staticDir, configuredRoot) {
|
|
|
450
543
|
|
|
451
544
|
try {
|
|
452
545
|
const ext = path.extname(filePath).toLowerCase();
|
|
546
|
+
// Range applies to GET only (RFC 9110 section 14.2). Static files send no
|
|
547
|
+
// validator, so no If-Range can match and that request gets the whole file.
|
|
548
|
+
// An empty file has no byte a 206 could carry.
|
|
549
|
+
const range =
|
|
550
|
+
method === 'GET' && stat.size > 0 && req.headers['if-range'] === undefined
|
|
551
|
+
? byteRange(req.headers.range, stat.size)
|
|
552
|
+
: null;
|
|
553
|
+
if (range === false) {
|
|
554
|
+
res.statusCode = 416;
|
|
555
|
+
res.setHeader('Content-Range', `bytes */${stat.size}`);
|
|
556
|
+
res.end();
|
|
557
|
+
return true;
|
|
558
|
+
}
|
|
453
559
|
const headers = new Headers({
|
|
454
560
|
'Content-Type': MIME_TYPES[ext] || 'application/octet-stream',
|
|
455
|
-
'Content-Length': String(stat.size),
|
|
561
|
+
'Content-Length': String(range ? range.end - range.start + 1 : stat.size),
|
|
456
562
|
'Cache-Control':
|
|
457
563
|
pathname.startsWith('/assets/') || pathname.startsWith('/static/')
|
|
458
564
|
? 'public, max-age=31536000, immutable'
|
|
459
565
|
: 'public, max-age=0, must-revalidate',
|
|
460
566
|
});
|
|
461
|
-
const
|
|
567
|
+
const status = range ? 206 : 200;
|
|
568
|
+
// Ranges address the identity bytes: shouldGzip declines any request with a
|
|
569
|
+
// Range header, and a gzip body does not advertise ranges.
|
|
570
|
+
const gzip = shouldGzip(req, status, headers, method !== 'HEAD');
|
|
462
571
|
if (gzip) {
|
|
463
572
|
headers.set('Content-Encoding', 'gzip');
|
|
464
573
|
headers.delete('Content-Length');
|
|
465
574
|
}
|
|
466
575
|
|
|
467
|
-
res.statusCode =
|
|
576
|
+
res.statusCode = status;
|
|
577
|
+
if (range) res.setHeader('Content-Range', `bytes ${range.start}-${range.end}/${stat.size}`);
|
|
578
|
+
if (!gzip) res.setHeader('Accept-Ranges', 'bytes');
|
|
468
579
|
res.setHeader('Content-Type', /** @type {string} */ (headers.get('Content-Type')));
|
|
469
580
|
const contentLength = headers.get('Content-Length');
|
|
470
581
|
if (contentLength !== null) res.setHeader('Content-Length', contentLength);
|
|
@@ -476,7 +587,12 @@ function serveStaticFileFromRoot(req, res, staticDir, configuredRoot) {
|
|
|
476
587
|
if (method === 'HEAD') {
|
|
477
588
|
res.end();
|
|
478
589
|
} else {
|
|
479
|
-
const source = fs.createReadStream(resolvedFile, {
|
|
590
|
+
const source = fs.createReadStream(resolvedFile, {
|
|
591
|
+
fd,
|
|
592
|
+
autoClose: true,
|
|
593
|
+
start: range?.start,
|
|
594
|
+
end: range?.end,
|
|
595
|
+
});
|
|
480
596
|
fd = -1; // The stream now owns and closes the descriptor.
|
|
481
597
|
if (gzip) {
|
|
482
598
|
pipeline(source, createGzip(), res, (error) => {
|
|
@@ -500,11 +616,12 @@ function serveStaticFileFromRoot(req, res, staticDir, configuredRoot) {
|
|
|
500
616
|
* when octane.config.ts has no adapter — an adapter's `serve()` replaces it.
|
|
501
617
|
*
|
|
502
618
|
* @param {(request: Request) => Response | Promise<Response>} handler
|
|
503
|
-
* @param {{ staticDir?: string }} [options]
|
|
619
|
+
* @param {{ staticDir?: string, trustProxy?: boolean }} [options]
|
|
504
620
|
* @returns {{ listen: (port?: number) => import('node:http').Server, close: () => void }}
|
|
505
621
|
*/
|
|
506
622
|
export function createNodeServer(handler, options = {}) {
|
|
507
623
|
const staticDir = options.staticDir;
|
|
624
|
+
const requestOptions = { trustProxy: options.trustProxy === true };
|
|
508
625
|
/** @type {string | null} */
|
|
509
626
|
let configuredRoot = null;
|
|
510
627
|
try {
|
|
@@ -523,7 +640,7 @@ export function createNodeServer(handler, options = {}) {
|
|
|
523
640
|
) {
|
|
524
641
|
return;
|
|
525
642
|
}
|
|
526
|
-
const response = await handler(nodeRequestToWebRequest(req, res));
|
|
643
|
+
const response = await handler(nodeRequestToWebRequest(req, res, requestOptions));
|
|
527
644
|
await sendWebResponseForRequest(res, response, req);
|
|
528
645
|
})().catch((error) => {
|
|
529
646
|
console.error('[octane] Request error:', error);
|
|
@@ -469,13 +469,15 @@ export const handler = createHandler(
|
|
|
469
469
|
},
|
|
470
470
|
);
|
|
471
471
|
|
|
472
|
+
const nodeRequestOptions = { trustProxy: octaneConfig.server.trustProxy };
|
|
473
|
+
|
|
472
474
|
/**
|
|
473
475
|
* Node-style (req, res) wrapper — for serverless platforms whose functions
|
|
474
476
|
* speak Node HTTP (e.g. Vercel's Node runtime).
|
|
475
477
|
*/
|
|
476
478
|
export async function nodeHandler(req, res) {
|
|
477
479
|
try {
|
|
478
|
-
const response = await handler(nodeRequestToWebRequest(req, res));
|
|
480
|
+
const response = await handler(nodeRequestToWebRequest(req, res, nodeRequestOptions));
|
|
479
481
|
await sendWebResponse(res, response);
|
|
480
482
|
} catch (error) {
|
|
481
483
|
console.error('[octane] Request error:', error);
|
|
@@ -503,7 +505,7 @@ if (isMainModule) {
|
|
|
503
505
|
const staticDir = join(__dirname, '../client');
|
|
504
506
|
const server = octaneConfig.adapter?.serve
|
|
505
507
|
? octaneConfig.adapter.serve(handler, { static: { dir: staticDir } })
|
|
506
|
-
: createNodeServer(handler, { staticDir });
|
|
508
|
+
: createNodeServer(handler, { staticDir, trustProxy: octaneConfig.server.trustProxy });
|
|
507
509
|
server.listen(port);
|
|
508
510
|
console.log('[octane] Production server listening on port ' + port);
|
|
509
511
|
}
|
package/types/index.d.ts
CHANGED
|
@@ -482,7 +482,10 @@ export interface OctaneConfigOptions {
|
|
|
482
482
|
server?: {
|
|
483
483
|
/**
|
|
484
484
|
* Trust `X-Forwarded-Proto` / `X-Forwarded-Host` when deriving the
|
|
485
|
-
* request origin
|
|
485
|
+
* request origin: the request URL (`Context.url`) on the built-in Node
|
|
486
|
+
* servers and dev servers, and the server-function origin check. Uses the
|
|
487
|
+
* first entry of each header and ignores a malformed one. Enable only when
|
|
488
|
+
* a trusted proxy overwrites both headers; clients can set them otherwise.
|
|
486
489
|
* @default false
|
|
487
490
|
*/
|
|
488
491
|
trustProxy?: boolean;
|
package/types/node.d.ts
CHANGED
|
@@ -5,10 +5,18 @@ import type { IncomingMessage, ServerResponse, Server } from 'node:http';
|
|
|
5
5
|
* cancel request.signal on a disconnect until the response finishes, including
|
|
6
6
|
* after the request body has been consumed. Without a response, cancellation
|
|
7
7
|
* only covers interrupted request uploads.
|
|
8
|
+
*
|
|
9
|
+
* The URL is `http://` plus the Host header. With `trustProxy`, its scheme comes
|
|
10
|
+
* from the first `X-Forwarded-Proto` entry when that is `http` or `https`, and
|
|
11
|
+
* its host from the first `X-Forwarded-Host` entry when that is a plain
|
|
12
|
+
* `host[:port]`; a malformed value keeps the direct connection's. The path and
|
|
13
|
+
* query always come from the request. Enable it only when a trusted proxy
|
|
14
|
+
* overwrites both headers.
|
|
8
15
|
*/
|
|
9
16
|
export function nodeRequestToWebRequest(
|
|
10
17
|
nodeRequest: IncomingMessage,
|
|
11
18
|
nodeResponse?: ServerResponse,
|
|
19
|
+
options?: { trustProxy?: boolean },
|
|
12
20
|
): Request;
|
|
13
21
|
|
|
14
22
|
/**
|
|
@@ -23,10 +31,24 @@ export function nodeRequestToWebRequest(
|
|
|
23
31
|
*/
|
|
24
32
|
export function sendWebResponse(nodeResponse: ServerResponse, webResponse: Response): Promise<void>;
|
|
25
33
|
|
|
34
|
+
/**
|
|
35
|
+
* The URL `nodeRequestToWebRequest` gives a Node request, with the same
|
|
36
|
+
* `trustProxy` rule for its origin. An origin-form target keeps its whole path
|
|
37
|
+
* and query on that origin, so a path that starts with `//` or `/\` never names
|
|
38
|
+
* another host. An `http://` or `https://` absolute-form target keeps its own
|
|
39
|
+
* origin (RFC 9112). Any other target, such as `*`, becomes a path under the root.
|
|
40
|
+
*/
|
|
41
|
+
export function nodeRequestUrl(
|
|
42
|
+
nodeRequest: IncomingMessage,
|
|
43
|
+
options?: { trustProxy?: boolean },
|
|
44
|
+
): URL;
|
|
45
|
+
|
|
26
46
|
/**
|
|
27
47
|
* Serve a static file from `staticDir` when the request path maps to one.
|
|
28
48
|
* Vite's `/assets/*` and Rsbuild's `/static/*` hash-named output get immutable
|
|
29
|
-
* caching; other files revalidate.
|
|
49
|
+
* caching; other files revalidate. A GET for one byte range gets an
|
|
50
|
+
* uncompressed 206, or 416 past the end of the file; any other Range request
|
|
51
|
+
* gets the whole file. Returns true when the request was handled.
|
|
30
52
|
*/
|
|
31
53
|
export function serveStaticFile(
|
|
32
54
|
req: IncomingMessage,
|
|
@@ -38,8 +60,9 @@ export function serveStaticFile(
|
|
|
38
60
|
* Minimal production HTTP server: static files from `staticDir` first (the
|
|
39
61
|
* built client assets), then the fetch-style SSR handler. The default boot for
|
|
40
62
|
* `node dist/server/entry.js` when octane.config.ts has no adapter.
|
|
63
|
+
* `trustProxy` applies to each request as in `nodeRequestToWebRequest`.
|
|
41
64
|
*/
|
|
42
65
|
export function createNodeServer(
|
|
43
66
|
handler: (request: Request) => Response | Promise<Response>,
|
|
44
|
-
options?: { staticDir?: string },
|
|
67
|
+
options?: { staticDir?: string; trustProxy?: boolean },
|
|
45
68
|
): { listen: (port?: number) => Server; close: () => void };
|
package/types/production.d.ts
CHANGED
|
@@ -54,7 +54,7 @@ export interface ServerManifest {
|
|
|
54
54
|
/** Layout module path → module namespace */
|
|
55
55
|
layouts: Record<string, Record<string, unknown>>;
|
|
56
56
|
middlewares: Middleware[];
|
|
57
|
-
/** Trust X-Forwarded
|
|
57
|
+
/** Trust X-Forwarded-Proto/Host when deriving the request origin */
|
|
58
58
|
trustProxy?: boolean;
|
|
59
59
|
/** Validated `module server` origin and body-size policy. */
|
|
60
60
|
rpc?: Partial<ResolvedOctaneConfig['server']['rpc']>;
|