@tschk/moonshine-deploy-node 0.3.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/README.md +16 -0
- package/package.json +32 -0
- package/src/index.ts +280 -0
package/README.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# `@tschk/moonshine-deploy-node`
|
|
2
|
+
|
|
3
|
+
Node HTTP deployment adapter for Moonshine.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { nodeAdapter, createNodeHandler } from "@tschk/moonshine-deploy-node";
|
|
7
|
+
|
|
8
|
+
await nodeAdapter.build(manifest, ".moonshine");
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
- Target runtime: `node`.
|
|
12
|
+
- Capabilities: `streaming`, `islands`, `revalidation`.
|
|
13
|
+
- Output: `server.ts` using `node:http` and a `Request` / `Response` bridge.
|
|
14
|
+
|
|
15
|
+
See [docs/DEPLOYMENT.md](../../docs/DEPLOYMENT.md) and
|
|
16
|
+
[docs/MANIFEST.md](../../docs/MANIFEST.md).
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tschk/moonshine-deploy-node",
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"license": "ISC",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"format": "bunx prettier --check .",
|
|
13
|
+
"lint": "bunx oxlint .",
|
|
14
|
+
"typecheck": "bunx tsc --noEmit",
|
|
15
|
+
"test": "bun test"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@tschk/moonshine-framework": "^0.3.1",
|
|
19
|
+
"@tschk/moonshine-server": "^0.3.1",
|
|
20
|
+
"@tschk/moonshine-compiler": "^0.3.1",
|
|
21
|
+
"@tschk/moonshine-react": "^0.3.1"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/bun": "1.3.14",
|
|
25
|
+
"@types/node": "^22.0.0",
|
|
26
|
+
"@tschk/moonshine-adapter-conformance": "^0.3.1",
|
|
27
|
+
"typescript": "7.0.2"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"src"
|
|
31
|
+
]
|
|
32
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { createReadStream } from "node:fs";
|
|
2
|
+
import { copyFile, mkdir, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import {
|
|
4
|
+
createServer,
|
|
5
|
+
type IncomingMessage,
|
|
6
|
+
type ServerResponse,
|
|
7
|
+
} from "node:http";
|
|
8
|
+
import { dirname, isAbsolute, resolve } from "node:path";
|
|
9
|
+
import { Readable } from "node:stream";
|
|
10
|
+
import {
|
|
11
|
+
type DeploymentAdapter,
|
|
12
|
+
type MoonshineManifest,
|
|
13
|
+
} from "@tschk/moonshine-framework";
|
|
14
|
+
import { resolveStaticPath } from "@tschk/moonshine-server";
|
|
15
|
+
import {
|
|
16
|
+
type Harness,
|
|
17
|
+
type HarnessFactory,
|
|
18
|
+
} from "@tschk/moonshine-adapter-conformance";
|
|
19
|
+
|
|
20
|
+
const HOP_BY_HOP = new Set([
|
|
21
|
+
"connection",
|
|
22
|
+
"keep-alive",
|
|
23
|
+
"proxy-authenticate",
|
|
24
|
+
"proxy-authorization",
|
|
25
|
+
"te",
|
|
26
|
+
"trailer",
|
|
27
|
+
"transfer-encoding",
|
|
28
|
+
"upgrade",
|
|
29
|
+
"content-length",
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
function isHopByHopHeader(key: string): boolean {
|
|
33
|
+
return HOP_BY_HOP.has(key.toLowerCase());
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const MIME: Record<string, string> = {
|
|
37
|
+
".css": "text/css; charset=utf-8",
|
|
38
|
+
".gif": "image/gif",
|
|
39
|
+
".html": "text/html; charset=utf-8",
|
|
40
|
+
".ico": "image/x-icon",
|
|
41
|
+
".jpeg": "image/jpeg",
|
|
42
|
+
".jpg": "image/jpeg",
|
|
43
|
+
".js": "text/javascript; charset=utf-8",
|
|
44
|
+
".json": "application/json; charset=utf-8",
|
|
45
|
+
".map": "application/json; charset=utf-8",
|
|
46
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
47
|
+
".png": "image/png",
|
|
48
|
+
".svg": "image/svg+xml",
|
|
49
|
+
".txt": "text/plain; charset=utf-8",
|
|
50
|
+
".wasm": "application/wasm",
|
|
51
|
+
".webp": "image/webp",
|
|
52
|
+
".woff": "font/woff",
|
|
53
|
+
".woff2": "font/woff2",
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
function extOf(path: string): string {
|
|
57
|
+
const i = path.lastIndexOf(".");
|
|
58
|
+
return i >= 0 ? path.slice(i).toLowerCase() : "";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function tryServeNodeStatic(
|
|
62
|
+
staticDir: string,
|
|
63
|
+
pathname: string,
|
|
64
|
+
): Promise<Response | null> {
|
|
65
|
+
const filePath = resolveStaticPath(staticDir, pathname);
|
|
66
|
+
if (!filePath) return null;
|
|
67
|
+
let stats;
|
|
68
|
+
try {
|
|
69
|
+
stats = await stat(filePath);
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
if (!stats.isFile()) return null;
|
|
74
|
+
const type = MIME[extOf(filePath)] ?? "application/octet-stream";
|
|
75
|
+
const stream = createReadStream(filePath);
|
|
76
|
+
return new Response(Readable.toWeb(stream) as unknown as ReadableStream, {
|
|
77
|
+
headers: {
|
|
78
|
+
"content-type": type,
|
|
79
|
+
"content-length": String(stats.size),
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function sendNodeResponse(
|
|
85
|
+
res: ServerResponse,
|
|
86
|
+
response: Response,
|
|
87
|
+
): Promise<void> {
|
|
88
|
+
const headers: Record<string, string> = {};
|
|
89
|
+
for (const [key, value] of response.headers.entries()) {
|
|
90
|
+
if (isHopByHopHeader(key)) continue;
|
|
91
|
+
headers[key] = value;
|
|
92
|
+
}
|
|
93
|
+
res.writeHead(response.status, headers);
|
|
94
|
+
if (!response.body) {
|
|
95
|
+
res.end();
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const stream = Readable.fromWeb(
|
|
99
|
+
response.body as unknown as Parameters<typeof Readable.fromWeb>[0],
|
|
100
|
+
);
|
|
101
|
+
stream.on("error", (err) => {
|
|
102
|
+
res.destroy(err);
|
|
103
|
+
});
|
|
104
|
+
stream.pipe(res);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export type NodeHandlerOptions = {
|
|
108
|
+
fetch: (request: Request) => Promise<Response>;
|
|
109
|
+
staticDir?: string;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export function createNodeHandler(
|
|
113
|
+
options: NodeHandlerOptions,
|
|
114
|
+
): (req: IncomingMessage, res: ServerResponse) => Promise<void> {
|
|
115
|
+
return async (req, res) => {
|
|
116
|
+
const method = req.method ?? "GET";
|
|
117
|
+
const reqUrl = req.url ?? "/";
|
|
118
|
+
const host = req.headers.host ?? "localhost";
|
|
119
|
+
const requestUrl = new URL(reqUrl, `http://${host}`);
|
|
120
|
+
const pathname = requestUrl.pathname;
|
|
121
|
+
|
|
122
|
+
if (
|
|
123
|
+
options.staticDir &&
|
|
124
|
+
(method === "GET" || method === "HEAD") &&
|
|
125
|
+
pathname !== "/"
|
|
126
|
+
) {
|
|
127
|
+
const staticRes = await tryServeNodeStatic(options.staticDir, pathname);
|
|
128
|
+
if (staticRes) {
|
|
129
|
+
await sendNodeResponse(res, staticRes);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const controller = new AbortController();
|
|
135
|
+
let closed = false;
|
|
136
|
+
const onClose = () => {
|
|
137
|
+
if (closed) return;
|
|
138
|
+
closed = true;
|
|
139
|
+
controller.abort();
|
|
140
|
+
};
|
|
141
|
+
req.once("close", onClose);
|
|
142
|
+
res.once("close", onClose);
|
|
143
|
+
|
|
144
|
+
const headers = new Headers();
|
|
145
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
146
|
+
if (value === undefined) continue;
|
|
147
|
+
if (isHopByHopHeader(key)) continue;
|
|
148
|
+
if (Array.isArray(value)) {
|
|
149
|
+
for (const v of value) {
|
|
150
|
+
headers.append(key, v);
|
|
151
|
+
}
|
|
152
|
+
} else {
|
|
153
|
+
headers.set(key, value);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const body =
|
|
158
|
+
method === "GET" || method === "HEAD" ? undefined : Readable.toWeb(req);
|
|
159
|
+
|
|
160
|
+
const init = {
|
|
161
|
+
method,
|
|
162
|
+
headers,
|
|
163
|
+
body,
|
|
164
|
+
signal: controller.signal,
|
|
165
|
+
duplex: "half",
|
|
166
|
+
} as unknown as RequestInit;
|
|
167
|
+
const request = new Request(requestUrl.href, init);
|
|
168
|
+
|
|
169
|
+
let response: Response;
|
|
170
|
+
try {
|
|
171
|
+
response = await options.fetch(request);
|
|
172
|
+
} catch (error) {
|
|
173
|
+
if (!res.headersSent) {
|
|
174
|
+
res.writeHead(500, { "content-type": "text/plain" });
|
|
175
|
+
}
|
|
176
|
+
res.end(String(error));
|
|
177
|
+
return;
|
|
178
|
+
} finally {
|
|
179
|
+
req.off("close", onClose);
|
|
180
|
+
res.off("close", onClose);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
await sendNodeResponse(res, response);
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export const nodeHarness: HarnessFactory = (fetch, options) => {
|
|
188
|
+
const handler = createNodeHandler({
|
|
189
|
+
fetch,
|
|
190
|
+
staticDir: options.staticDir,
|
|
191
|
+
});
|
|
192
|
+
const server = createServer(handler);
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
start: async () => {
|
|
196
|
+
await new Promise<void>((resolve) => server.listen(0, resolve));
|
|
197
|
+
const address = server.address() as { port: number } | null;
|
|
198
|
+
const port = address?.port ?? 0;
|
|
199
|
+
return new URL(`http://localhost:${port}/`);
|
|
200
|
+
},
|
|
201
|
+
stop: async () => {
|
|
202
|
+
await new Promise<void>((resolve, reject) =>
|
|
203
|
+
server.close((err) => (err ? reject(err) : resolve())),
|
|
204
|
+
);
|
|
205
|
+
},
|
|
206
|
+
} as Harness;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
async function copyAsset(
|
|
210
|
+
asset: MoonshineManifest["assets"][number],
|
|
211
|
+
outDir: string,
|
|
212
|
+
): Promise<string> {
|
|
213
|
+
const source = isAbsolute(asset.file)
|
|
214
|
+
? asset.file
|
|
215
|
+
: resolve(outDir, asset.file);
|
|
216
|
+
const target = resolve(
|
|
217
|
+
outDir,
|
|
218
|
+
(asset.path ?? asset.file).replace(/^\/+/, ""),
|
|
219
|
+
);
|
|
220
|
+
await mkdir(dirname(target), { recursive: true });
|
|
221
|
+
if (source !== target) {
|
|
222
|
+
await copyFile(source, target);
|
|
223
|
+
}
|
|
224
|
+
return (asset.path ?? asset.file).replace(/^\/+/, "");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export const nodeAdapter: DeploymentAdapter = {
|
|
228
|
+
name: "node",
|
|
229
|
+
runtimes: ["node"],
|
|
230
|
+
capabilities: ["streaming", "islands", "revalidation"],
|
|
231
|
+
build: async (manifest, outDir) => {
|
|
232
|
+
await mkdir(outDir, { recursive: true });
|
|
233
|
+
const assets: MoonshineManifest["assets"] = [];
|
|
234
|
+
for (const asset of manifest.assets) {
|
|
235
|
+
const file = await copyAsset(asset, outDir);
|
|
236
|
+
assets.push({ ...asset, file });
|
|
237
|
+
}
|
|
238
|
+
const builtManifest: MoonshineManifest = { ...manifest, assets };
|
|
239
|
+
await writeFile(
|
|
240
|
+
resolve(outDir, "manifest.json"),
|
|
241
|
+
JSON.stringify(builtManifest, null, 2) + "\n",
|
|
242
|
+
);
|
|
243
|
+
const entry = `import { createServer } from "node:http";
|
|
244
|
+
import { createRequestHandler } from "@tschk/moonshine-server";
|
|
245
|
+
import { reactRenderer } from "@tschk/moonshine-react";
|
|
246
|
+
import { createNodeHandler } from "@tschk/moonshine-deploy-node";
|
|
247
|
+
import { resolve } from "node:path";
|
|
248
|
+
import manifest from "./manifest.json" with { type: "json" };
|
|
249
|
+
import { modules } from "./dist/server.js";
|
|
250
|
+
|
|
251
|
+
const projectDir = resolve(import.meta.dir, "..");
|
|
252
|
+
const abs = (p) => (p ? resolve(projectDir, p) : undefined);
|
|
253
|
+
const resolvedManifest = {
|
|
254
|
+
...manifest,
|
|
255
|
+
routes: manifest.routes.map((r) => ({
|
|
256
|
+
...r,
|
|
257
|
+
file: resolve(projectDir, r.file),
|
|
258
|
+
dataFile: abs(r.dataFile),
|
|
259
|
+
layouts: r.layouts?.map(abs).filter(Boolean),
|
|
260
|
+
middleware: r.middleware?.map(abs).filter(Boolean),
|
|
261
|
+
errorBoundary: abs(r.errorBoundary),
|
|
262
|
+
})),
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const fetch = createRequestHandler({
|
|
266
|
+
manifest: resolvedManifest,
|
|
267
|
+
modules,
|
|
268
|
+
renderer: reactRenderer,
|
|
269
|
+
staticDir: import.meta.dir,
|
|
270
|
+
});
|
|
271
|
+
const server = createServer(createNodeHandler({ fetch, staticDir: import.meta.dir }));
|
|
272
|
+
server.listen(Number(process.env.PORT) || 0, () => {
|
|
273
|
+
const address = server.address();
|
|
274
|
+
const port = typeof address === "object" && address ? address.port : 0;
|
|
275
|
+
console.log("http://localhost:" + port + "/");
|
|
276
|
+
});
|
|
277
|
+
`;
|
|
278
|
+
await writeFile(resolve(outDir, "server.ts"), entry);
|
|
279
|
+
},
|
|
280
|
+
};
|