-
Notifications
You must be signed in to change notification settings - Fork 417
Expand file tree
/
Copy pathstart-server.ts
More file actions
167 lines (149 loc) · 4.64 KB
/
start-server.ts
File metadata and controls
167 lines (149 loc) · 4.64 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
import { exec as execCb } from 'child_process';
import { promisify } from 'util';
import type { PHPRequest, StreamedPHPResponse } from '@php-wasm/universal';
import type { Request, Response } from 'express';
import express from 'express';
import type { IncomingMessage, Server, ServerResponse } from 'http';
import type { AddressInfo } from 'net';
import { Readable } from 'stream';
import { pipeline } from 'stream/promises';
import type { RunCLIServer } from './run-cli';
import { logger } from '@php-wasm/logger';
const exec = promisify(execCb);
export interface ServerOptions {
port: number;
onBind: (
server: Server,
port: number
) => Promise<RunCLIServer | number | void>;
/**
* Handler for requests. Always returns StreamedPHPResponse.
*/
handleRequest: (request: PHPRequest) => Promise<StreamedPHPResponse>;
}
export function isPortInUse(port: number): Promise<boolean> {
return new Promise((resolve) => {
if (port === 0) return resolve(false);
const server = express().listen(port);
server.once('listening', () => server.close(() => resolve(false)));
server.once('error', (error: NodeJS.ErrnoException) =>
resolve(error.code === 'EADDRINUSE')
);
});
}
export async function startServer(
options: ServerOptions
): Promise<RunCLIServer | number | void> {
const app = express();
const server = await new Promise<
Server<typeof IncomingMessage, typeof ServerResponse>
>((resolve, reject) => {
const server = app
.listen(options.port, () => {
const address = server.address();
if (address === null || typeof address === 'string') {
reject(new Error('Server address is not available'));
} else {
resolve(server);
}
})
.once('error', reject);
});
app.use('/', async (req, res) => {
try {
const phpRequest: PHPRequest = {
url: req.url,
headers: parseHeaders(req),
method: req.method as any,
body: await bufferRequestBody(req),
};
const response = await options.handleRequest(phpRequest);
await handleStreamedResponse(response, res);
} catch (error) {
logger.error(error);
if (!res.headersSent) {
res.statusCode = 500;
res.end('Internal Server Error');
}
}
});
const address = server.address();
const port = (address! as AddressInfo).port;
// Codespaces ports default to private, breaking CORS.
// Publish once the tunnel is ready.
const codespaceName = process.env['CODESPACE_NAME'];
if (codespaceName) {
setCodespacesPortPublic(port, codespaceName);
}
return await options.onBind(server, port);
}
/**
* Handles a StreamedPHPResponse by piping the stdout stream directly
* to the HTTP response, avoiding buffering the entire response in memory.
*/
async function handleStreamedResponse(
streamedResponse: StreamedPHPResponse,
res: Response
): Promise<void> {
// Wait for headers to be available
const [headers, httpStatusCode] = await Promise.all([
streamedResponse.headers,
streamedResponse.httpStatusCode,
]);
// Set response headers
res.statusCode = httpStatusCode;
for (const key in headers) {
res.setHeader(key, headers[key]);
}
// Cast needed: Web ReadableStream and Node.js ReadableStream types differ
const nodeStream = Readable.fromWeb(streamedResponse.stdout as any);
try {
await pipeline(nodeStream, res);
} catch (error: unknown) {
// Ignore client-disconnect errors. These occur when the browser
// navigates away or refreshes before the response finishes:
// - ERR_STREAM_PREMATURE_CLOSE: stream was open but closed early
// - ERR_STREAM_UNABLE_TO_PIPE: stream was already destroyed
if (
error instanceof Error &&
'code' in error &&
(error.code === 'ERR_STREAM_PREMATURE_CLOSE' ||
error.code === 'ERR_STREAM_UNABLE_TO_PIPE')
) {
return;
}
throw error;
}
}
const bufferRequestBody = async (req: Request): Promise<Uint8Array> =>
await new Promise((resolve) => {
const body: Uint8Array[] = [];
req.on('data', (chunk) => {
body.push(chunk);
});
req.on('end', () => {
resolve(new Uint8Array(Buffer.concat(body)));
});
});
async function setCodespacesPortPublic(port: number, codespaceName: string) {
logger.log(`Publishing port ${port}...`);
const cmd = `gh codespace ports visibility ${port}:public -c ${codespaceName}`;
for (let i = 0; i < 10; i++) {
try {
await exec(cmd);
return;
} catch {
await new Promise((resolve) => setTimeout(resolve, 2000));
}
}
}
const parseHeaders = (req: Request): Record<string, string> => {
const requestHeaders: Record<string, string> = {};
if (req.rawHeaders && req.rawHeaders.length) {
for (let i = 0; i < req.rawHeaders.length; i += 2) {
requestHeaders[req.rawHeaders[i].toLowerCase()] =
req.rawHeaders[i + 1];
}
}
return requestHeaders;
};