@pracht/adapter-node 0.0.1 → 0.1.0

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/dist/index.d.mts CHANGED
@@ -20,6 +20,23 @@ interface NodeAdapterOptions<TContext = unknown> {
20
20
  cssManifest?: Record<string, string[]>;
21
21
  jsManifest?: Record<string, string[]>;
22
22
  createContext?: (args: NodeAdapterContextArgs) => TContext | Promise<TContext>;
23
+ /**
24
+ * Whether to trust proxy headers (`Forwarded`, `X-Forwarded-Proto`,
25
+ * `X-Forwarded-Host`) when constructing the request URL.
26
+ *
27
+ * When **false** (the default), the request URL is derived from the socket:
28
+ * protocol is inferred from TLS state, and host from the `Host` header.
29
+ * Forwarded headers are ignored, preventing host-header poisoning.
30
+ *
31
+ * When **true**, forwarded headers are honored with the following precedence:
32
+ * 1. RFC 7239 `Forwarded` header (`proto=` and `host=` directives)
33
+ * 2. `X-Forwarded-Proto` / `X-Forwarded-Host`
34
+ * 3. Socket-derived values (fallback)
35
+ *
36
+ * Enable this only when the Node server sits behind a trusted reverse proxy
37
+ * (e.g. nginx, Cloudflare, a load balancer) that sets these headers.
38
+ */
39
+ trustProxy?: boolean;
23
40
  }
24
41
  interface NodeServerEntryModuleOptions {
25
42
  port?: number;
package/dist/index.mjs CHANGED
@@ -1,15 +1,46 @@
1
1
  import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
2
2
  import { readFile } from "node:fs/promises";
3
- import { dirname, join } from "node:path";
3
+ import { dirname, extname, join } from "node:path";
4
4
  import { applyDefaultSecurityHeaders, handlePrachtRequest } from "@pracht/core";
5
5
  //#region src/index.ts
6
+ const ROUTE_STATE_REQUEST_HEADER = "x-pracht-route-state-request";
7
+ const MIME_TYPES = {
8
+ ".html": "text/html; charset=utf-8",
9
+ ".js": "application/javascript",
10
+ ".css": "text/css",
11
+ ".json": "application/json",
12
+ ".png": "image/png",
13
+ ".jpg": "image/jpeg",
14
+ ".jpeg": "image/jpeg",
15
+ ".gif": "image/gif",
16
+ ".webp": "image/webp",
17
+ ".svg": "image/svg+xml",
18
+ ".ico": "image/x-icon",
19
+ ".woff": "font/woff",
20
+ ".woff2": "font/woff2",
21
+ ".ttf": "font/ttf",
22
+ ".otf": "font/otf",
23
+ ".txt": "text/plain",
24
+ ".xml": "application/xml",
25
+ ".webmanifest": "application/manifest+json"
26
+ };
27
+ /**
28
+ * Hashed assets (e.g. `assets/chunk-AbCd1234.js`) are safe to cache
29
+ * indefinitely. Everything else gets a conservative policy.
30
+ */
31
+ const HASHED_ASSET_RE = /\/assets\//;
32
+ function getCacheControl(urlPath) {
33
+ if (HASHED_ASSET_RE.test(urlPath)) return "public, max-age=31536000, immutable";
34
+ return "public, max-age=0, must-revalidate";
35
+ }
6
36
  function createNodeRequestHandler(options) {
7
37
  const isgManifest = options.isgManifest ?? {};
8
38
  const staticDir = options.staticDir;
39
+ const trustProxy = options.trustProxy ?? false;
9
40
  return async (req, res) => {
10
41
  let request;
11
42
  try {
12
- request = await createWebRequest(req);
43
+ request = await createWebRequest(req, trustProxy);
13
44
  } catch (err) {
14
45
  if (err instanceof Error && err.message === "Request body too large") {
15
46
  res.statusCode = 413;
@@ -19,7 +50,23 @@ function createNodeRequestHandler(options) {
19
50
  throw err;
20
51
  }
21
52
  const url = new URL(request.url);
22
- if (staticDir && request.method === "GET" && url.pathname in isgManifest) {
53
+ const isRouteStateRequest = request.headers.get(ROUTE_STATE_REQUEST_HEADER) === "1";
54
+ if (staticDir && request.method === "GET") {
55
+ const staticResult = resolveStaticFile(staticDir, url.pathname, isgManifest);
56
+ if (staticResult) {
57
+ const body = await readFile(staticResult.filePath);
58
+ res.statusCode = 200;
59
+ applyDefaultSecurityHeaders(new Headers({
60
+ "content-type": staticResult.contentType,
61
+ "cache-control": staticResult.cacheControl
62
+ })).forEach((value, key) => {
63
+ res.setHeader(key, value);
64
+ });
65
+ res.end(body);
66
+ return;
67
+ }
68
+ }
69
+ if (staticDir && request.method === "GET" && !isRouteStateRequest && url.pathname in isgManifest) {
23
70
  const entry = isgManifest[url.pathname];
24
71
  const htmlPath = url.pathname === "/" ? join(staticDir, "index.html") : join(staticDir, url.pathname, "index.html");
25
72
  if (existsSync(htmlPath)) {
@@ -30,7 +77,9 @@ function createNodeRequestHandler(options) {
30
77
  res.statusCode = 200;
31
78
  applyDefaultSecurityHeaders(new Headers({
32
79
  "content-type": "text/html; charset=utf-8",
33
- "x-pracht-isg": isStale ? "stale" : "fresh"
80
+ "cache-control": "public, max-age=0, must-revalidate",
81
+ "x-pracht-isg": isStale ? "stale" : "fresh",
82
+ vary: ROUTE_STATE_REQUEST_HEADER
34
83
  })).forEach((value, key) => {
35
84
  res.setHeader(key, value);
36
85
  });
@@ -56,7 +105,7 @@ function createNodeRequestHandler(options) {
56
105
  cssManifest: options.cssManifest,
57
106
  jsManifest: options.jsManifest
58
107
  });
59
- if (staticDir && request.method === "GET" && url.pathname in isgManifest && response.status === 200) {
108
+ if (staticDir && request.method === "GET" && !isRouteStateRequest && url.pathname in isgManifest && response.status === 200 && response.headers.get("content-type")?.includes("text/html")) {
60
109
  const html = await response.clone().text();
61
110
  const htmlPath = url.pathname === "/" ? join(staticDir, "index.html") : join(staticDir, url.pathname, "index.html");
62
111
  mkdirSync(dirname(htmlPath), { recursive: true });
@@ -119,9 +168,8 @@ function createNodeServerEntryModule(options = {}) {
119
168
  ""
120
169
  ].join("\n");
121
170
  }
122
- async function createWebRequest(req) {
123
- const protocol = getFirstHeaderValue(req.headers["x-forwarded-proto"]) ?? "http";
124
- const host = getFirstHeaderValue(req.headers.host) ?? "localhost";
171
+ async function createWebRequest(req, trustProxy) {
172
+ const { protocol, host } = resolveOrigin(req, trustProxy);
125
173
  const url = new URL(req.url ?? "/", `${protocol}://${host}`);
126
174
  const method = req.method ?? "GET";
127
175
  const init = {
@@ -138,6 +186,56 @@ async function createWebRequest(req) {
138
186
  }
139
187
  return new Request(url, init);
140
188
  }
189
+ /**
190
+ * Derive the request protocol and host from the incoming message.
191
+ *
192
+ * When `trustProxy` is false (default), the protocol is inferred from the
193
+ * socket's TLS state and the host from the HTTP `Host` header. Forwarded
194
+ * headers are ignored entirely.
195
+ *
196
+ * When `trustProxy` is true, the following precedence applies:
197
+ * 1. RFC 7239 `Forwarded` header (`proto=` / `host=` directives)
198
+ * 2. `X-Forwarded-Proto` / `X-Forwarded-Host`
199
+ * 3. Socket-derived values (fallback)
200
+ */
201
+ function resolveOrigin(req, trustProxy) {
202
+ const socketProtocol = "encrypted" in req.socket && req.socket.encrypted ? "https" : "http";
203
+ const socketHost = getFirstHeaderValue(req.headers.host) ?? "localhost";
204
+ if (!trustProxy) return {
205
+ protocol: socketProtocol,
206
+ host: socketHost
207
+ };
208
+ const forwarded = getFirstHeaderValue(req.headers.forwarded);
209
+ if (forwarded) {
210
+ const parsed = parseForwardedHeader(forwarded);
211
+ return {
212
+ protocol: parsed.proto ?? getFirstHeaderValue(req.headers["x-forwarded-proto"]) ?? socketProtocol,
213
+ host: parsed.host ?? getFirstHeaderValue(req.headers["x-forwarded-host"]) ?? socketHost
214
+ };
215
+ }
216
+ return {
217
+ protocol: getFirstHeaderValue(req.headers["x-forwarded-proto"]) ?? socketProtocol,
218
+ host: getFirstHeaderValue(req.headers["x-forwarded-host"]) ?? socketHost
219
+ };
220
+ }
221
+ /**
222
+ * Parse the first element of an RFC 7239 `Forwarded` header, extracting
223
+ * `proto` and `host` directives. Returns `undefined` for directives that
224
+ * are not present.
225
+ */
226
+ function parseForwardedHeader(value) {
227
+ const first = value.split(",")[0];
228
+ const result = {};
229
+ for (const part of first.split(";")) {
230
+ const [key, val] = part.trim().split("=");
231
+ if (!key || !val) continue;
232
+ const k = key.toLowerCase();
233
+ const v = val.replace(/^"|"$/g, "");
234
+ if (k === "proto") result.proto = v;
235
+ else if (k === "host") result.host = v;
236
+ }
237
+ return result;
238
+ }
141
239
  function createHeaders(headers) {
142
240
  const result = new Headers();
143
241
  for (const [key, value] of Object.entries(headers)) {
@@ -184,6 +282,35 @@ function getFirstHeaderValue(value) {
184
282
  }
185
283
  const BODYLESS_METHODS = new Set(["GET", "HEAD"]);
186
284
  /**
285
+ * Resolve a URL pathname to a static file inside `staticDir`.
286
+ *
287
+ * Tries the exact path first (e.g. `/assets/chunk-Ab12.js`), then falls back
288
+ * to `{pathname}/index.html` for clean-URL pages (e.g. `/about` →
289
+ * `about/index.html`). Returns `null` when no matching file is found.
290
+ */
291
+ function resolveStaticFile(staticDir, pathname, isgManifest = {}) {
292
+ const exactPath = join(staticDir, pathname);
293
+ if (!exactPath.startsWith(staticDir + "/") && exactPath !== staticDir) return null;
294
+ try {
295
+ if (statSync(exactPath).isFile()) return {
296
+ filePath: exactPath,
297
+ contentType: MIME_TYPES[extname(exactPath)] || "application/octet-stream",
298
+ cacheControl: getCacheControl(pathname)
299
+ };
300
+ } catch {}
301
+ if (pathname in isgManifest) return null;
302
+ const indexPath = pathname === "/" ? join(staticDir, "index.html") : join(staticDir, pathname, "index.html");
303
+ if (!indexPath.startsWith(staticDir + "/")) return null;
304
+ try {
305
+ if (statSync(indexPath).isFile()) return {
306
+ filePath: indexPath,
307
+ contentType: "text/html; charset=utf-8",
308
+ cacheControl: "public, max-age=0, must-revalidate"
309
+ };
310
+ } catch {}
311
+ return null;
312
+ }
313
+ /**
187
314
  * Create a pracht adapter for Node.js.
188
315
  *
189
316
  * ```ts
package/package.json CHANGED
@@ -1,30 +1,30 @@
1
1
  {
2
2
  "name": "@pracht/adapter-node",
3
- "version": "0.0.1",
3
+ "version": "0.1.0",
4
+ "homepage": "https://github.com/JoviDeCroock/pracht/tree/main/packages/adapter-node",
5
+ "bugs": {
6
+ "url": "https://github.com/JoviDeCroock/pracht/issues"
7
+ },
4
8
  "repository": {
5
9
  "type": "git",
6
10
  "url": "https://github.com/JoviDeCroock/pracht",
7
11
  "directory": "packages/adapter-node"
8
12
  },
9
- "homepage": "https://github.com/JoviDeCroock/pracht/tree/main/packages/adapter-node",
10
- "bugs": {
11
- "url": "https://github.com/JoviDeCroock/pracht/issues"
12
- },
13
13
  "files": [
14
14
  "dist"
15
15
  ],
16
16
  "type": "module",
17
- "publishConfig": {
18
- "provenance": true
19
- },
20
17
  "exports": {
21
18
  ".": {
22
19
  "types": "./dist/index.d.mts",
23
20
  "default": "./dist/index.mjs"
24
21
  }
25
22
  },
23
+ "publishConfig": {
24
+ "provenance": true
25
+ },
26
26
  "dependencies": {
27
- "@pracht/core": "0.0.1"
27
+ "@pracht/core": "0.1.0"
28
28
  },
29
29
  "scripts": {
30
30
  "build": "tsdown"