evikit 2.7.3 → 2.8.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/server.js CHANGED
@@ -1,536 +1,668 @@
1
- import { Busboy } from "@fastify/busboy";
2
- import { toJsonSchema } from "@valibot/to-json-schema";
3
- import acceptLanguage from "accept-language";
4
- import { po } from "gettext-parser";
5
- import cluster from "node:cluster";
6
- import { readFile } from "node:fs/promises";
7
- import { availableParallelism } from "node:os";
8
- import { h } from "preact";
9
- import { renderToString } from "preact-render-to-string";
10
- import {
11
- object,
12
- optional,
13
- parse,
14
- picklist,
15
- record,
16
- string,
17
- union,
18
- ValiError,
19
- } from "valibot";
20
-
21
- import { Po } from "./i18n.js";
22
-
23
- /** @param {() => () => void} callback */
24
- export function daemon(callback) {
25
- if (
26
- (process.env["npm_command"] == "start" && cluster.isPrimary) ||
27
- process.env["npm_lifecycle_event"] == "dev"
28
- ) {
29
- /** @type {any} */
30
- const self = globalThis;
31
- if (self.clearDaemon) {
32
- self.clearDaemon();
33
- }
34
- self.clearDaemon = callback();
35
- }
36
- }
37
-
38
- /** @type {import("./server-generic.js").ServerEndpoint} */
39
- export function endpoint({ input, outputs }, handler) {
40
- const arrayOf = ArrayOf(input);
41
- const schema = union(
42
- Object.entries(outputs).map(([status, schema]) =>
43
- object({
44
- contentType: optional(string()),
45
- cookies: optional(record(string(), object({ value: string() }))),
46
- headers: optional(record(string(), string())),
47
- output: schema,
48
- status: picklist([Number(status)]),
49
- }),
50
- ),
51
- );
52
- return {
53
- handler: async (req, res) => {
54
- try {
55
- /** @type {Record<string, string | File | (string | File)[]>} */
56
- const body = {};
57
- if (req.headers["content-type"]) {
58
- const busboy = new Busboy({
59
- headers: /** @type {any} */ (req.headers),
60
- });
61
- busboy.on("field", (name, value) => {
62
- body[name] =
63
- typeof body[name] == "undefined"
64
- ? value
65
- : Array.isArray(body[name])
66
- ? [...body[name], value]
67
- : [body[name], value];
68
- });
69
-
70
- if (
71
- Object.values(input.entries).some(
72
- (entry) => entry.type == "file" || arrayOf(entry, "file"),
73
- )
74
- ) {
75
- busboy.on("file", (name, stream, filename) => {
76
- const entry = input.entries[name];
77
- if (entry && (entry.type == "file" || arrayOf(entry, "file"))) {
78
- /** @type {ArrayBuffer[]} */
79
- const buffers = [];
80
- stream.on("data", (data) => {
81
- buffers.push(data);
82
- });
83
- stream.on("end", () => {
84
- const value = new File(buffers, filename);
85
- body[name] =
86
- typeof body[name] == "undefined"
87
- ? value
88
- : Array.isArray(body[name])
89
- ? [...body[name], value]
90
- : [body[name], value];
91
- });
92
- } else {
93
- stream.resume();
94
- }
95
- });
96
- }
97
-
98
- await new Promise((resolve, reject) => {
99
- busboy.on("error", reject).on("finish", resolve);
100
- req.pipe(busboy);
101
- });
102
- }
103
-
104
- const { contentType, cookies, headers, output, status } = parse(
105
- schema,
106
- await handler({
107
- input: parse(input, {
108
- ...(req.headers.cookie
109
- ? Object.fromEntries(
110
- req.headers.cookie
111
- .split("; ")
112
- .map((row) => row.split("=").map(decodeURIComponent)),
113
- )
114
- : undefined),
115
- ...Object.fromEntries(
116
- Object.entries(
117
- Object.groupBy(
118
- [
119
- ...new URL(
120
- `http://${req.headers.host}${req.url}`,
121
- ).searchParams.entries(),
122
- ],
123
- ([name]) => name || "",
124
- ),
125
- ).map(([name, group]) => {
126
- const values = (group || []).map((value) => value[1]);
127
- return [name, values.length == 1 ? values[0] : values];
128
- }),
129
- ),
130
- ...body,
131
- }),
132
- }),
133
- );
134
-
135
- const location = headers
136
- ? Object.entries(headers).find(
137
- ([name]) => name.toLowerCase() == "location",
138
- )?.[1]
139
- : undefined;
140
- const opaqueRedirectWorkaround =
141
- req.headers.accept == "application/json" && location
142
- ? { location, status }
143
- : undefined;
144
- const workaroundHeaders = headers
145
- ? Object.entries(headers).filter(([name]) => {
146
- const lower = name.toLowerCase();
147
- return (
148
- lower != "content-type" &&
149
- (lower != "location" || !opaqueRedirectWorkaround)
150
- );
151
- })
152
- : [];
153
-
154
- if (cookies) {
155
- workaroundHeaders.push([
156
- "set-cookie",
157
- Object.entries(cookies || "")
158
- .map(
159
- ([name, { value }]) =>
160
- `${encodeURIComponent(name)}=${encodeURIComponent(value)}`,
161
- )
162
- .join("; "),
163
- ]);
164
- }
165
-
166
- if (contentType == "application/json") {
167
- res.statusCode = status;
168
- res.setHeader("content-type", "application/json; charset=utf-8");
169
- for (const [name, value] of workaroundHeaders) {
170
- res.setHeader(name, value);
171
- }
172
- res.end(JSON.stringify(output));
173
- } else if (opaqueRedirectWorkaround) {
174
- res.setHeader("content-type", "application/json; charset=utf-8");
175
- for (const [name, value] of workaroundHeaders) {
176
- res.setHeader(name, value);
177
- }
178
- res.end(JSON.stringify({ opaqueRedirectWorkaround }));
179
- } else {
180
- res.statusCode = status;
181
- res.setHeader("content-type", "text/plain; charset=utf-8");
182
- for (const [name, value] of workaroundHeaders) {
183
- res.setHeader(name, value);
184
- }
185
- res.end(output);
186
- }
187
- } catch (/** @type {any} */ error) {
188
- console.error(error);
189
- res.statusCode = error instanceof ValiError ? 400 : 500;
190
- res.setHeader("content-type", "text/plain; charset=utf-8");
191
- res.end(error.message);
192
- }
193
- },
194
- input,
195
- outputs,
196
- };
197
- }
198
-
199
- /** @type {import("./server-generic.js").ServerJson} */
200
- export function json(output) {
201
- return {
202
- contentType: /** @type {any} */ ("application/json"),
203
- output,
204
- status: 200,
205
- };
206
- }
207
-
208
- /** @param {{ listen: (port: number, callback: () => void) => { close(): void, on(event: "error", callback: (error: unknown) => void ): void } }} app */
209
- export function listen(app) {
210
- const parallelism =
211
- Number(process.env["PARALLELISM"]) || availableParallelism();
212
- const port = Number(process.env["PORT"]) || 5173;
213
- if (cluster.isPrimary && parallelism > 1) {
214
- for (let i = 0; i < parallelism; i++) {
215
- cluster.fork({ i });
216
- }
217
- } else {
218
- const server = app.listen(port, () => {
219
- if (cluster.isPrimary || process.env["i"] == "0") {
220
- console.log(
221
- `Server started at ${process.env["ORIGIN"] || `http://localhost:${port}`}`,
222
- );
223
- }
224
- });
225
- server.on("error", (error) => {
226
- console.error(error);
227
- process.exitCode = 1;
228
- });
229
- process.on("SIGTERM", () => {
230
- server.close();
231
- });
232
- }
233
- }
234
-
235
- /** @param {{ html: string, prerender({ lang, url }: { lang: string, url: string }): Promise<{ data: {}, head: { elements: Set<{ props: { [k: string]: string }, type: string }>, lang: string, title: string }, html: string }> }} props */
236
- export function production({ html, prerender }) {
237
- /**
238
- * @param {import("node:http").IncomingMessage} req
239
- * @param {import("node:http").ServerResponse<import("node:http").IncomingMessage>} res
240
- */
241
- return async function onRequest(req, res) {
242
- try {
243
- const rendered = await prerender({
244
- lang: acceptLanguage.get(req.headers["accept-language"]) || "en",
245
- url: req.url || "/",
246
- });
247
- res.statusCode =
248
- // https://indieweb.org/meta_http-equiv_status
249
- Number(
250
- [...rendered.head.elements.values()]
251
- .find(
252
- ({ props, type }) =>
253
- type == "meta" && props["http-equiv"] == "status",
254
- )
255
- ?.props["content"]?.replace(/ .*/, ""),
256
- ) || 200;
257
- res.setHeader("content-type", "text/html; charset=utf-8");
258
- res.end(
259
- html
260
- .replace(/lang=".+?"/, () => `lang="${rendered.head.lang}"`)
261
- .replace(/<title>.+<\/title>/, () =>
262
- renderToString(h("title", null, rendered.head.title)),
263
- )
264
- .replace(/<!--app-head-->[\s\S]+?<body/, (match) =>
265
- match
266
- .replace(/<link[^>]+>/g, (link) =>
267
- /data-hoofd/.test(link) ? "" : link,
268
- )
269
- .replace(/<meta[^>]+>/g, ""),
270
- )
271
- .replace(
272
- `<!--app-head-->`,
273
- [...rendered.head.elements.values()]
274
- .map(
275
- ({ props, type }) =>
276
- `<${type} ` +
277
- Object.entries(props)
278
- .map(([name, value]) => `${name}="${value}"`)
279
- .join(" ") +
280
- (type == "script" ? `</${type}>` : " />"),
281
- )
282
- .join(""),
283
- )
284
- .replace(
285
- /(<div id="app">)[\s\S]+<!--app-html-->/,
286
- (_, div) =>
287
- div +
288
- rendered.html +
289
- '<script type="application/json" id="preact-prerender-data">' +
290
- JSON.stringify(rendered.data).replace(/\//g, "\\/") +
291
- "</script>",
292
- ),
293
- );
294
- } catch (error) {
295
- console.error(error);
296
- res.statusCode = 500;
297
- res.end("Internal Server Error");
298
- }
299
- };
300
- }
301
-
302
- /** @type {import("./server-generic.js").ServerRedirect} */
303
- export function redirect(location, status, init) {
304
- return {
305
- contentType: /** @type {any} */ ("text/plain"),
306
- cookies: init?.cookies,
307
- headers: { location },
308
- output: location,
309
- status,
310
- };
311
- }
312
-
313
- /**
314
- * @param {string} path
315
- * @param {import("./server-generic.js").MethodServer} controller */
316
- export function route(path, controller) {
317
- return { controller, path, pattern: new URLPattern(`http://*:*${path}`) };
318
- }
319
-
320
- /**
321
- * @param {{ pkg: { name: string, version: string } }} props
322
- * @param {ReturnType<typeof route>[]} routes
323
- */
324
- export function router({ pkg }, ...routes) {
325
- const openapi = {
326
- info: { title: pkg.name, version: pkg.version },
327
- openapi: "3.1.0",
328
- paths: Object.fromEntries(
329
- Object.values(routes).map(({ controller, path }) => [
330
- path.replace(/:(\w+)/g, "{$1}"),
331
- Object.fromEntries(
332
- Object.entries(controller).map(([method, { input, outputs }]) => {
333
- const arrayOf = ArrayOf(input);
334
- const parameters = path
335
- .split("/")
336
- .filter((component) => /^:/.test(component))
337
- .map((component) => component.slice(1));
338
- return [
339
- method,
340
- {
341
- parameters: [
342
- ...parameters.map((name) => ({
343
- in: "path",
344
- name,
345
- required: true,
346
- schema: { type: "string" },
347
- })),
348
- ...(method == "post"
349
- ? []
350
- : Object.entries(input.entries)
351
- .filter(
352
- ([name]) =>
353
- name != "lang" && !parameters.includes(name),
354
- )
355
- .map(([name, entry]) => ({
356
- in: "query",
357
- name,
358
- required: !entry.expects.includes("undefined"),
359
- schema: { type: "string" },
360
- }))),
361
- ],
362
- ...(method == "post"
363
- ? {
364
- requestBody: {
365
- content: {
366
- [Object.values(input.entries).some(
367
- (entry) =>
368
- entry.type == "file" || arrayOf(entry, "file"),
369
- )
370
- ? "multipart/form-data"
371
- : "application/x-www-form-urlencoded"]: {
372
- schema: toJsonSchema(
373
- object(
374
- Object.fromEntries(
375
- Object.entries(input.entries).filter(
376
- ([name]) =>
377
- name != "lang" &&
378
- !parameters.includes(name),
379
- ),
380
- ),
381
- ),
382
- {
383
- ignoreActions: ["trim"],
384
- overrideSchema: (context) =>
385
- context.valibotSchema.type == "file"
386
- ? { format: "binary", type: "string" }
387
- : context.jsonSchema,
388
- },
389
- ),
390
- },
391
- },
392
- },
393
- }
394
- : undefined),
395
- responses: Object.fromEntries(
396
- Object.entries(outputs).map(([status, schema]) => [
397
- status,
398
- {
399
- content: {
400
- ["application/json"]: { schema: toJsonSchema(schema) },
401
- },
402
- description:
403
- status == "200"
404
- ? "OK"
405
- : status == "302"
406
- ? "Found"
407
- : "Bad Request",
408
- },
409
- ]),
410
- ),
411
- },
412
- ];
413
- }),
414
- ),
415
- ]),
416
- ),
417
- servers: [{ url: process.env["ORIGIN"] || "" }],
418
- };
419
-
420
- Po.set(async (lang) =>
421
- po.parse(await readFile(`po/${encodeURIComponent(lang)}.po`, "utf-8")),
422
- );
423
-
424
- const poPattern = new URLPattern(`http://*:*/po/:lang`);
425
-
426
- /**
427
- * @param {import("node:http").IncomingMessage} req
428
- * @param {import("node:http").ServerResponse<import("node:http").IncomingMessage>} res
429
- * @param {() => void} [next]
430
- */
431
- async function onRequest(req, res, next) {
432
- const lowercase = req.method?.toLowerCase();
433
- const url = new URL(`http://${req.headers.host}${req.url}`);
434
-
435
- const pathname = poPattern.exec(url)?.pathname;
436
- if (pathname) {
437
- const lang = acceptLanguage.get(pathname.groups["lang"]) || "en";
438
- if (lang != "en") {
439
- res.setHeader("content-type", "application/json");
440
- res.end(JSON.stringify(await Po.get(lang)));
441
- return;
442
- }
443
- }
444
-
445
- for (const { controller, pattern } of Object.values(routes)) {
446
- const pathname = pattern.exec(url)?.pathname;
447
- if (pathname) {
448
- for (const [method, { handler }] of Object.entries(controller)) {
449
- if (method == lowercase) {
450
- const lang =
451
- acceptLanguage.get(req.headers["accept-language"]) || "en";
452
- if (!url.searchParams.has(lang)) {
453
- url.searchParams.set("lang", lang);
454
- }
455
- for (const [name, value] of Object.entries(pathname.groups)) {
456
- if (!url.searchParams.has(name)) {
457
- url.searchParams.set(name, value || "");
458
- }
459
- }
460
- req.url = url.pathname + (url.search || "");
461
- handler(req, res);
462
- return;
463
- }
464
- }
465
-
466
- if (lowercase == "options") {
467
- res.statusCode = 204;
468
- res.setHeader(
469
- "allow",
470
- [
471
- ...new Set([
472
- "OPTIONS",
473
- ...Object.keys(controller).map((x) => x.toUpperCase()),
474
- ]),
475
- ].join(", "),
476
- );
477
- res.end();
478
- return;
479
- }
480
-
481
- res.statusCode = 405;
482
- res.end("Method Not Allowed");
483
- return;
484
- }
485
- }
486
-
487
- if (req.url == "/api" || req.url?.startsWith("/api/")) {
488
- res.statusCode = 404;
489
- res.end("Not Found");
490
- return;
491
- }
492
-
493
- if (req.url == "/openapi.json") {
494
- res.setHeader("content-type", "application/json");
495
- res.end(JSON.stringify(openapi));
496
- return;
497
- }
498
-
499
- if (!next) {
500
- res.statusCode = 404;
501
- res.end("Not Found");
502
- return;
503
- }
504
-
505
- next();
506
- }
507
-
508
- return { onRequest, openapi };
509
- }
510
-
511
- /** @type {import("./server-generic.js").ServerText} */
512
- export function text(output, status) {
513
- return {
514
- contentType: /** @type {any} */ ("text/plain"),
515
- output,
516
- status,
517
- };
518
- }
519
-
520
- /** @param {import("valibot").ObjectSchema<any, any>} input */
521
- function ArrayOf(input) {
522
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
523
- const entries = Object.values(input);
524
- /**
525
- * @param {typeof entries[0]} entry
526
- * @param {string} type
527
- */
528
- return function arrayOf(entry, type) {
529
- if (entry.type != "array") {
530
- return false;
531
- }
532
- /** @type {import("valibot").ArraySchema<any, any>} */
533
- const array = /** @type {any} */ (entry);
534
- return array.item.type == type;
535
- };
536
- }
1
+ import { Busboy } from "@fastify/busboy";
2
+ import { toJsonSchema } from "@valibot/to-json-schema";
3
+ import acceptLanguage from "accept-language";
4
+ import { po } from "gettext-parser";
5
+ import cluster from "node:cluster";
6
+ import { createHash } from "node:crypto";
7
+ import { readFile } from "node:fs/promises";
8
+ import { availableParallelism } from "node:os";
9
+ import { h } from "preact";
10
+ import { renderToString } from "preact-render-to-string";
11
+ import {
12
+ object,
13
+ optional,
14
+ parse,
15
+ picklist,
16
+ record,
17
+ string,
18
+ union,
19
+ ValiError,
20
+ } from "valibot";
21
+
22
+ import { Po } from "./i18n.js";
23
+
24
+ /** @param {() => () => void} callback */
25
+ export function daemon(callback) {
26
+ if (
27
+ (process.env["npm_command"] == "start" &&
28
+ (parallelism() == 1 ? cluster.isPrimary : process.env["i"] == "0")) ||
29
+ process.env["npm_lifecycle_event"] == "dev"
30
+ ) {
31
+ /** @type {any} */
32
+ const self = globalThis;
33
+ if (self.clearDaemon) {
34
+ self.clearDaemon();
35
+ }
36
+ self.clearDaemon = callback();
37
+ }
38
+ }
39
+
40
+ /** @type {import("./server-generic.js").ServerEndpoint} */
41
+ export function endpoint({ input, outputs }, handler) {
42
+ const arrayOf = ArrayOf(input);
43
+ const schema = union(
44
+ Object.entries(outputs).map(([status, schema]) =>
45
+ object({
46
+ contentType: optional(string()),
47
+ cookies: optional(record(string(), object({ value: string() }))),
48
+ headers: optional(record(string(), string())),
49
+ output: schema,
50
+ status: picklist([Number(status)]),
51
+ }),
52
+ ),
53
+ );
54
+ return {
55
+ handler: async (req, res) => {
56
+ try {
57
+ /** @type {Record<string, string | File | (string | File)[]>} */
58
+ const body = {};
59
+ if (req.headers["content-type"]) {
60
+ const busboy = new Busboy({
61
+ headers: /** @type {any} */ (req.headers),
62
+ });
63
+ busboy.on("field", (name, value) => {
64
+ body[name] =
65
+ typeof body[name] == "undefined"
66
+ ? value
67
+ : Array.isArray(body[name])
68
+ ? [...body[name], value]
69
+ : [body[name], value];
70
+ });
71
+
72
+ if (
73
+ Object.values(input.entries).some(
74
+ (entry) => entry.type == "file" || arrayOf(entry, "file"),
75
+ )
76
+ ) {
77
+ busboy.on("file", (name, stream, filename) => {
78
+ const entry = input.entries[name];
79
+ if (entry && (entry.type == "file" || arrayOf(entry, "file"))) {
80
+ /** @type {ArrayBuffer[]} */
81
+ const buffers = [];
82
+ stream.on("data", (data) => {
83
+ buffers.push(data);
84
+ });
85
+ stream.on("end", () => {
86
+ const value = new File(buffers, filename);
87
+ body[name] =
88
+ typeof body[name] == "undefined"
89
+ ? value
90
+ : Array.isArray(body[name])
91
+ ? [...body[name], value]
92
+ : [body[name], value];
93
+ });
94
+ } else {
95
+ stream.resume();
96
+ }
97
+ });
98
+ }
99
+
100
+ await new Promise((resolve, reject) => {
101
+ busboy.on("error", reject).on("finish", resolve);
102
+ req.pipe(busboy);
103
+ });
104
+ }
105
+
106
+ const { contentType, cookies, headers, output, status } = parse(
107
+ schema,
108
+ await handler({
109
+ input: parse(input, {
110
+ ...(req.headers.cookie
111
+ ? Object.fromEntries(
112
+ req.headers.cookie
113
+ .split("; ")
114
+ .map((row) => row.split("=").map(decodeURIComponent)),
115
+ )
116
+ : undefined),
117
+ ...Object.fromEntries(
118
+ Object.entries(
119
+ Object.groupBy(
120
+ [
121
+ ...new URL(
122
+ `http://${req.headers.host}${req.url}`,
123
+ ).searchParams.entries(),
124
+ ],
125
+ ([name]) => name || "",
126
+ ),
127
+ ).map(([name, group]) => {
128
+ const values = (group || []).map((value) => value[1]);
129
+ return [name, values.length == 1 ? values[0] : values];
130
+ }),
131
+ ),
132
+ ...body,
133
+ }),
134
+ }),
135
+ );
136
+
137
+ const location = headers
138
+ ? Object.entries(headers).find(
139
+ ([name]) => name.toLowerCase() == "location",
140
+ )?.[1]
141
+ : undefined;
142
+ const opaqueRedirectWorkaround =
143
+ req.headers.accept == "application/json" && location
144
+ ? { location, status }
145
+ : undefined;
146
+ const workaroundHeaders = headers
147
+ ? Object.entries(headers).filter(([name]) => {
148
+ const lower = name.toLowerCase();
149
+ return (
150
+ lower != "content-type" &&
151
+ (lower != "location" || !opaqueRedirectWorkaround)
152
+ );
153
+ })
154
+ : [];
155
+
156
+ if (cookies) {
157
+ workaroundHeaders.push([
158
+ "set-cookie",
159
+ Object.entries(cookies || "")
160
+ .map(
161
+ ([name, { value }]) =>
162
+ `${encodeURIComponent(name)}=${encodeURIComponent(value)}; path=/`,
163
+ )
164
+ .join("; "),
165
+ ]);
166
+ }
167
+
168
+ if (contentType == "application/json") {
169
+ res.statusCode = status;
170
+ res.setHeader("content-type", "application/json; charset=utf-8");
171
+ for (const [name, value] of workaroundHeaders) {
172
+ res.setHeader(name, value);
173
+ }
174
+ res.end(JSON.stringify(output));
175
+ } else if (opaqueRedirectWorkaround) {
176
+ res.setHeader("content-type", "application/json; charset=utf-8");
177
+ for (const [name, value] of workaroundHeaders) {
178
+ res.setHeader(name, value);
179
+ }
180
+ res.end(JSON.stringify({ opaqueRedirectWorkaround }));
181
+ } else {
182
+ res.statusCode = status;
183
+ res.setHeader("content-type", "text/plain; charset=utf-8");
184
+ for (const [name, value] of workaroundHeaders) {
185
+ res.setHeader(name, value);
186
+ }
187
+ res.end(output);
188
+ }
189
+ } catch (/** @type {any} */ error) {
190
+ console.error(error);
191
+ res.statusCode = error instanceof ValiError ? 400 : 500;
192
+ res.setHeader("content-type", "text/plain; charset=utf-8");
193
+ res.end(error.message);
194
+ }
195
+ },
196
+ input,
197
+ outputs,
198
+ };
199
+ }
200
+
201
+ /** @type {import("./server-generic.js").ServerJson} */
202
+ export function json(output) {
203
+ return {
204
+ contentType: /** @type {any} */ ("application/json"),
205
+ output,
206
+ status: 200,
207
+ };
208
+ }
209
+
210
+ /** @param {{ listen: (port: number, callback: () => void) => { close(): void, on(event: "error", callback: (error: unknown) => void ): void } }} app */
211
+ export function listen(app) {
212
+ const port = Number(process.env["PORT"]) || 5173;
213
+ if (cluster.isPrimary && parallelism() > 1) {
214
+ /** @type {{ on(event: "message", callback: (message: any) => void): void, send(message: any, _: any, options: { keepOpen: boolean }): void }[]} */
215
+ const forks = [];
216
+ for (let i = 0; i < parallelism(); i++) {
217
+ forks.push(cluster.fork({ i }));
218
+ }
219
+ for (const fork of forks) {
220
+ fork.on("message", (maybeMessage) => {
221
+ const message = /** @type {any} */ (maybeMessage);
222
+ for (let i = 0; i < forks.length; i++) {
223
+ if (i != message.i) {
224
+ forks[i]?.send(message, undefined, { keepOpen: true });
225
+ }
226
+ }
227
+ });
228
+ }
229
+ } else {
230
+ const server = app.listen(port, () => {
231
+ if (cluster.isPrimary || process.env["i"] == "0") {
232
+ console.log(
233
+ `Server started at ${process.env["ORIGIN"] || `http://localhost:${port}`}`,
234
+ );
235
+ }
236
+ });
237
+ server.on("error", (error) => {
238
+ console.error(error);
239
+ process.exitCode = 1;
240
+ });
241
+ process.on("SIGTERM", () => {
242
+ server.close();
243
+ });
244
+ }
245
+ }
246
+
247
+ /** @param {{ html: string, prerender({ cookie, lang, url }: { cookie?: string | undefined, lang: string, url: string }): Promise<{ data: {}, head: { elements: Set<{ props: { [k: string]: string }, type: string }>, lang: string, title: string }, html: string }> }} props */
248
+ export function production({ html, prerender }) {
249
+ /**
250
+ * @param {import("node:http").IncomingMessage} req
251
+ * @param {import("node:http").ServerResponse<import("node:http").IncomingMessage>} res
252
+ */
253
+ return async function onRequest(req, res) {
254
+ try {
255
+ const rendered = await prerender({
256
+ cookie: req.headers.cookie,
257
+ lang: acceptLanguage.get(req.headers["accept-language"]) || "en",
258
+ url: req.url || "/",
259
+ });
260
+ res.statusCode =
261
+ // https://indieweb.org/meta_http-equiv_status
262
+ Number(
263
+ [...rendered.head.elements.values()]
264
+ .find(
265
+ ({ props, type }) =>
266
+ type == "meta" && props["http-equiv"] == "status",
267
+ )
268
+ ?.props["content"]?.replace(/ .*/, ""),
269
+ ) || 200;
270
+ res.setHeader("content-type", "text/html; charset=utf-8");
271
+ res.end(
272
+ html
273
+ .replace(/lang=".+?"/, () => `lang="${rendered.head.lang}"`)
274
+ .replace(/<title>.+<\/title>/, () =>
275
+ renderToString(h("title", null, rendered.head.title)),
276
+ )
277
+ .replace(/<!--app-head-->[\s\S]+?<body/, (match) =>
278
+ match
279
+ .replace(/<link[^>]+>/g, (link) =>
280
+ /data-hoofd/.test(link) ? "" : link,
281
+ )
282
+ .replace(/<meta[^>]+>/g, ""),
283
+ )
284
+ .replace(
285
+ `<!--app-head-->`,
286
+ [...rendered.head.elements.values()]
287
+ .map(
288
+ ({ props, type }) =>
289
+ `<${type} ` +
290
+ Object.entries(props)
291
+ .map(([name, value]) => `${name}="${value}"`)
292
+ .join(" ") +
293
+ (type == "script" ? `</${type}>` : " />"),
294
+ )
295
+ .join(""),
296
+ )
297
+ .replace(
298
+ /(<div id="app">)[\s\S]+<!--app-html-->/,
299
+ (_, div) =>
300
+ div +
301
+ rendered.html +
302
+ '<script type="application/json" id="preact-prerender-data">' +
303
+ JSON.stringify(rendered.data).replace(/\//g, "\\/") +
304
+ "</script>",
305
+ ),
306
+ );
307
+ } catch (error) {
308
+ console.error(error);
309
+ res.statusCode = 500;
310
+ res.end("Internal Server Error");
311
+ }
312
+ };
313
+ }
314
+
315
+ /** @type {import("./server-generic.js").ServerRedirect} */
316
+ export function redirect(location, status, init) {
317
+ return {
318
+ contentType: /** @type {any} */ ("text/plain"),
319
+ cookies: init?.cookies,
320
+ headers: { location },
321
+ output: location,
322
+ status,
323
+ };
324
+ }
325
+
326
+ /**
327
+ * @param {string} path
328
+ * @param {import("./server-generic.js").MethodServer} controller */
329
+ export function route(path, controller) {
330
+ return { controller, path, pattern: new URLPattern(`http://*:*${path}`) };
331
+ }
332
+
333
+ /**
334
+ * // eslint-disable-next-line @typescript-eslint/no-explicit-any
335
+ * @param {{ db?: import("./db.js").Db<any>, pkg: { name: string, version: string } }} props
336
+ * @param {ReturnType<typeof route>[]} routes
337
+ */
338
+ export function router({ db, pkg }, ...routes) {
339
+ const openapi = {
340
+ info: { title: pkg.name, version: pkg.version },
341
+ openapi: "3.1.0",
342
+ paths: Object.fromEntries(
343
+ Object.values(routes).map(({ controller, path }) => [
344
+ path.replace(/:(\w+)/g, "{$1}"),
345
+ Object.fromEntries(
346
+ Object.entries(controller).map(([method, { input, outputs }]) => {
347
+ const arrayOf = ArrayOf(input);
348
+ const parameters = path
349
+ .split("/")
350
+ .filter((component) => /^:/.test(component))
351
+ .map((component) => component.slice(1));
352
+ return [
353
+ method,
354
+ {
355
+ parameters: [
356
+ ...parameters.map((name) => ({
357
+ in: "path",
358
+ name,
359
+ required: true,
360
+ schema: { type: "string" },
361
+ })),
362
+ ...(method == "post"
363
+ ? []
364
+ : Object.entries(input.entries)
365
+ .filter(
366
+ ([name]) =>
367
+ name != "lang" && !parameters.includes(name),
368
+ )
369
+ .map(([name, entry]) => ({
370
+ in: "query",
371
+ name,
372
+ required: !entry.expects.includes("undefined"),
373
+ schema: { type: "string" },
374
+ }))),
375
+ ],
376
+ ...(method == "post"
377
+ ? {
378
+ requestBody: {
379
+ content: {
380
+ [Object.values(input.entries).some(
381
+ (entry) =>
382
+ entry.type == "file" || arrayOf(entry, "file"),
383
+ )
384
+ ? "multipart/form-data"
385
+ : "application/x-www-form-urlencoded"]: {
386
+ schema: toJsonSchema(
387
+ object(
388
+ Object.fromEntries(
389
+ Object.entries(input.entries).filter(
390
+ ([name]) =>
391
+ name != "lang" &&
392
+ !parameters.includes(name),
393
+ ),
394
+ ),
395
+ ),
396
+ {
397
+ ignoreActions: ["trim"],
398
+ overrideSchema: (context) =>
399
+ context.valibotSchema.type == "file"
400
+ ? { format: "binary", type: "string" }
401
+ : context.jsonSchema,
402
+ },
403
+ ),
404
+ },
405
+ },
406
+ },
407
+ }
408
+ : undefined),
409
+ responses: Object.fromEntries(
410
+ Object.entries(outputs).map(([status, schema]) => [
411
+ status,
412
+ {
413
+ content: {
414
+ ["application/json"]: { schema: toJsonSchema(schema) },
415
+ },
416
+ description:
417
+ status == "200"
418
+ ? "OK"
419
+ : status == "302"
420
+ ? "Found"
421
+ : "Bad Request",
422
+ },
423
+ ]),
424
+ ),
425
+ },
426
+ ];
427
+ }),
428
+ ),
429
+ ]),
430
+ ),
431
+ servers: [{ url: process.env["ORIGIN"] || "" }],
432
+ };
433
+
434
+ Po.set(async (lang) =>
435
+ po.parse(await readFile(`po/${encodeURIComponent(lang)}.po`, "utf-8")),
436
+ );
437
+
438
+ const poPattern = new URLPattern(`http://*:*/po/:lang`);
439
+
440
+ /** @type {Map<(req: any, res: any) => void, string[]>} */
441
+ const TablesInOutput = new Map();
442
+ for (const { controller } of Object.values(routes)) {
443
+ for (const [method, { handler, outputs }] of Object.entries(controller)) {
444
+ if (method == "get") {
445
+ /** @type {string[]} */
446
+ let tablesInOutput = [];
447
+
448
+ /** @param {import("valibot").ArraySchema<any, any> | import("valibot").ObjectSchema<import("valibot").ObjectEntries, any> | import("valibot").StringSchema<any>} schema */
449
+ function walk(schema) {
450
+ if (schema.type == "array") {
451
+ walk(schema.item);
452
+ } else if (schema.type == "object") {
453
+ for (const entry of Object.values(schema.entries)) {
454
+ walk(/** @type {any} */ (entry));
455
+ }
456
+ } else if (db) {
457
+ for (const [name, table] of Object.entries(
458
+ /** @type {Record<string, import("valibot").ObjectSchema<import("valibot").ObjectEntries, any>>} */ (
459
+ db.schema
460
+ ),
461
+ )) {
462
+ for (const column of Object.values(table.entries)) {
463
+ if (column == schema) {
464
+ tablesInOutput.push(name);
465
+ }
466
+ }
467
+ }
468
+ }
469
+ }
470
+ for (const schema of Object.values(outputs)) {
471
+ walk(schema);
472
+ }
473
+ if (tablesInOutput.length) {
474
+ TablesInOutput.set(handler, [...new Set(tablesInOutput)]);
475
+ }
476
+ }
477
+ }
478
+ }
479
+
480
+ /**
481
+ * @param {import("node:http").IncomingMessage} req
482
+ * @param {import("node:http").ServerResponse<import("node:http").IncomingMessage>} res
483
+ * @param {() => void} [next]
484
+ */
485
+ async function onRequest(req, res, next) {
486
+ const lowercase = req.method?.toLowerCase();
487
+ const url = new URL(`http://${req.headers.host}${req.url}`);
488
+
489
+ const pathname = poPattern.exec(url)?.pathname;
490
+ if (pathname) {
491
+ const lang = acceptLanguage.get(pathname.groups["lang"]) || "en";
492
+ if (lang != "en") {
493
+ res.setHeader("content-type", "application/json");
494
+ res.end(JSON.stringify(await Po.get(lang)));
495
+ return;
496
+ }
497
+ }
498
+
499
+ for (const { controller, pattern } of Object.values(routes)) {
500
+ const pathname = pattern.exec(url)?.pathname;
501
+ if (pathname) {
502
+ for (const [method, { handler }] of Object.entries(controller)) {
503
+ if (method == lowercase) {
504
+ const lang =
505
+ acceptLanguage.get(req.headers["accept-language"]) || "en";
506
+ if (!url.searchParams.has(lang)) {
507
+ url.searchParams.set("lang", lang);
508
+ }
509
+ for (const [name, value] of Object.entries(pathname.groups)) {
510
+ if (!url.searchParams.has(name)) {
511
+ url.searchParams.set(name, value || "");
512
+ }
513
+ }
514
+ req.url = url.pathname + (url.search || "");
515
+
516
+ if (method == "get" && req.headers.accept == "text/event-stream") {
517
+ res.setHeader("x-accel-buffering", "no");
518
+ res.setHeader("content-type", "text/event-stream");
519
+ res.setHeader("cache-control", "no-cache");
520
+ const tablesInOutput = TablesInOutput.get(handler);
521
+ if (tablesInOutput && db) {
522
+ let busy = false;
523
+ let queue = 0;
524
+ let startedAt = 0;
525
+ let endedAt = 0;
526
+ let lastHash = "";
527
+ const interval = setInterval(() => {
528
+ const now = Date.now();
529
+ if (
530
+ !busy &&
531
+ queue &&
532
+ endedAt < now - (endedAt - startedAt) * 3
533
+ ) {
534
+ busy = true;
535
+ queue = 0;
536
+ startedAt = now;
537
+ /** @type {Record<string, string>} */
538
+ const headers = {};
539
+ let statusCode = 200;
540
+ handler(req, {
541
+ end(body) {
542
+ const data = JSON.stringify({
543
+ body,
544
+ headers,
545
+ statusCode,
546
+ });
547
+ const hash = createHash("sha256")
548
+ .update(data)
549
+ .digest("hex");
550
+ if (lastHash != hash) {
551
+ lastHash = hash;
552
+ res.write(`data: ${data}\n\n`);
553
+ const expressCompressionResponse =
554
+ /** @type {any} */ (res);
555
+ if (
556
+ typeof expressCompressionResponse.flush ==
557
+ "function"
558
+ ) {
559
+ expressCompressionResponse.flush();
560
+ }
561
+ }
562
+ endedAt = Date.now();
563
+ busy = false;
564
+ },
565
+ setHeader(name, value) {
566
+ headers[name] = value;
567
+ },
568
+ set statusCode(/** @type {number} */ value) {
569
+ statusCode = value;
570
+ },
571
+ });
572
+ }
573
+ }, 100);
574
+ const unsubscribe = db.subscribe(({ table }) => {
575
+ if (tablesInOutput.includes(table)) {
576
+ queue++;
577
+ }
578
+ });
579
+ req.on("close", () => {
580
+ unsubscribe();
581
+ clearInterval(interval);
582
+ });
583
+ } else {
584
+ res.end("data: null\n\n");
585
+ }
586
+ return;
587
+ }
588
+
589
+ handler(req, res);
590
+ return;
591
+ }
592
+ }
593
+
594
+ if (lowercase == "options") {
595
+ res.statusCode = 204;
596
+ res.setHeader(
597
+ "allow",
598
+ [
599
+ ...new Set([
600
+ "OPTIONS",
601
+ ...Object.keys(controller).map((x) => x.toUpperCase()),
602
+ ]),
603
+ ].join(", "),
604
+ );
605
+ res.end();
606
+ return;
607
+ }
608
+
609
+ res.statusCode = 405;
610
+ res.end("Method Not Allowed");
611
+ return;
612
+ }
613
+ }
614
+
615
+ if (req.url == "/api" || req.url?.startsWith("/api/")) {
616
+ res.statusCode = 404;
617
+ res.end("Not Found");
618
+ return;
619
+ }
620
+
621
+ if (req.url == "/openapi.json") {
622
+ res.setHeader("content-type", "application/json");
623
+ res.end(JSON.stringify(openapi));
624
+ return;
625
+ }
626
+
627
+ if (!next) {
628
+ res.statusCode = 404;
629
+ res.end("Not Found");
630
+ return;
631
+ }
632
+
633
+ next();
634
+ }
635
+
636
+ return { onRequest, openapi };
637
+ }
638
+
639
+ /** @type {import("./server-generic.js").ServerText} */
640
+ export function text(output, status) {
641
+ return {
642
+ contentType: /** @type {any} */ ("text/plain"),
643
+ output,
644
+ status,
645
+ };
646
+ }
647
+
648
+ /** @param {import("valibot").ObjectSchema<any, any>} input */
649
+ function ArrayOf(input) {
650
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
651
+ const entries = Object.values(input);
652
+ /**
653
+ * @param {typeof entries[0]} entry
654
+ * @param {string} type
655
+ */
656
+ return function arrayOf(entry, type) {
657
+ if (entry.type != "array") {
658
+ return false;
659
+ }
660
+ /** @type {import("valibot").ArraySchema<any, any>} */
661
+ const array = /** @type {any} */ (entry);
662
+ return array.item.type == type;
663
+ };
664
+ }
665
+
666
+ function parallelism() {
667
+ return Number(process.env["PARALLELISM"]) || availableParallelism();
668
+ }