svelte-adapter-uws 0.1.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.
- package/LICENSE +21 -0
- package/README.md +1543 -0
- package/client.d.ts +356 -0
- package/client.js +571 -0
- package/files/cookies.js +25 -0
- package/files/env.js +41 -0
- package/files/handler.js +898 -0
- package/files/index.js +116 -0
- package/files/shims.js +21 -0
- package/files/utils.js +136 -0
- package/index.d.ts +396 -0
- package/index.js +224 -0
- package/package.json +81 -0
- package/vite.d.ts +48 -0
- package/vite.js +310 -0
package/files/handler.js
ADDED
|
@@ -0,0 +1,898 @@
|
|
|
1
|
+
import 'SHIMS';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import { Readable } from 'node:stream';
|
|
7
|
+
import uWS from 'uWebSockets.js';
|
|
8
|
+
import { Server } from 'SERVER';
|
|
9
|
+
import { manifest, prerendered, base } from 'MANIFEST';
|
|
10
|
+
import { env } from 'ENV';
|
|
11
|
+
import * as wsModule from 'WS_HANDLER';
|
|
12
|
+
import { parseCookies } from './cookies.js';
|
|
13
|
+
import { mimeLookup, splitCookiesString, parse_as_bytes, parse_origin } from './utils.js';
|
|
14
|
+
|
|
15
|
+
/* global ENV_PREFIX */
|
|
16
|
+
/* global PRECOMPRESS */
|
|
17
|
+
/* global WS_ENABLED */
|
|
18
|
+
/* global WS_PATH */
|
|
19
|
+
/* global WS_OPTIONS */
|
|
20
|
+
/* global HEALTH_CHECK_PATH */
|
|
21
|
+
|
|
22
|
+
class PayloadTooLargeError extends Error {
|
|
23
|
+
constructor() { super('Payload too large'); }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ── In-memory static file cache ─────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @typedef {{
|
|
30
|
+
* buffer: Buffer,
|
|
31
|
+
* contentType: string,
|
|
32
|
+
* etag: string,
|
|
33
|
+
* headers: [string, string][],
|
|
34
|
+
* brBuffer?: Buffer,
|
|
35
|
+
* gzBuffer?: Buffer
|
|
36
|
+
* }} StaticEntry
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/** @type {Map<string, StaticEntry>} */
|
|
40
|
+
const staticCache = new Map();
|
|
41
|
+
|
|
42
|
+
const textDecoder = new TextDecoder();
|
|
43
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Recursively walk a directory and call fn for each file.
|
|
47
|
+
* @param {string} dir
|
|
48
|
+
* @param {(relPath: string, absPath: string) => void} fn
|
|
49
|
+
* @param {string} prefix
|
|
50
|
+
*/
|
|
51
|
+
function walk(dir, fn, prefix = '') {
|
|
52
|
+
if (!fs.existsSync(dir)) return;
|
|
53
|
+
for (const entry of fs.readdirSync(dir)) {
|
|
54
|
+
const abs = path.join(dir, entry);
|
|
55
|
+
const rel = prefix ? `${prefix}/${entry}` : entry;
|
|
56
|
+
if (fs.statSync(abs).isDirectory()) {
|
|
57
|
+
walk(abs, fn, rel);
|
|
58
|
+
} else {
|
|
59
|
+
fn(rel, abs);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Load a directory into the static cache.
|
|
66
|
+
* @param {string} dir
|
|
67
|
+
* @param {string} urlPrefix
|
|
68
|
+
* @param {boolean} immutable
|
|
69
|
+
*/
|
|
70
|
+
function cacheDir(dir, urlPrefix, immutable) {
|
|
71
|
+
walk(dir, (relPath, absPath) => {
|
|
72
|
+
if (relPath.endsWith('.br') || relPath.endsWith('.gz')) return;
|
|
73
|
+
|
|
74
|
+
const urlPath = `${urlPrefix}/${relPath}`;
|
|
75
|
+
const contentType = mimeLookup(relPath);
|
|
76
|
+
const buffer = fs.readFileSync(absPath);
|
|
77
|
+
|
|
78
|
+
/** @type {[string, string][]} */
|
|
79
|
+
const headers = [];
|
|
80
|
+
let etag = '';
|
|
81
|
+
if (immutable && relPath.startsWith(`${manifest.appPath}/immutable/`)) {
|
|
82
|
+
headers.push(['cache-control', 'public, max-age=31536000, immutable']);
|
|
83
|
+
} else {
|
|
84
|
+
etag = `W/"${createHash('md5').update(buffer).digest('hex').slice(0, 12)}"`;
|
|
85
|
+
headers.push(['cache-control', 'no-cache'], ['etag', etag]);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** @type {StaticEntry} */
|
|
89
|
+
const entry = { buffer, contentType, etag, headers };
|
|
90
|
+
|
|
91
|
+
if (PRECOMPRESS) {
|
|
92
|
+
const brPath = absPath + '.br';
|
|
93
|
+
const gzPath = absPath + '.gz';
|
|
94
|
+
if (fs.existsSync(brPath)) entry.brBuffer = fs.readFileSync(brPath);
|
|
95
|
+
if (fs.existsSync(gzPath)) entry.gzBuffer = fs.readFileSync(gzPath);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
staticCache.set(urlPath, entry);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const clientDir = path.join(__dirname, 'client');
|
|
103
|
+
const prerenderedDir = path.join(__dirname, 'prerendered');
|
|
104
|
+
|
|
105
|
+
cacheDir(path.join(clientDir, base), base, true);
|
|
106
|
+
cacheDir(path.join(prerenderedDir, base), base, false);
|
|
107
|
+
|
|
108
|
+
// ── TLS config (must be before origin warning) ──────────────────────────────
|
|
109
|
+
|
|
110
|
+
const ssl_cert = env('SSL_CERT', '');
|
|
111
|
+
const ssl_key = env('SSL_KEY', '');
|
|
112
|
+
const is_tls = !!(ssl_cert && ssl_key);
|
|
113
|
+
|
|
114
|
+
if ((ssl_cert || ssl_key) && !is_tls) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
'Incomplete TLS config: both SSL_CERT and SSL_KEY must be set.\n' +
|
|
117
|
+
` SSL_CERT: ${ssl_cert ? 'set' : 'missing'}\n` +
|
|
118
|
+
` SSL_KEY: ${ssl_key ? 'set' : 'missing'}`
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── SvelteKit Server ────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
const origin = parse_origin(env('ORIGIN', undefined));
|
|
125
|
+
const xff_depth = parseInt(env('XFF_DEPTH', '1'), 10);
|
|
126
|
+
const address_header = env('ADDRESS_HEADER', '').toLowerCase();
|
|
127
|
+
const protocol_header = env('PROTOCOL_HEADER', '').toLowerCase();
|
|
128
|
+
const host_header = env('HOST_HEADER', '').toLowerCase();
|
|
129
|
+
const port_header = env('PORT_HEADER', '').toLowerCase();
|
|
130
|
+
const body_size_limit = parse_as_bytes(env('BODY_SIZE_LIMIT', '512K'));
|
|
131
|
+
|
|
132
|
+
if (isNaN(body_size_limit)) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`Invalid BODY_SIZE_LIMIT: '${env('BODY_SIZE_LIMIT')}'. Please provide a numeric value.`
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!origin && !host_header && !protocol_header && !is_tls) {
|
|
139
|
+
console.warn(
|
|
140
|
+
'Warning: No ORIGIN, HOST_HEADER, or PROTOCOL_HEADER configured. ' +
|
|
141
|
+
'The server will use http:// with the request Host header. ' +
|
|
142
|
+
'For production, either:\n' +
|
|
143
|
+
' SSL_CERT + SSL_KEY for native TLS (no proxy needed)\n' +
|
|
144
|
+
' ORIGIN=https://example.com (behind a TLS proxy)\n' +
|
|
145
|
+
' PROTOCOL_HEADER=x-forwarded-proto + HOST_HEADER=x-forwarded-host (flexible proxy)'
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const asset_dir = `${__dirname}/client${base}`;
|
|
150
|
+
|
|
151
|
+
const server = new Server(manifest);
|
|
152
|
+
await server.init({
|
|
153
|
+
env: /** @type {Record<string, string>} */ (process.env),
|
|
154
|
+
read: (file) => /** @type {ReadableStream} */ (Readable.toWeb(fs.createReadStream(`${asset_dir}/${file}`)))
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// ── uWS App ─────────────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
const app = is_tls
|
|
160
|
+
? uWS.SSLApp({ cert_file_name: ssl_cert, key_file_name: ssl_key })
|
|
161
|
+
: uWS.App();
|
|
162
|
+
|
|
163
|
+
// ── Platform (exposed to SvelteKit via event.platform) ──────────────────────
|
|
164
|
+
|
|
165
|
+
/** @type {Set<import('uWebSockets.js').WebSocket<any>>} */
|
|
166
|
+
const wsConnections = new Set();
|
|
167
|
+
|
|
168
|
+
/** @type {import('./index.js').Platform} */
|
|
169
|
+
const platform = {
|
|
170
|
+
/**
|
|
171
|
+
* Publish a message to all WebSocket clients subscribed to a topic.
|
|
172
|
+
* Auto-wraps in a { topic, event, data } envelope that the client store understands.
|
|
173
|
+
* No-op if no clients are subscribed - safe to call unconditionally.
|
|
174
|
+
*/
|
|
175
|
+
publish(topic, event, data) {
|
|
176
|
+
return app.publish(topic, JSON.stringify({ topic, event, data }), false, false);
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Send a message to a single WebSocket connection.
|
|
181
|
+
* Wraps in the same { topic, event, data } envelope as publish().
|
|
182
|
+
*/
|
|
183
|
+
send(ws, topic, event, data) {
|
|
184
|
+
return ws.send(JSON.stringify({ topic, event, data }), false, false);
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Send a message to connections matching a filter.
|
|
189
|
+
* The filter receives each connection's userData (from the upgrade handler).
|
|
190
|
+
* Returns the number of connections the message was sent to.
|
|
191
|
+
*/
|
|
192
|
+
sendTo(filter, topic, event, data) {
|
|
193
|
+
const envelope = JSON.stringify({ topic, event, data });
|
|
194
|
+
let count = 0;
|
|
195
|
+
for (const ws of wsConnections) {
|
|
196
|
+
if (filter(ws.getUserData())) {
|
|
197
|
+
ws.send(envelope, false, false);
|
|
198
|
+
count++;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return count;
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Number of active WebSocket connections.
|
|
206
|
+
*/
|
|
207
|
+
get connections() {
|
|
208
|
+
return wsConnections.size;
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Number of clients subscribed to a specific topic.
|
|
213
|
+
*/
|
|
214
|
+
subscribers(topic) {
|
|
215
|
+
return app.numSubscribers(topic);
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Get a scoped helper for a topic - less repetition when publishing
|
|
220
|
+
* multiple events to the same topic.
|
|
221
|
+
*/
|
|
222
|
+
topic(name) {
|
|
223
|
+
return {
|
|
224
|
+
publish: (/** @type {string} */ event, /** @type {unknown} */ data) => {
|
|
225
|
+
platform.publish(name, event, data);
|
|
226
|
+
},
|
|
227
|
+
created: (/** @type {unknown} */ data) => platform.publish(name, 'created', data),
|
|
228
|
+
updated: (/** @type {unknown} */ data) => platform.publish(name, 'updated', data),
|
|
229
|
+
deleted: (/** @type {unknown} */ data) => platform.publish(name, 'deleted', data),
|
|
230
|
+
set: (/** @type {number} */ value) => platform.publish(name, 'set', value),
|
|
231
|
+
increment: (/** @type {number} */ amount = 1) => platform.publish(name, 'increment', amount),
|
|
232
|
+
decrement: (/** @type {number} */ amount = 1) => platform.publish(name, 'decrement', amount)
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
// ── Origin construction ─────────────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Construct the origin from request headers.
|
|
241
|
+
*
|
|
242
|
+
* WARNING: PROTOCOL_HEADER / HOST_HEADER / PORT_HEADER are trusted as-is.
|
|
243
|
+
* Only use these behind a trusted reverse proxy that overwrites the headers.
|
|
244
|
+
* Never expose them when the adapter is directly internet-facing.
|
|
245
|
+
*
|
|
246
|
+
* @param {Record<string, string>} headers
|
|
247
|
+
* @returns {string}
|
|
248
|
+
*/
|
|
249
|
+
function get_origin(headers) {
|
|
250
|
+
// Default protocol matches the app type: 'https' for SSLApp, 'http' for App.
|
|
251
|
+
const default_protocol = is_tls ? 'https' : 'http';
|
|
252
|
+
const protocol = protocol_header
|
|
253
|
+
? decodeURIComponent(headers[protocol_header] || default_protocol)
|
|
254
|
+
: default_protocol;
|
|
255
|
+
|
|
256
|
+
if (protocol !== 'http' && protocol !== 'https') {
|
|
257
|
+
throw new Error(
|
|
258
|
+
`The ${protocol_header} header specified '${protocol}' which is not a valid protocol. Only 'http' and 'https' are supported.`
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const host = (host_header && headers[host_header]) || headers['host'];
|
|
263
|
+
if (!host) {
|
|
264
|
+
throw new Error('Could not determine host. The request must have a host header.');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const port = port_header ? headers[port_header] : undefined;
|
|
268
|
+
if (port && isNaN(+port)) {
|
|
269
|
+
throw new Error(
|
|
270
|
+
`The ${port_header} header specified ${port} which is an invalid port.`
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Strip existing port from host before appending PORT_HEADER value
|
|
275
|
+
// (the Host header often includes the port, e.g. "example.com:3000")
|
|
276
|
+
const hostWithoutPort = port ? host.replace(/:\d+$/, '') : host;
|
|
277
|
+
|
|
278
|
+
return port ? `${protocol}://${hostWithoutPort}:${port}` : `${protocol}://${host}`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ── Body reading ────────────────────────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* @param {import('uWebSockets.js').HttpResponse} res
|
|
285
|
+
* @param {number} limit
|
|
286
|
+
* @param {AbortSignal} signal - Aborted when the client disconnects
|
|
287
|
+
* @returns {ReadableStream<Uint8Array>}
|
|
288
|
+
*/
|
|
289
|
+
function readBody(res, limit, signal) {
|
|
290
|
+
return new ReadableStream({
|
|
291
|
+
start(controller) {
|
|
292
|
+
if (signal.aborted) {
|
|
293
|
+
controller.error(new Error('Request aborted'));
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
signal.addEventListener('abort', () => {
|
|
297
|
+
try { controller.error(new Error('Request aborted')); } catch { /* already closed */ }
|
|
298
|
+
}, { once: true });
|
|
299
|
+
|
|
300
|
+
let size = 0;
|
|
301
|
+
let done = false;
|
|
302
|
+
res.onData((chunk, isLast) => {
|
|
303
|
+
if (done) return;
|
|
304
|
+
// MUST copy - uWS reuses the ArrayBuffer after callback returns
|
|
305
|
+
const copy = Buffer.from(chunk.slice(0));
|
|
306
|
+
size += copy.byteLength;
|
|
307
|
+
if (limit !== Infinity && size > limit) {
|
|
308
|
+
done = true;
|
|
309
|
+
controller.error(new PayloadTooLargeError());
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
controller.enqueue(copy);
|
|
313
|
+
if (isLast) {
|
|
314
|
+
done = true;
|
|
315
|
+
controller.close();
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ── Static file serving ─────────────────────────────────────────────────────
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* @param {import('uWebSockets.js').HttpResponse} res
|
|
326
|
+
* @param {StaticEntry} entry
|
|
327
|
+
* @param {string} acceptEncoding
|
|
328
|
+
* @param {string} ifNoneMatch
|
|
329
|
+
*/
|
|
330
|
+
function serveStatic(res, entry, acceptEncoding, ifNoneMatch) {
|
|
331
|
+
if (entry.etag && ifNoneMatch === entry.etag) {
|
|
332
|
+
res.cork(() => {
|
|
333
|
+
res.writeStatus('304 Not Modified').end();
|
|
334
|
+
});
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
res.cork(() => {
|
|
339
|
+
let body = entry.buffer;
|
|
340
|
+
if (entry.brBuffer && acceptEncoding.includes('br')) {
|
|
341
|
+
res.writeHeader('content-encoding', 'br');
|
|
342
|
+
body = entry.brBuffer;
|
|
343
|
+
} else if (entry.gzBuffer && acceptEncoding.includes('gzip')) {
|
|
344
|
+
res.writeHeader('content-encoding', 'gzip');
|
|
345
|
+
body = entry.gzBuffer;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (entry.brBuffer || entry.gzBuffer) {
|
|
349
|
+
res.writeHeader('vary', 'Accept-Encoding');
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
res.writeStatus('200 OK');
|
|
353
|
+
res.writeHeader('content-type', entry.contentType);
|
|
354
|
+
res.writeHeader('content-length', String(body.byteLength));
|
|
355
|
+
// Pre-computed [key, value] tuples - no Object.entries() allocation per request
|
|
356
|
+
for (let i = 0; i < entry.headers.length; i++) {
|
|
357
|
+
res.writeHeader(entry.headers[i][0], entry.headers[i][1]);
|
|
358
|
+
}
|
|
359
|
+
res.end(body);
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ── Prerendered page check ──────────────────────────────────────────────────
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* @param {import('uWebSockets.js').HttpResponse} res
|
|
367
|
+
* @param {string} pathname
|
|
368
|
+
* @param {string} search
|
|
369
|
+
* @param {string} acceptEncoding
|
|
370
|
+
* @param {string} ifNoneMatch
|
|
371
|
+
* @returns {boolean}
|
|
372
|
+
*/
|
|
373
|
+
function tryPrerendered(res, pathname, search, acceptEncoding, ifNoneMatch) {
|
|
374
|
+
// Fast path: skip decodeURIComponent when there are no encoded characters
|
|
375
|
+
const needsDecode = pathname.includes('%');
|
|
376
|
+
let decoded;
|
|
377
|
+
if (needsDecode) {
|
|
378
|
+
try {
|
|
379
|
+
decoded = decodeURIComponent(pathname);
|
|
380
|
+
} catch {
|
|
381
|
+
res.cork(() => {
|
|
382
|
+
res.writeStatus('400 Bad Request');
|
|
383
|
+
res.writeHeader('content-type', 'text/plain');
|
|
384
|
+
res.end('Bad Request');
|
|
385
|
+
});
|
|
386
|
+
return true;
|
|
387
|
+
}
|
|
388
|
+
} else {
|
|
389
|
+
decoded = pathname;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (prerendered.has(decoded)) {
|
|
393
|
+
const entry = staticCache.get(decoded);
|
|
394
|
+
if (entry) {
|
|
395
|
+
serveStatic(res, entry, acceptEncoding, ifNoneMatch);
|
|
396
|
+
return true;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const alt = decoded.endsWith('/') ? decoded.slice(0, -1) : decoded + '/';
|
|
401
|
+
if (prerendered.has(alt)) {
|
|
402
|
+
const location = alt + search;
|
|
403
|
+
res.cork(() => {
|
|
404
|
+
res.writeStatus('308 Permanent Redirect');
|
|
405
|
+
res.writeHeader('location', location);
|
|
406
|
+
res.end();
|
|
407
|
+
});
|
|
408
|
+
return true;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return false;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// ── SSR handler ─────────────────────────────────────────────────────────────
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* @param {import('uWebSockets.js').HttpResponse} res
|
|
418
|
+
* @param {string} method
|
|
419
|
+
* @param {string} url
|
|
420
|
+
* @param {Record<string, string>} headers
|
|
421
|
+
* @param {string} remoteAddress - Client IP address
|
|
422
|
+
* @param {{ aborted: boolean }} state
|
|
423
|
+
* @param {AbortSignal} abortSignal
|
|
424
|
+
*/
|
|
425
|
+
async function handleSSR(res, method, url, headers, remoteAddress, state, abortSignal) {
|
|
426
|
+
try {
|
|
427
|
+
const base_origin = origin || get_origin(headers);
|
|
428
|
+
|
|
429
|
+
// Reject oversized bodies early when Content-Length is known
|
|
430
|
+
if (method !== 'GET' && method !== 'HEAD' && body_size_limit !== Infinity) {
|
|
431
|
+
const contentLength = parseInt(headers['content-length'], 10);
|
|
432
|
+
if (contentLength > body_size_limit) {
|
|
433
|
+
res.cork(() => {
|
|
434
|
+
res.writeStatus('413 Content Too Large');
|
|
435
|
+
res.writeHeader('content-type', 'text/plain');
|
|
436
|
+
res.end('Content Too Large');
|
|
437
|
+
});
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const body =
|
|
443
|
+
method === 'GET' || method === 'HEAD'
|
|
444
|
+
? undefined
|
|
445
|
+
: readBody(res, body_size_limit, abortSignal);
|
|
446
|
+
|
|
447
|
+
const request = new Request(base_origin + url, {
|
|
448
|
+
method,
|
|
449
|
+
headers: Object.entries(headers),
|
|
450
|
+
body,
|
|
451
|
+
// @ts-expect-error
|
|
452
|
+
duplex: 'half'
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
const response = await server.respond(request, {
|
|
456
|
+
platform,
|
|
457
|
+
getClientAddress: () => {
|
|
458
|
+
if (address_header) {
|
|
459
|
+
if (!(address_header in headers)) {
|
|
460
|
+
throw new Error(
|
|
461
|
+
`Address header was specified with ${ENV_PREFIX + 'ADDRESS_HEADER'}=${address_header} but is absent from request`
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const value = headers[address_header] || '';
|
|
466
|
+
|
|
467
|
+
if (address_header === 'x-forwarded-for') {
|
|
468
|
+
const addresses = value.split(',');
|
|
469
|
+
|
|
470
|
+
if (xff_depth < 1) {
|
|
471
|
+
throw new Error(`${ENV_PREFIX + 'XFF_DEPTH'} must be a positive integer`);
|
|
472
|
+
}
|
|
473
|
+
if (xff_depth > addresses.length) {
|
|
474
|
+
throw new Error(
|
|
475
|
+
`${ENV_PREFIX + 'XFF_DEPTH'} is ${xff_depth}, but only found ${addresses.length} addresses`
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
return addresses[addresses.length - xff_depth].trim();
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return value;
|
|
482
|
+
}
|
|
483
|
+
return remoteAddress;
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
if (state.aborted) return;
|
|
488
|
+
await writeResponse(res, response, state);
|
|
489
|
+
} catch (err) {
|
|
490
|
+
if (state.aborted) return;
|
|
491
|
+
if (err instanceof PayloadTooLargeError) {
|
|
492
|
+
res.cork(() => {
|
|
493
|
+
res.writeStatus('413 Content Too Large');
|
|
494
|
+
res.writeHeader('content-type', 'text/plain');
|
|
495
|
+
res.end('Content Too Large');
|
|
496
|
+
});
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
console.error('SSR error:', err);
|
|
500
|
+
res.cork(() => {
|
|
501
|
+
res.writeStatus('500 Internal Server Error');
|
|
502
|
+
res.writeHeader('content-type', 'text/plain');
|
|
503
|
+
res.end('Internal Server Error');
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// ── Response writer (with backpressure) ─────────────────────────────────────
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Write response headers inside a cork.
|
|
512
|
+
* @param {import('uWebSockets.js').HttpResponse} res
|
|
513
|
+
* @param {Response} response
|
|
514
|
+
*/
|
|
515
|
+
function writeHeaders(res, response) {
|
|
516
|
+
res.writeStatus(String(response.status));
|
|
517
|
+
for (const [key, value] of response.headers) {
|
|
518
|
+
if (key === 'set-cookie') {
|
|
519
|
+
for (const cookie of splitCookiesString(
|
|
520
|
+
/** @type {string} */ (response.headers.get(key))
|
|
521
|
+
)) {
|
|
522
|
+
res.writeHeader(key, cookie);
|
|
523
|
+
}
|
|
524
|
+
} else {
|
|
525
|
+
res.writeHeader(key, value);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* @param {import('uWebSockets.js').HttpResponse} res
|
|
532
|
+
* @param {Response} response
|
|
533
|
+
* @param {{ aborted: boolean }} state
|
|
534
|
+
*/
|
|
535
|
+
async function writeResponse(res, response, state) {
|
|
536
|
+
// No body - write headers + end in a single cork (one syscall)
|
|
537
|
+
if (!response.body) {
|
|
538
|
+
res.cork(() => {
|
|
539
|
+
writeHeaders(res, response);
|
|
540
|
+
res.end();
|
|
541
|
+
});
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (response.body.locked) {
|
|
546
|
+
res.cork(() => {
|
|
547
|
+
res.writeStatus('500 Internal Server Error');
|
|
548
|
+
res.writeHeader('content-type', 'text/plain');
|
|
549
|
+
res.end(
|
|
550
|
+
'Fatal error: Response body is locked. ' +
|
|
551
|
+
"This can happen when the response was already read (for example through 'response.json()' or 'response.text()')."
|
|
552
|
+
);
|
|
553
|
+
});
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const reader = response.body.getReader();
|
|
558
|
+
try {
|
|
559
|
+
// Read first chunk - if it's also the last, write headers + body in one cork
|
|
560
|
+
const first = await reader.read();
|
|
561
|
+
if (first.done || state.aborted) {
|
|
562
|
+
if (!state.aborted) res.cork(() => { writeHeaders(res, response); res.end(); });
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const second = await reader.read();
|
|
567
|
+
if (second.done || state.aborted) {
|
|
568
|
+
// Single-chunk response (common for SSR) - one cork, one syscall
|
|
569
|
+
if (!state.aborted) {
|
|
570
|
+
res.cork(() => {
|
|
571
|
+
writeHeaders(res, response);
|
|
572
|
+
res.end(first.value);
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Multi-chunk streaming response - write headers + first two chunks in one cork
|
|
579
|
+
res.cork(() => {
|
|
580
|
+
writeHeaders(res, response);
|
|
581
|
+
res.write(first.value);
|
|
582
|
+
res.write(second.value);
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
// Stream remaining chunks with backpressure
|
|
586
|
+
for (;;) {
|
|
587
|
+
const { done, value } = await reader.read();
|
|
588
|
+
if (done || state.aborted) break;
|
|
589
|
+
|
|
590
|
+
const ok = res.write(value);
|
|
591
|
+
if (!ok) {
|
|
592
|
+
await new Promise((resolve) =>
|
|
593
|
+
res.onWritable(() => { resolve(undefined); return true; })
|
|
594
|
+
);
|
|
595
|
+
if (state.aborted) break;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
} finally {
|
|
599
|
+
if (!state.aborted) res.cork(() => res.end());
|
|
600
|
+
reader.cancel().catch(() => {});
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// ── Main request handler ────────────────────────────────────────────────────
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* @param {import('uWebSockets.js').HttpResponse} res
|
|
608
|
+
* @param {import('uWebSockets.js').HttpRequest} req
|
|
609
|
+
*/
|
|
610
|
+
function handleRequest(res, req) {
|
|
611
|
+
// ═══ SYNCHRONOUS PHASE ═══
|
|
612
|
+
// uWS HttpRequest is stack-allocated - MUST read everything before any await.
|
|
613
|
+
// uWS returns lowercase method; we use lowercase comparisons on the fast path
|
|
614
|
+
// and only toUpperCase() for SSR where the Request constructor expects it.
|
|
615
|
+
const method = req.getMethod();
|
|
616
|
+
const pathname = req.getUrl();
|
|
617
|
+
|
|
618
|
+
// ═══ STATIC FILE FAST PATH ═══
|
|
619
|
+
// Minimum work: 1 Map lookup + 2 header reads. No header collection,
|
|
620
|
+
// no query string handling, no remoteAddress decode, no toUpperCase().
|
|
621
|
+
const staticFile = staticCache.get(pathname);
|
|
622
|
+
if (staticFile && (method === 'get' || method === 'head')) {
|
|
623
|
+
return serveStatic(
|
|
624
|
+
res, staticFile,
|
|
625
|
+
req.getHeader('accept-encoding'),
|
|
626
|
+
req.getHeader('if-none-match')
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Build full URL only for SSR - static files never reach here
|
|
631
|
+
const query = req.getQuery();
|
|
632
|
+
const METHOD = method.toUpperCase();
|
|
633
|
+
|
|
634
|
+
// ═══ PRERENDERED CHECK ═══
|
|
635
|
+
// Lightweight: only 2 header reads, no full collection, no remoteAddress decode
|
|
636
|
+
if (METHOD === 'GET') {
|
|
637
|
+
if (tryPrerendered(res, pathname, query ? `?${query}` : '',
|
|
638
|
+
req.getHeader('accept-encoding'), req.getHeader('if-none-match'))) {
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const url = query ? `${pathname}?${query}` : pathname;
|
|
644
|
+
|
|
645
|
+
// Full header collection - only for SSR paths
|
|
646
|
+
/** @type {Record<string, string>} */
|
|
647
|
+
const headers = {};
|
|
648
|
+
req.forEach((key, value) => {
|
|
649
|
+
headers[key] = value;
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
// Decode remote address eagerly - uWS may reuse the underlying buffer
|
|
653
|
+
const remoteAddress = textDecoder.decode(res.getRemoteAddressAsText());
|
|
654
|
+
|
|
655
|
+
// Set onAborted BEFORE any async work (mandatory uWS pattern)
|
|
656
|
+
const abortController = new AbortController();
|
|
657
|
+
const state = { aborted: false };
|
|
658
|
+
res.onAborted(() => {
|
|
659
|
+
state.aborted = true;
|
|
660
|
+
abortController.abort();
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
// ═══ ASYNC PHASE: SSR ═══
|
|
664
|
+
inFlightCount++;
|
|
665
|
+
handleSSR(res, METHOD, url, headers, remoteAddress, state, abortController.signal)
|
|
666
|
+
.finally(requestDone);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// ── WebSocket support ───────────────────────────────────────────────────────
|
|
670
|
+
|
|
671
|
+
// WS_ENABLED is set by the adapter at build time - no inference from exports needed
|
|
672
|
+
if (WS_ENABLED) {
|
|
673
|
+
// Warn about unrecognized exports - catches typos like "mesage" or "opn"
|
|
674
|
+
const knownWsExports = new Set(['open', 'message', 'upgrade', 'close', 'drain', 'subscribe']);
|
|
675
|
+
for (const name of Object.keys(wsModule)) {
|
|
676
|
+
if (!knownWsExports.has(name)) {
|
|
677
|
+
console.warn(
|
|
678
|
+
`Warning: WebSocket handler exports unknown "${name}". ` +
|
|
679
|
+
`Did you mean one of: ${[...knownWsExports].join(', ')}?`
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const wsOptions = WS_OPTIONS;
|
|
685
|
+
const allowedOrigins = wsOptions.allowedOrigins || 'same-origin';
|
|
686
|
+
|
|
687
|
+
app.ws(WS_PATH, {
|
|
688
|
+
// Handle HTTP → WebSocket upgrade with user-provided auth
|
|
689
|
+
upgrade: (res, req, context) => {
|
|
690
|
+
// Read everything synchronously - uWS req is stack-allocated
|
|
691
|
+
/** @type {Record<string, string>} */
|
|
692
|
+
const headers = {};
|
|
693
|
+
req.forEach((key, value) => {
|
|
694
|
+
headers[key] = value;
|
|
695
|
+
});
|
|
696
|
+
const secKey = req.getHeader('sec-websocket-key');
|
|
697
|
+
const secProtocol = req.getHeader('sec-websocket-protocol');
|
|
698
|
+
const secExtensions = req.getHeader('sec-websocket-extensions');
|
|
699
|
+
|
|
700
|
+
// Origin validation - reject cross-origin WebSocket connections.
|
|
701
|
+
// Non-browser clients (no Origin header) are always allowed.
|
|
702
|
+
const reqOrigin = headers['origin'];
|
|
703
|
+
if (reqOrigin && allowedOrigins !== '*') {
|
|
704
|
+
let allowed = false;
|
|
705
|
+
if (allowedOrigins === 'same-origin') {
|
|
706
|
+
try {
|
|
707
|
+
const originHost = new URL(reqOrigin).host;
|
|
708
|
+
const requestHost = (host_header && headers[host_header]) || headers['host'];
|
|
709
|
+
allowed = !requestHost || originHost === requestHost;
|
|
710
|
+
} catch {
|
|
711
|
+
allowed = false;
|
|
712
|
+
}
|
|
713
|
+
} else if (Array.isArray(allowedOrigins)) {
|
|
714
|
+
allowed = allowedOrigins.includes(reqOrigin);
|
|
715
|
+
}
|
|
716
|
+
if (!allowed) {
|
|
717
|
+
res.cork(() => {
|
|
718
|
+
res.writeStatus('403 Forbidden');
|
|
719
|
+
res.writeHeader('content-type', 'text/plain');
|
|
720
|
+
res.end('Origin not allowed');
|
|
721
|
+
});
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// No user upgrade handler - accept synchronously (no microtask yield,
|
|
727
|
+
// no cookie parsing, no remoteAddress decode)
|
|
728
|
+
if (!wsModule.upgrade) {
|
|
729
|
+
res.cork(() => {
|
|
730
|
+
res.upgrade({}, secKey, secProtocol, secExtensions, context);
|
|
731
|
+
});
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// ── User upgrade handler path (may be async) ──
|
|
736
|
+
const url = req.getUrl();
|
|
737
|
+
const remoteAddress = textDecoder.decode(res.getRemoteAddressAsText());
|
|
738
|
+
|
|
739
|
+
let aborted = false;
|
|
740
|
+
res.onAborted(() => {
|
|
741
|
+
aborted = true;
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
const cookies = parseCookies(headers['cookie']);
|
|
745
|
+
|
|
746
|
+
Promise.resolve(wsModule.upgrade({ headers, cookies, url, remoteAddress }))
|
|
747
|
+
.then((userData) => {
|
|
748
|
+
if (aborted) return;
|
|
749
|
+
if (userData === false) {
|
|
750
|
+
res.cork(() => {
|
|
751
|
+
res.writeStatus('401 Unauthorized');
|
|
752
|
+
res.writeHeader('content-type', 'text/plain');
|
|
753
|
+
res.end('Unauthorized');
|
|
754
|
+
});
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
res.cork(() => {
|
|
758
|
+
res.upgrade(
|
|
759
|
+
userData || {},
|
|
760
|
+
secKey,
|
|
761
|
+
secProtocol,
|
|
762
|
+
secExtensions,
|
|
763
|
+
context
|
|
764
|
+
);
|
|
765
|
+
});
|
|
766
|
+
})
|
|
767
|
+
.catch((err) => {
|
|
768
|
+
console.error('WebSocket upgrade error:', err);
|
|
769
|
+
if (!aborted) {
|
|
770
|
+
res.cork(() => {
|
|
771
|
+
res.writeStatus('500 Internal Server Error');
|
|
772
|
+
res.writeHeader('content-type', 'text/plain');
|
|
773
|
+
res.end('Internal Server Error');
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
});
|
|
777
|
+
},
|
|
778
|
+
|
|
779
|
+
open: (ws) => {
|
|
780
|
+
wsConnections.add(ws);
|
|
781
|
+
wsModule.open?.(ws);
|
|
782
|
+
},
|
|
783
|
+
|
|
784
|
+
message: (ws, message, isBinary) => {
|
|
785
|
+
// Built-in: handle subscribe/unsubscribe from the client store.
|
|
786
|
+
// Only parse small text messages - sub/unsub envelopes are always < 512 bytes.
|
|
787
|
+
if (!isBinary && message.byteLength < 512) {
|
|
788
|
+
try {
|
|
789
|
+
const msg = JSON.parse(Buffer.from(message).toString());
|
|
790
|
+
if (msg.type === 'subscribe' && typeof msg.topic === 'string') {
|
|
791
|
+
// If a subscribe hook exists, let it gate access
|
|
792
|
+
if (wsModule.subscribe && wsModule.subscribe(ws, msg.topic) === false) {
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
ws.subscribe(msg.topic);
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
if (msg.type === 'unsubscribe' && typeof msg.topic === 'string') {
|
|
799
|
+
ws.unsubscribe(msg.topic);
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
} catch {
|
|
803
|
+
// Not valid JSON - fall through to user handler
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
// Delegate everything else to the user's handler (if provided)
|
|
807
|
+
wsModule.message?.(ws, message, isBinary);
|
|
808
|
+
},
|
|
809
|
+
|
|
810
|
+
drain: wsModule.drain || undefined,
|
|
811
|
+
|
|
812
|
+
close: (ws, code, message) => {
|
|
813
|
+
wsConnections.delete(ws);
|
|
814
|
+
wsModule.close?.(ws, code, message);
|
|
815
|
+
},
|
|
816
|
+
|
|
817
|
+
maxPayloadLength: wsOptions.maxPayloadLength,
|
|
818
|
+
idleTimeout: wsOptions.idleTimeout,
|
|
819
|
+
maxBackpressure: wsOptions.maxBackpressure,
|
|
820
|
+
sendPingsAutomatically: wsOptions.sendPingsAutomatically,
|
|
821
|
+
compression: typeof wsOptions.compression === 'number'
|
|
822
|
+
? wsOptions.compression
|
|
823
|
+
: wsOptions.compression
|
|
824
|
+
? uWS.SHARED_COMPRESSOR
|
|
825
|
+
: uWS.DISABLED
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
console.log(`WebSocket endpoint registered at ${WS_PATH}`);
|
|
829
|
+
if (WS_PATH !== '/ws') {
|
|
830
|
+
console.log(`Client must match: connect({ path: '${WS_PATH}' })`);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// Health check endpoint (before catch-all so it never hits SSR)
|
|
835
|
+
if (HEALTH_CHECK_PATH) {
|
|
836
|
+
app.get(HEALTH_CHECK_PATH, (res) => {
|
|
837
|
+
res.cork(() => {
|
|
838
|
+
res.writeStatus('200 OK').end('OK');
|
|
839
|
+
});
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// Register HTTP handler (after WS so the WS route takes priority)
|
|
844
|
+
app.any('/*', handleRequest);
|
|
845
|
+
|
|
846
|
+
// ── In-flight request tracking ───────────────────────────────────────────
|
|
847
|
+
|
|
848
|
+
let inFlightCount = 0;
|
|
849
|
+
/** @type {Array<() => void>} */
|
|
850
|
+
let drainResolvers = [];
|
|
851
|
+
|
|
852
|
+
function requestDone() {
|
|
853
|
+
inFlightCount--;
|
|
854
|
+
if (inFlightCount === 0 && drainResolvers.length > 0) {
|
|
855
|
+
for (const resolve of drainResolvers) resolve();
|
|
856
|
+
drainResolvers = [];
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* Returns a promise that resolves when all in-flight SSR requests have completed.
|
|
862
|
+
* @returns {Promise<void>}
|
|
863
|
+
*/
|
|
864
|
+
export function drain() {
|
|
865
|
+
if (inFlightCount === 0) return Promise.resolve();
|
|
866
|
+
return new Promise((resolve) => { drainResolvers.push(resolve); });
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// ── Exports ─────────────────────────────────────────────────────────────────
|
|
870
|
+
|
|
871
|
+
let listenSocket = null;
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* Start the uWS server.
|
|
875
|
+
* @param {string} host
|
|
876
|
+
* @param {number} port
|
|
877
|
+
*/
|
|
878
|
+
export function start(host, port) {
|
|
879
|
+
app.listen(host, port, (socket) => {
|
|
880
|
+
if (socket) {
|
|
881
|
+
listenSocket = socket;
|
|
882
|
+
console.log(`Listening on ${is_tls ? 'https' : 'http'}://${host}:${port}`);
|
|
883
|
+
} else {
|
|
884
|
+
console.error(`Failed to listen on ${host}:${port}`);
|
|
885
|
+
process.exit(1);
|
|
886
|
+
}
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/**
|
|
891
|
+
* Stop the server.
|
|
892
|
+
*/
|
|
893
|
+
export function shutdown() {
|
|
894
|
+
if (listenSocket) {
|
|
895
|
+
uWS.us_listen_socket_close(listenSocket);
|
|
896
|
+
listenSocket = null;
|
|
897
|
+
}
|
|
898
|
+
}
|