@pracht/adapter-node 0.0.1 → 0.1.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/dist/index.d.mts +17 -0
- package/dist/index.mjs +135 -8
- package/package.json +9 -9
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
|
-
|
|
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
|
-
"
|
|
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 =
|
|
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.
|
|
3
|
+
"version": "0.1.1",
|
|
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
|
|
27
|
+
"@pracht/core": "0.2.0"
|
|
28
28
|
},
|
|
29
29
|
"scripts": {
|
|
30
30
|
"build": "tsdown"
|