Skip to content

Commit f30b2b4

Browse files
committed
Add mastro/node
1 parent ef3b045 commit f30b2b4

File tree

4 files changed

+1486
-1
lines changed

4 files changed

+1486
-1
lines changed

deno.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"./images": "./src/images.ts",
99
"./init": "./src/init.ts",
1010
"./markdown": "./src/markdown.ts",
11+
"./node": "./src/node/serve.ts",
1112
"./server": "./src/server.ts",
1213
"./reactive": "./src/reactive/reactive.ts"
1314
},

src/node/serve.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/**
2+
* This module contains a basic version of a function like `Deno.serve`,
3+
* implemented using `node:http`.
4+
* @module
5+
*/
6+
7+
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
8+
import { Http2ServerResponse } from 'node:http2';
9+
10+
// deno-lint-ignore-file no-explicit-any
11+
12+
/**
13+
* Basic version of a function like `Deno.serve`,
14+
* implemented using `node:http`. To start a server:
15+
*
16+
* ```
17+
* import { serve } from "mastro/node";
18+
* import server from "mastro/server";
19+
* serve(server.fetch);
20+
* ```
21+
*
22+
* lightly adapted from:
23+
* https://github.com/withastro/astro/blob/db8f8becc9508fa4f292d45c14af92ba59c414d1/packages/astro/src/core/app/node.ts#L55
24+
* (MIT License)
25+
*/
26+
export const serve = (
27+
handler: (r: Request) => Promise<Response>,
28+
{ port = 8000 } = {},
29+
) => {
30+
const server = createServer(async (req, res) => {
31+
const standardReq = createRequest(req);
32+
const standardRes = await handler(standardReq);
33+
await writeResponse(standardRes, res);
34+
});
35+
server.on('error', e => {
36+
console.error(e);
37+
});
38+
server.listen(port, () => console.log(`Listening on http://localhost:${port}`));
39+
}
40+
41+
/**
42+
* Streams a web-standard Response into a NodeJS Server Response.
43+
*/
44+
const writeResponse = async (standardRes: Response, res: ServerResponse): Promise<void> => {
45+
const { status, headers, body, statusText } = standardRes;
46+
// HTTP/2 doesn't support statusMessage
47+
if (!(res instanceof Http2ServerResponse)) {
48+
res.statusMessage = statusText;
49+
}
50+
res.writeHead(status, Object.fromEntries(headers.entries()));
51+
if (!body) {
52+
res.end();
53+
return;
54+
}
55+
try {
56+
const reader = body.getReader();
57+
res.on('close', () => {
58+
// Cancelling the reader may reject not just because of
59+
// an error in the ReadableStream's cancel callback, but
60+
// also because of an error anywhere in the stream.
61+
reader.cancel().catch((err) => {
62+
console.error(
63+
`There was an uncaught error in the middle of the stream while rendering ${
64+
res.req.url}.`,
65+
err,
66+
);
67+
});
68+
});
69+
let result = await reader.read();
70+
while (!result.done) {
71+
res.write(result.value);
72+
result = await reader.read();
73+
}
74+
res.end();
75+
// the error will be logged by the "on end" callback above
76+
} catch (err) {
77+
res.write('Internal server error', () => {
78+
err instanceof Error ? res.destroy(err) : res.destroy();
79+
});
80+
}
81+
}
82+
83+
/**
84+
* Converts a NodeJS IncomingMessage into a web standard Request.
85+
*/
86+
const createRequest = (req: IncomingMessage): Request => {
87+
const method = req.method || 'GET';
88+
const options: RequestInit = {
89+
method,
90+
headers: makeRequestHeaders(req),
91+
...(method === 'HEAD' || method === 'GET' ? {} : asyncIterableToBodyProps(req)),
92+
};
93+
return new Request(getUrl(req), options);
94+
}
95+
96+
const getUrl = (req: IncomingMessage): URL => {
97+
// Get the used protocol between the end client and first proxy.
98+
// NOTE: Some proxies append values with spaces and some do not.
99+
// We need to handle it here and parse the header correctly.
100+
// @example "https, http,http" => "http"
101+
const forwardedProtocol = getFirstValue(req.headers['x-forwarded-proto']);
102+
const providedProtocol = ('encrypted' in req.socket && req.socket.encrypted)
103+
? 'https'
104+
: 'http';
105+
const protocol = forwardedProtocol ?? providedProtocol;
106+
107+
// @example "example.com,www2.example.com" => "example.com"
108+
const forwardedHostname = getFirstValue(req.headers['x-forwarded-host']);
109+
const providedHostname = req.headers.host ?? req.headers[':authority'];
110+
const hostname = forwardedHostname ?? providedHostname;
111+
112+
// @example "443,8080,80" => "443"
113+
const port = getFirstValue(req.headers['x-forwarded-port']);
114+
115+
try {
116+
const hostnamePort = getHostnamePort(hostname, port);
117+
return new URL(`${protocol}://${hostnamePort}${req.url}`);
118+
} catch {
119+
// Fallback to the provided hostname and port
120+
const hostnamePort = getHostnamePort(providedHostname, port);
121+
return new URL(`${providedProtocol}://${hostnamePort}`);
122+
}
123+
}
124+
125+
// Parses multiple header and returns first value if available.
126+
const getFirstValue = (multiValueHeader?: string | string[]) =>
127+
multiValueHeader?.toString()?.split(',').map((e) => e.trim())?.[0];
128+
129+
const getHostnamePort = (
130+
hostname: string | string[] | undefined,
131+
port?: string,
132+
): string => {
133+
const portInHostname = typeof hostname === 'string' && /:\d+$/.test(hostname);
134+
return portInHostname ? hostname : `${hostname}${port ? `:${port}` : ''}`;
135+
}
136+
137+
const makeRequestHeaders = (req: IncomingMessage): Headers => {
138+
const headers = new Headers();
139+
for (const [name, value] of Object.entries(req.headers)) {
140+
if (value === undefined) {
141+
continue;
142+
}
143+
if (Array.isArray(value)) {
144+
for (const item of value) {
145+
headers.append(name, item);
146+
}
147+
} else {
148+
headers.append(name, value);
149+
}
150+
}
151+
return headers;
152+
}
153+
154+
const asyncIterableToBodyProps = (iterable: AsyncIterable<any>): RequestInit => {
155+
return {
156+
// @ts-expect-error Undici accepts a non-standard async iterable for the body.
157+
body: iterable,
158+
// The duplex property is required when using a ReadableStream or async
159+
// iterable for the body. The type definitions do not include the duplex
160+
// property because they are not up-to-date.
161+
duplex: 'half',
162+
};
163+
}

0 commit comments

Comments
 (0)