@octanejs/app-core 0.1.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@octanejs/app-core",
3
- "version": "0.1.0",
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.8.0"
82
+ "octane": "^0.10.0"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@types/node": "^24.13.3",
86
- "octane": "0.8.0"
86
+ "octane": "0.10.0"
87
87
  }
88
88
  }
@@ -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' &&
@@ -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 host = nodeRequest.headers.host || 'localhost';
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(new URL(req.url || '/', 'http://localhost').pathname);
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 gzip = shouldGzip(req, 200, headers, method !== 'HEAD');
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 = 200;
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, { fd, autoClose: true });
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. Enable only behind a trusted reverse proxy.
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. Returns true when the request was handled.
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 };
@@ -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-* headers when deriving origin for RPC fetch */
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']>;