@pracht/adapter-node 0.0.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.
@@ -0,0 +1,39 @@
1
+ import { ISGManifestEntry, ModuleRegistry, PrachtApp, ResolvedApiRoute } from "@pracht/core";
2
+ import { IncomingMessage, ServerResponse } from "node:http";
3
+ import { PrachtAdapter } from "@pracht/vite-plugin";
4
+
5
+ //#region src/index.d.ts
6
+ interface NodeAdapterContextArgs {
7
+ request: Request;
8
+ req: IncomingMessage;
9
+ res: ServerResponse;
10
+ }
11
+ interface NodeAdapterOptions<TContext = unknown> {
12
+ app: PrachtApp;
13
+ registry?: ModuleRegistry;
14
+ staticDir?: string;
15
+ viteManifest?: unknown;
16
+ isgManifest?: Record<string, ISGManifestEntry>;
17
+ apiRoutes?: ResolvedApiRoute[];
18
+ clientEntryUrl?: string;
19
+ cssUrls?: string[];
20
+ cssManifest?: Record<string, string[]>;
21
+ jsManifest?: Record<string, string[]>;
22
+ createContext?: (args: NodeAdapterContextArgs) => TContext | Promise<TContext>;
23
+ }
24
+ interface NodeServerEntryModuleOptions {
25
+ port?: number;
26
+ }
27
+ declare function createNodeRequestHandler<TContext = unknown>(options: NodeAdapterOptions<TContext>): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
28
+ declare function createNodeServerEntryModule(options?: NodeServerEntryModuleOptions): string;
29
+ /**
30
+ * Create a pracht adapter for Node.js.
31
+ *
32
+ * ```ts
33
+ * import { nodeAdapter } from "@pracht/adapter-node";
34
+ * pracht({ adapter: nodeAdapter() })
35
+ * ```
36
+ */
37
+ declare function nodeAdapter(options?: NodeServerEntryModuleOptions): PrachtAdapter;
38
+ //#endregion
39
+ export { NodeAdapterContextArgs, NodeAdapterOptions, NodeServerEntryModuleOptions, createNodeRequestHandler, createNodeServerEntryModule, nodeAdapter };
package/dist/index.mjs ADDED
@@ -0,0 +1,204 @@
1
+ import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { applyDefaultSecurityHeaders, handlePrachtRequest } from "@pracht/core";
5
+ //#region src/index.ts
6
+ function createNodeRequestHandler(options) {
7
+ const isgManifest = options.isgManifest ?? {};
8
+ const staticDir = options.staticDir;
9
+ return async (req, res) => {
10
+ let request;
11
+ try {
12
+ request = await createWebRequest(req);
13
+ } catch (err) {
14
+ if (err instanceof Error && err.message === "Request body too large") {
15
+ res.statusCode = 413;
16
+ res.end("Payload Too Large");
17
+ return;
18
+ }
19
+ throw err;
20
+ }
21
+ const url = new URL(request.url);
22
+ if (staticDir && request.method === "GET" && url.pathname in isgManifest) {
23
+ const entry = isgManifest[url.pathname];
24
+ const htmlPath = url.pathname === "/" ? join(staticDir, "index.html") : join(staticDir, url.pathname, "index.html");
25
+ if (existsSync(htmlPath)) {
26
+ const stat = statSync(htmlPath);
27
+ const ageMs = Date.now() - stat.mtimeMs;
28
+ const isStale = entry.revalidate.kind === "time" && ageMs > entry.revalidate.seconds * 1e3;
29
+ const html = await readFile(htmlPath, "utf-8");
30
+ res.statusCode = 200;
31
+ applyDefaultSecurityHeaders(new Headers({
32
+ "content-type": "text/html; charset=utf-8",
33
+ "x-pracht-isg": isStale ? "stale" : "fresh"
34
+ })).forEach((value, key) => {
35
+ res.setHeader(key, value);
36
+ });
37
+ res.end(html);
38
+ if (isStale) regenerateISGPage(options, url.pathname, htmlPath).catch((err) => {
39
+ console.error(`ISG regeneration failed for ${url.pathname}:`, err);
40
+ });
41
+ return;
42
+ }
43
+ }
44
+ const context = options.createContext ? await options.createContext({
45
+ request,
46
+ req,
47
+ res
48
+ }) : void 0;
49
+ const response = await handlePrachtRequest({
50
+ app: options.app,
51
+ context,
52
+ registry: options.registry,
53
+ request,
54
+ apiRoutes: options.apiRoutes,
55
+ clientEntryUrl: options.clientEntryUrl,
56
+ cssManifest: options.cssManifest,
57
+ jsManifest: options.jsManifest
58
+ });
59
+ if (staticDir && request.method === "GET" && url.pathname in isgManifest && response.status === 200) {
60
+ const html = await response.clone().text();
61
+ const htmlPath = url.pathname === "/" ? join(staticDir, "index.html") : join(staticDir, url.pathname, "index.html");
62
+ mkdirSync(dirname(htmlPath), { recursive: true });
63
+ writeFileSync(htmlPath, html, "utf-8");
64
+ }
65
+ await writeWebResponse(res, response);
66
+ };
67
+ }
68
+ async function regenerateISGPage(options, pathname, htmlPath) {
69
+ const url = new URL(pathname, "http://localhost");
70
+ const request = new Request(url, { method: "GET" });
71
+ const response = await handlePrachtRequest({
72
+ app: options.app,
73
+ registry: options.registry,
74
+ request,
75
+ clientEntryUrl: options.clientEntryUrl,
76
+ cssManifest: options.cssManifest,
77
+ jsManifest: options.jsManifest
78
+ });
79
+ if (response.status === 200) {
80
+ const html = await response.text();
81
+ mkdirSync(dirname(htmlPath), { recursive: true });
82
+ writeFileSync(htmlPath, html, "utf-8");
83
+ }
84
+ }
85
+ function createNodeServerEntryModule(options = {}) {
86
+ return [
87
+ "import { existsSync, readFileSync } from \"node:fs\";",
88
+ "import { createServer } from \"node:http\";",
89
+ "import { dirname, resolve } from \"node:path\";",
90
+ "import { fileURLToPath, pathToFileURL } from \"node:url\";",
91
+ "import { createNodeRequestHandler } from \"@pracht/adapter-node\";",
92
+ "",
93
+ "const serverDir = dirname(fileURLToPath(import.meta.url));",
94
+ "const staticDir = resolve(serverDir, \"../client\");",
95
+ "const isgManifestPath = resolve(serverDir, \"isg-manifest.json\");",
96
+ "const isgManifest = existsSync(isgManifestPath)",
97
+ " ? JSON.parse(readFileSync(isgManifestPath, \"utf-8\"))",
98
+ " : {};",
99
+ "",
100
+ "export const handler = createNodeRequestHandler({",
101
+ " app: resolvedApp,",
102
+ " registry,",
103
+ " staticDir,",
104
+ " isgManifest,",
105
+ " apiRoutes,",
106
+ " clientEntryUrl: clientEntryUrl ?? undefined,",
107
+ " cssManifest,",
108
+ " jsManifest,",
109
+ "});",
110
+ "",
111
+ "const entryHref = process.argv[1] ? pathToFileURL(process.argv[1]).href : null;",
112
+ "if (entryHref && import.meta.url === entryHref) {",
113
+ " const server = createServer(handler);",
114
+ ` const port = Number(process.env.PORT ?? ${options.port ?? 3e3});`,
115
+ " server.listen(port, () => {",
116
+ " console.log(`pracht node server listening on http://localhost:${port}`);",
117
+ " });",
118
+ "}",
119
+ ""
120
+ ].join("\n");
121
+ }
122
+ async function createWebRequest(req) {
123
+ const protocol = getFirstHeaderValue(req.headers["x-forwarded-proto"]) ?? "http";
124
+ const host = getFirstHeaderValue(req.headers.host) ?? "localhost";
125
+ const url = new URL(req.url ?? "/", `${protocol}://${host}`);
126
+ const method = req.method ?? "GET";
127
+ const init = {
128
+ headers: createHeaders(req.headers),
129
+ method
130
+ };
131
+ if (!BODYLESS_METHODS.has(method.toUpperCase())) {
132
+ const body = await readRequestBody(req);
133
+ if (body.byteLength > 0) {
134
+ const exactBody = new Uint8Array(body.byteLength);
135
+ exactBody.set(body);
136
+ init.body = exactBody.buffer;
137
+ }
138
+ }
139
+ return new Request(url, init);
140
+ }
141
+ function createHeaders(headers) {
142
+ const result = new Headers();
143
+ for (const [key, value] of Object.entries(headers)) {
144
+ if (typeof value === "undefined") continue;
145
+ if (Array.isArray(value)) {
146
+ for (const entry of value) result.append(key, entry);
147
+ continue;
148
+ }
149
+ result.set(key, value);
150
+ }
151
+ return result;
152
+ }
153
+ const MAX_BODY_SIZE = 1024 * 1024;
154
+ async function readRequestBody(req) {
155
+ const chunks = [];
156
+ let totalSize = 0;
157
+ for await (const chunk of req) {
158
+ const buf = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
159
+ totalSize += buf.byteLength;
160
+ if (totalSize > MAX_BODY_SIZE) {
161
+ req.destroy();
162
+ throw new Error("Request body too large");
163
+ }
164
+ chunks.push(buf);
165
+ }
166
+ return Buffer.concat(chunks);
167
+ }
168
+ async function writeWebResponse(res, response) {
169
+ res.statusCode = response.status;
170
+ res.statusMessage = response.statusText;
171
+ response.headers.forEach((value, key) => {
172
+ res.setHeader(key, value);
173
+ });
174
+ if (!response.body) {
175
+ res.end();
176
+ return;
177
+ }
178
+ const body = Buffer.from(await response.arrayBuffer());
179
+ res.end(body);
180
+ }
181
+ function getFirstHeaderValue(value) {
182
+ if (Array.isArray(value)) return value[0];
183
+ return value;
184
+ }
185
+ const BODYLESS_METHODS = new Set(["GET", "HEAD"]);
186
+ /**
187
+ * Create a pracht adapter for Node.js.
188
+ *
189
+ * ```ts
190
+ * import { nodeAdapter } from "@pracht/adapter-node";
191
+ * pracht({ adapter: nodeAdapter() })
192
+ * ```
193
+ */
194
+ function nodeAdapter(options = {}) {
195
+ return {
196
+ id: "node",
197
+ serverImports: "import { resolveApp, resolveApiRoutes } from \"@pracht/core\";",
198
+ createServerEntryModule() {
199
+ return createNodeServerEntryModule(options);
200
+ }
201
+ };
202
+ }
203
+ //#endregion
204
+ export { createNodeRequestHandler, createNodeServerEntryModule, nodeAdapter };
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@pracht/adapter-node",
3
+ "version": "0.0.0",
4
+ "files": [
5
+ "dist"
6
+ ],
7
+ "type": "module",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.mts",
11
+ "default": "./dist/index.mjs"
12
+ }
13
+ },
14
+ "dependencies": {
15
+ "@pracht/core": "0.0.0"
16
+ },
17
+ "scripts": {
18
+ "build": "tsdown"
19
+ }
20
+ }