evikit 2.3.2 → 2.4.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.
Files changed (6) hide show
  1. package/README.md +19 -0
  2. package/index.d.ts +153 -134
  3. package/index.js +211 -243
  4. package/package.json +1 -1
  5. package/server.js +498 -487
  6. package/html.js +0 -49
package/server.js CHANGED
@@ -1,487 +1,498 @@
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 { readFile } from "node:fs/promises";
6
- import { Fragment, h } from "preact";
7
- import { renderToString } from "preact-render-to-string";
8
- import {
9
- object,
10
- optional,
11
- parse,
12
- picklist,
13
- record,
14
- string,
15
- union,
16
- ValiError,
17
- } from "valibot";
18
-
19
- import { Po } from "./i18n.js";
20
-
21
- /** @type {import("./server-generic.js").ServerEndpoint} */
22
- export function endpoint({ input, outputs }, handler) {
23
- const arrayOf = ArrayOf(input);
24
- const schema = union(
25
- Object.entries(outputs).map(([status, schema]) =>
26
- object({
27
- contentType: optional(string()),
28
- cookies: optional(record(string(), object({ value: string() }))),
29
- headers: optional(record(string(), string())),
30
- output: schema,
31
- status: picklist([Number(status)]),
32
- }),
33
- ),
34
- );
35
- return {
36
- handler: async (req, res) => {
37
- try {
38
- /** @type {Record<string, string | File | (string | File)[]>} */
39
- const body = {};
40
- if (req.headers["content-type"]) {
41
- const busboy = new Busboy({
42
- headers: /** @type {any} */ (req.headers),
43
- });
44
- busboy.on("field", (name, value) => {
45
- body[name] =
46
- typeof body[name] == "undefined"
47
- ? value
48
- : Array.isArray(body[name])
49
- ? [...body[name], value]
50
- : [body[name], value];
51
- });
52
-
53
- if (
54
- Object.values(input.entries).some(
55
- (entry) => entry.type == "file" || arrayOf(entry, "file"),
56
- )
57
- ) {
58
- busboy.on("file", (name, stream, filename) => {
59
- const entry = input.entries[name];
60
- if (entry && (entry.type == "file" || arrayOf(entry, "file"))) {
61
- /** @type {ArrayBuffer[]} */
62
- const buffers = [];
63
- stream.on("data", (data) => {
64
- buffers.push(data);
65
- });
66
- stream.on("end", () => {
67
- const value = new File(buffers, filename);
68
- body[name] =
69
- typeof body[name] == "undefined"
70
- ? value
71
- : Array.isArray(body[name])
72
- ? [...body[name], value]
73
- : [body[name], value];
74
- });
75
- } else {
76
- stream.resume();
77
- }
78
- });
79
- }
80
-
81
- await new Promise((resolve, reject) => {
82
- busboy.on("error", reject).on("finish", resolve);
83
- req.pipe(busboy);
84
- });
85
- }
86
-
87
- const { contentType, cookies, headers, output, status } = parse(
88
- schema,
89
- await handler({
90
- input: parse(input, {
91
- ...(req.headers.cookie
92
- ? Object.fromEntries(
93
- req.headers.cookie
94
- .split("; ")
95
- .map((row) => row.split("=").map(decodeURIComponent)),
96
- )
97
- : undefined),
98
- ...Object.fromEntries(
99
- Object.entries(
100
- Object.groupBy(
101
- [
102
- ...new URL(
103
- `http://${req.headers.host}${req.url}`,
104
- ).searchParams.entries(),
105
- ],
106
- ([name]) => name || "",
107
- ),
108
- ).map(([name, group]) => {
109
- const values = (group || []).map((value) => value[1]);
110
- return [name, values.length == 1 ? values[0] : values];
111
- }),
112
- ),
113
- ...body,
114
- }),
115
- }),
116
- );
117
-
118
- const location = headers
119
- ? Object.entries(headers).find(
120
- ([name]) => name.toLowerCase() == "location",
121
- )?.[1]
122
- : undefined;
123
- const opaqueRedirectWorkaround =
124
- req.headers.accept == "application/json" && location
125
- ? { location, status }
126
- : undefined;
127
- const workaroundHeaders = headers
128
- ? Object.entries(headers).filter(([name]) => {
129
- const lower = name.toLowerCase();
130
- return (
131
- lower != "content-type" &&
132
- (lower != "location" || !opaqueRedirectWorkaround)
133
- );
134
- })
135
- : [];
136
-
137
- if (cookies) {
138
- workaroundHeaders.push([
139
- "set-cookie",
140
- Object.entries(cookies || "")
141
- .map(
142
- ([name, { value }]) =>
143
- `${encodeURIComponent(name)}=${encodeURIComponent(value)}`,
144
- )
145
- .join("; "),
146
- ]);
147
- }
148
-
149
- if (contentType == "application/json") {
150
- res.statusCode = status;
151
- res.setHeader("content-type", "application/json; charset=utf-8");
152
- for (const [name, value] of workaroundHeaders) {
153
- res.setHeader(name, value);
154
- }
155
- res.end(JSON.stringify(output));
156
- } else if (opaqueRedirectWorkaround) {
157
- res.setHeader("content-type", "application/json; charset=utf-8");
158
- for (const [name, value] of workaroundHeaders) {
159
- res.setHeader(name, value);
160
- }
161
- res.end(JSON.stringify({ opaqueRedirectWorkaround }));
162
- } else {
163
- res.statusCode = status;
164
- res.setHeader("content-type", "text/plain; charset=utf-8");
165
- for (const [name, value] of workaroundHeaders) {
166
- res.setHeader(name, value);
167
- }
168
- res.end(output);
169
- }
170
- } catch (/** @type {any} */ error) {
171
- console.error(error);
172
- res.statusCode = error instanceof ValiError ? 400 : 500;
173
- res.setHeader("content-type", "text/plain; charset=utf-8");
174
- res.end(error.message);
175
- }
176
- },
177
- input,
178
- outputs,
179
- };
180
- }
181
-
182
- /** @type {import("./server-generic.js").ServerJson} */
183
- export function json(output) {
184
- return {
185
- contentType: /** @type {any} */ ("application/json"),
186
- output,
187
- status: 200,
188
- };
189
- }
190
-
191
- /** @param {{ listen: (port: number, callback: () => void) => { close(): void, on(event: "error", callback: (error: unknown) => void ): void } }} app */
192
- export function listen(app) {
193
- const port = Number(process.env["PORT"]) || 5173;
194
- const server = app.listen(port, () => {
195
- console.log(
196
- `Server started at ${process.env["ORIGIN"] || `http://localhost:${port}`}`,
197
- );
198
- });
199
- server.on("error", (error) => {
200
- console.error(error);
201
- process.exitCode = 1;
202
- });
203
- process.on("SIGTERM", () => {
204
- server.close();
205
- });
206
- }
207
-
208
- /** @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 */
209
- export function production({ html, prerender }) {
210
- /**
211
- * @param {import("node:http").IncomingMessage} req
212
- * @param {import("node:http").ServerResponse<import("node:http").IncomingMessage>} res
213
- */
214
- return async function onRequest(req, res) {
215
- try {
216
- const rendered = await prerender({
217
- lang: acceptLanguage.get(req.headers["accept-language"]) || "en",
218
- url: req.url || "/",
219
- });
220
- res.statusCode =
221
- // https://indieweb.org/meta_http-equiv_status
222
- Number(
223
- [...rendered.head.elements.values()]
224
- .find(
225
- ({ props, type }) =>
226
- type == "meta" && props["http-equiv"] == "status",
227
- )
228
- ?.props["content"]?.replace(/ .*/, ""),
229
- ) || 200;
230
- res.setHeader("content-type", "text/html; charset=utf-8");
231
- res.end(
232
- html
233
- .replace(/lang=".+?"/, () => `lang="${rendered.head.lang}"`)
234
- .replace(/<title>.+<\/title>/, () =>
235
- renderToString(h("title", null, rendered.head.title)),
236
- )
237
- .replace(/<!--app-head-->[\s\S]+?<body/, (match) =>
238
- match.replace(/<meta[^>]+>/g, ""),
239
- )
240
- .replace(
241
- `<!--app-head-->`,
242
- renderToString(
243
- h(
244
- Fragment,
245
- null,
246
- [...rendered.head.elements.values()].map(({ props, type }) =>
247
- h(type, props),
248
- ),
249
- ),
250
- ),
251
- )
252
- .replace(
253
- /(<div id="app">)[\s\S]+<!--app-html-->/,
254
- (_, div) =>
255
- div +
256
- rendered.html +
257
- '<script type="application/json" id="preact-prerender-data">' +
258
- JSON.stringify(rendered.data).replace(/\//g, "\\/") +
259
- "</script>",
260
- ),
261
- );
262
- } catch (error) {
263
- console.error(error);
264
- res.statusCode = 500;
265
- res.end("Internal Server Error");
266
- }
267
- };
268
- }
269
-
270
- /** @type {import("./server-generic.js").ServerRedirect} */
271
- export function redirect(location, status, init) {
272
- return {
273
- contentType: /** @type {any} */ ("text/plain"),
274
- cookies: init?.cookies,
275
- headers: { location },
276
- output: location,
277
- status,
278
- };
279
- }
280
-
281
- /**
282
- * @param {string} path
283
- * @param {import("./server-generic.js").MethodServer} controller */
284
- export function route(path, controller) {
285
- return { controller, path, pattern: new URLPattern(`http://*:*${path}`) };
286
- }
287
-
288
- /**
289
- * @param {{ pkg: { name: string, version: string } }} props
290
- * @param {ReturnType<typeof route>[]} routes
291
- */
292
- export function router({ pkg }, ...routes) {
293
- const openapi = {
294
- info: { title: pkg.name, version: pkg.version },
295
- openapi: "3.1.0",
296
- paths: Object.fromEntries(
297
- Object.values(routes).map(({ controller, path }) => [
298
- path.replace(/:(\w+)/g, "{$1}"),
299
- Object.fromEntries(
300
- Object.entries(controller).map(([method, { input, outputs }]) => {
301
- const arrayOf = ArrayOf(input);
302
- const parameters = path
303
- .split("/")
304
- .filter((component) => /^:/.test(component))
305
- .map((component) => component.slice(1));
306
- return [
307
- method,
308
- {
309
- parameters: parameters.map((name) => ({
310
- in: "path",
311
- name,
312
- required: true,
313
- schema: { type: "string" },
314
- })),
315
- ...(method == "post"
316
- ? {
317
- requestBody: {
318
- content: {
319
- [Object.values(input.entries).some(
320
- (entry) =>
321
- entry.type == "file" || arrayOf(entry, "file"),
322
- )
323
- ? "multipart/form-data"
324
- : "application/x-www-form-urlencoded"]: {
325
- schema: toJsonSchema(
326
- object(
327
- Object.fromEntries(
328
- Object.entries(input.entries).filter(
329
- ([name]) => !parameters.includes(name),
330
- ),
331
- ),
332
- ),
333
- {
334
- ignoreActions: ["trim"],
335
- overrideSchema: (context) =>
336
- context.valibotSchema.type == "file"
337
- ? { format: "binary", type: "string" }
338
- : context.jsonSchema,
339
- },
340
- ),
341
- },
342
- },
343
- },
344
- }
345
- : undefined),
346
- responses: Object.fromEntries(
347
- Object.entries(outputs).map(([status, schema]) => [
348
- status,
349
- {
350
- content: {
351
- ["application/json"]: { schema: toJsonSchema(schema) },
352
- },
353
- description:
354
- status == "200"
355
- ? "OK"
356
- : status == "302"
357
- ? "Found"
358
- : "Bad Request",
359
- },
360
- ]),
361
- ),
362
- },
363
- ];
364
- }),
365
- ),
366
- ]),
367
- ),
368
- servers: [{ url: process.env["ORIGIN"] || "" }],
369
- };
370
-
371
- Po.set(async (lang) =>
372
- po.parse(await readFile(`po/${encodeURIComponent(lang)}.po`, "utf-8")),
373
- );
374
-
375
- const poPattern = new URLPattern(`http://*:*/po/:lang`);
376
-
377
- /**
378
- * @param {import("node:http").IncomingMessage} req
379
- * @param {import("node:http").ServerResponse<import("node:http").IncomingMessage>} res
380
- * @param {() => void} [next]
381
- */
382
- async function onRequest(req, res, next) {
383
- const lowercase = req.method?.toLowerCase();
384
- const url = new URL(`http://${req.headers.host}${req.url}`);
385
-
386
- const pathname = poPattern.exec(url)?.pathname;
387
- if (pathname) {
388
- const lang = acceptLanguage.get(pathname.groups["lang"]) || "en";
389
- if (lang != "en") {
390
- res.setHeader("content-type", "application/json");
391
- res.end(JSON.stringify(await Po.get(lang)));
392
- return;
393
- }
394
- }
395
-
396
- for (const { controller, pattern } of Object.values(routes)) {
397
- const pathname = pattern.exec(url)?.pathname;
398
- if (pathname) {
399
- for (const [method, { handler }] of Object.entries(controller)) {
400
- if (method == lowercase) {
401
- const lang =
402
- acceptLanguage.get(req.headers["accept-language"]) || "en";
403
- if (!url.searchParams.has(lang)) {
404
- url.searchParams.set("lang", lang);
405
- }
406
- for (const [name, value] of Object.entries(pathname.groups)) {
407
- if (!url.searchParams.has(name)) {
408
- url.searchParams.set(name, value || "");
409
- }
410
- }
411
- req.url = url.pathname + (url.search || "");
412
- handler(req, res);
413
- return;
414
- }
415
- }
416
-
417
- if (lowercase == "options") {
418
- res.statusCode = 204;
419
- res.setHeader(
420
- "allow",
421
- [
422
- ...new Set([
423
- "OPTIONS",
424
- ...Object.keys(controller).map((x) => x.toUpperCase()),
425
- ]),
426
- ].join(", "),
427
- );
428
- res.end();
429
- return;
430
- }
431
-
432
- res.statusCode = 405;
433
- res.end("Method Not Allowed");
434
- return;
435
- }
436
- }
437
-
438
- if (req.url == "/api" || req.url?.startsWith("/api/")) {
439
- res.statusCode = 404;
440
- res.end("Not Found");
441
- return;
442
- }
443
-
444
- if (req.url == "/openapi.json") {
445
- res.setHeader("content-type", "application/json");
446
- res.end(JSON.stringify(openapi));
447
- return;
448
- }
449
-
450
- if (!next) {
451
- res.statusCode = 404;
452
- res.end("Not Found");
453
- return;
454
- }
455
-
456
- next();
457
- }
458
-
459
- return { onRequest, openapi };
460
- }
461
-
462
- /** @type {import("./server-generic.js").ServerText} */
463
- export function text(output, status) {
464
- return {
465
- contentType: /** @type {any} */ ("text/plain"),
466
- output,
467
- status,
468
- };
469
- }
470
-
471
- /** @param {import("valibot").ObjectSchema<any, any>} input */
472
- function ArrayOf(input) {
473
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
474
- const entries = Object.values(input);
475
- /**
476
- * @param {typeof entries[0]} entry
477
- * @param {string} type
478
- */
479
- return function arrayOf(entry, type) {
480
- if (entry.type != "array") {
481
- return false;
482
- }
483
- /** @type {import("valibot").ArraySchema<any, any>} */
484
- const array = /** @type {any} */ (entry);
485
- return array.item.type == type;
486
- };
487
- }
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
+ /** @type {import("./server-generic.js").ServerEndpoint} */
24
+ export function endpoint({ input, outputs }, handler) {
25
+ const arrayOf = ArrayOf(input);
26
+ const schema = union(
27
+ Object.entries(outputs).map(([status, schema]) =>
28
+ object({
29
+ contentType: optional(string()),
30
+ cookies: optional(record(string(), object({ value: string() }))),
31
+ headers: optional(record(string(), string())),
32
+ output: schema,
33
+ status: picklist([Number(status)]),
34
+ }),
35
+ ),
36
+ );
37
+ return {
38
+ handler: async (req, res) => {
39
+ try {
40
+ /** @type {Record<string, string | File | (string | File)[]>} */
41
+ const body = {};
42
+ if (req.headers["content-type"]) {
43
+ const busboy = new Busboy({
44
+ headers: /** @type {any} */ (req.headers),
45
+ });
46
+ busboy.on("field", (name, value) => {
47
+ body[name] =
48
+ typeof body[name] == "undefined"
49
+ ? value
50
+ : Array.isArray(body[name])
51
+ ? [...body[name], value]
52
+ : [body[name], value];
53
+ });
54
+
55
+ if (
56
+ Object.values(input.entries).some(
57
+ (entry) => entry.type == "file" || arrayOf(entry, "file"),
58
+ )
59
+ ) {
60
+ busboy.on("file", (name, stream, filename) => {
61
+ const entry = input.entries[name];
62
+ if (entry && (entry.type == "file" || arrayOf(entry, "file"))) {
63
+ /** @type {ArrayBuffer[]} */
64
+ const buffers = [];
65
+ stream.on("data", (data) => {
66
+ buffers.push(data);
67
+ });
68
+ stream.on("end", () => {
69
+ const value = new File(buffers, filename);
70
+ body[name] =
71
+ typeof body[name] == "undefined"
72
+ ? value
73
+ : Array.isArray(body[name])
74
+ ? [...body[name], value]
75
+ : [body[name], value];
76
+ });
77
+ } else {
78
+ stream.resume();
79
+ }
80
+ });
81
+ }
82
+
83
+ await new Promise((resolve, reject) => {
84
+ busboy.on("error", reject).on("finish", resolve);
85
+ req.pipe(busboy);
86
+ });
87
+ }
88
+
89
+ const { contentType, cookies, headers, output, status } = parse(
90
+ schema,
91
+ await handler({
92
+ input: parse(input, {
93
+ ...(req.headers.cookie
94
+ ? Object.fromEntries(
95
+ req.headers.cookie
96
+ .split("; ")
97
+ .map((row) => row.split("=").map(decodeURIComponent)),
98
+ )
99
+ : undefined),
100
+ ...Object.fromEntries(
101
+ Object.entries(
102
+ Object.groupBy(
103
+ [
104
+ ...new URL(
105
+ `http://${req.headers.host}${req.url}`,
106
+ ).searchParams.entries(),
107
+ ],
108
+ ([name]) => name || "",
109
+ ),
110
+ ).map(([name, group]) => {
111
+ const values = (group || []).map((value) => value[1]);
112
+ return [name, values.length == 1 ? values[0] : values];
113
+ }),
114
+ ),
115
+ ...body,
116
+ }),
117
+ }),
118
+ );
119
+
120
+ const location = headers
121
+ ? Object.entries(headers).find(
122
+ ([name]) => name.toLowerCase() == "location",
123
+ )?.[1]
124
+ : undefined;
125
+ const opaqueRedirectWorkaround =
126
+ req.headers.accept == "application/json" && location
127
+ ? { location, status }
128
+ : undefined;
129
+ const workaroundHeaders = headers
130
+ ? Object.entries(headers).filter(([name]) => {
131
+ const lower = name.toLowerCase();
132
+ return (
133
+ lower != "content-type" &&
134
+ (lower != "location" || !opaqueRedirectWorkaround)
135
+ );
136
+ })
137
+ : [];
138
+
139
+ if (cookies) {
140
+ workaroundHeaders.push([
141
+ "set-cookie",
142
+ Object.entries(cookies || "")
143
+ .map(
144
+ ([name, { value }]) =>
145
+ `${encodeURIComponent(name)}=${encodeURIComponent(value)}`,
146
+ )
147
+ .join("; "),
148
+ ]);
149
+ }
150
+
151
+ if (contentType == "application/json") {
152
+ res.statusCode = status;
153
+ res.setHeader("content-type", "application/json; charset=utf-8");
154
+ for (const [name, value] of workaroundHeaders) {
155
+ res.setHeader(name, value);
156
+ }
157
+ res.end(JSON.stringify(output));
158
+ } else if (opaqueRedirectWorkaround) {
159
+ res.setHeader("content-type", "application/json; charset=utf-8");
160
+ for (const [name, value] of workaroundHeaders) {
161
+ res.setHeader(name, value);
162
+ }
163
+ res.end(JSON.stringify({ opaqueRedirectWorkaround }));
164
+ } else {
165
+ res.statusCode = status;
166
+ res.setHeader("content-type", "text/plain; charset=utf-8");
167
+ for (const [name, value] of workaroundHeaders) {
168
+ res.setHeader(name, value);
169
+ }
170
+ res.end(output);
171
+ }
172
+ } catch (/** @type {any} */ error) {
173
+ console.error(error);
174
+ res.statusCode = error instanceof ValiError ? 400 : 500;
175
+ res.setHeader("content-type", "text/plain; charset=utf-8");
176
+ res.end(error.message);
177
+ }
178
+ },
179
+ input,
180
+ outputs,
181
+ };
182
+ }
183
+
184
+ /** @type {import("./server-generic.js").ServerJson} */
185
+ export function json(output) {
186
+ return {
187
+ contentType: /** @type {any} */ ("application/json"),
188
+ output,
189
+ status: 200,
190
+ };
191
+ }
192
+
193
+ /** @param {{ listen: (port: number, callback: () => void) => { close(): void, on(event: "error", callback: (error: unknown) => void ): void } }} app */
194
+ export function listen(app) {
195
+ const port = Number(process.env["PORT"]) || 5173;
196
+ if (cluster.isPrimary && availableParallelism() > 1) {
197
+ for (let i = 0; i < availableParallelism(); i++) {
198
+ cluster.fork({ i });
199
+ }
200
+ } else {
201
+ const server = app.listen(port, () => {
202
+ if (cluster.isPrimary || process.env["i"] == "0") {
203
+ console.log(
204
+ `Server started at ${process.env["ORIGIN"] || `http://localhost:${port}`}`,
205
+ );
206
+ }
207
+ });
208
+ server.on("error", (error) => {
209
+ console.error(error);
210
+ process.exitCode = 1;
211
+ });
212
+ process.on("SIGTERM", () => {
213
+ server.close();
214
+ });
215
+ }
216
+ }
217
+
218
+ /** @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 */
219
+ export function production({ html, prerender }) {
220
+ /**
221
+ * @param {import("node:http").IncomingMessage} req
222
+ * @param {import("node:http").ServerResponse<import("node:http").IncomingMessage>} res
223
+ */
224
+ return async function onRequest(req, res) {
225
+ try {
226
+ const rendered = await prerender({
227
+ lang: acceptLanguage.get(req.headers["accept-language"]) || "en",
228
+ url: req.url || "/",
229
+ });
230
+ res.statusCode =
231
+ // https://indieweb.org/meta_http-equiv_status
232
+ Number(
233
+ [...rendered.head.elements.values()]
234
+ .find(
235
+ ({ props, type }) =>
236
+ type == "meta" && props["http-equiv"] == "status",
237
+ )
238
+ ?.props["content"]?.replace(/ .*/, ""),
239
+ ) || 200;
240
+ res.setHeader("content-type", "text/html; charset=utf-8");
241
+ res.end(
242
+ html
243
+ .replace(/lang=".+?"/, () => `lang="${rendered.head.lang}"`)
244
+ .replace(/<title>.+<\/title>/, () =>
245
+ renderToString(h("title", null, rendered.head.title)),
246
+ )
247
+ .replace(/<!--app-head-->[\s\S]+?<body/, (match) =>
248
+ match.replace(/<meta[^>]+>/g, ""),
249
+ )
250
+ .replace(
251
+ `<!--app-head-->`,
252
+ [...rendered.head.elements.values()]
253
+ .map(
254
+ ({ props, type }) =>
255
+ `<${type} ` +
256
+ Object.entries(props)
257
+ .map(([name, value]) => `${name}="${value}"`)
258
+ .join(" ") +
259
+ (type == "script" ? `</${type}>` : " />"),
260
+ )
261
+ .join(""),
262
+ )
263
+ .replace(
264
+ /(<div id="app">)[\s\S]+<!--app-html-->/,
265
+ (_, div) =>
266
+ div +
267
+ rendered.html +
268
+ '<script type="application/json" id="preact-prerender-data">' +
269
+ JSON.stringify(rendered.data).replace(/\//g, "\\/") +
270
+ "</script>",
271
+ ),
272
+ );
273
+ } catch (error) {
274
+ console.error(error);
275
+ res.statusCode = 500;
276
+ res.end("Internal Server Error");
277
+ }
278
+ };
279
+ }
280
+
281
+ /** @type {import("./server-generic.js").ServerRedirect} */
282
+ export function redirect(location, status, init) {
283
+ return {
284
+ contentType: /** @type {any} */ ("text/plain"),
285
+ cookies: init?.cookies,
286
+ headers: { location },
287
+ output: location,
288
+ status,
289
+ };
290
+ }
291
+
292
+ /**
293
+ * @param {string} path
294
+ * @param {import("./server-generic.js").MethodServer} controller */
295
+ export function route(path, controller) {
296
+ return { controller, path, pattern: new URLPattern(`http://*:*${path}`) };
297
+ }
298
+
299
+ /**
300
+ * @param {{ pkg: { name: string, version: string } }} props
301
+ * @param {ReturnType<typeof route>[]} routes
302
+ */
303
+ export function router({ pkg }, ...routes) {
304
+ const openapi = {
305
+ info: { title: pkg.name, version: pkg.version },
306
+ openapi: "3.1.0",
307
+ paths: Object.fromEntries(
308
+ Object.values(routes).map(({ controller, path }) => [
309
+ path.replace(/:(\w+)/g, "{$1}"),
310
+ Object.fromEntries(
311
+ Object.entries(controller).map(([method, { input, outputs }]) => {
312
+ const arrayOf = ArrayOf(input);
313
+ const parameters = path
314
+ .split("/")
315
+ .filter((component) => /^:/.test(component))
316
+ .map((component) => component.slice(1));
317
+ return [
318
+ method,
319
+ {
320
+ parameters: parameters.map((name) => ({
321
+ in: "path",
322
+ name,
323
+ required: true,
324
+ schema: { type: "string" },
325
+ })),
326
+ ...(method == "post"
327
+ ? {
328
+ requestBody: {
329
+ content: {
330
+ [Object.values(input.entries).some(
331
+ (entry) =>
332
+ entry.type == "file" || arrayOf(entry, "file"),
333
+ )
334
+ ? "multipart/form-data"
335
+ : "application/x-www-form-urlencoded"]: {
336
+ schema: toJsonSchema(
337
+ object(
338
+ Object.fromEntries(
339
+ Object.entries(input.entries).filter(
340
+ ([name]) => !parameters.includes(name),
341
+ ),
342
+ ),
343
+ ),
344
+ {
345
+ ignoreActions: ["trim"],
346
+ overrideSchema: (context) =>
347
+ context.valibotSchema.type == "file"
348
+ ? { format: "binary", type: "string" }
349
+ : context.jsonSchema,
350
+ },
351
+ ),
352
+ },
353
+ },
354
+ },
355
+ }
356
+ : undefined),
357
+ responses: Object.fromEntries(
358
+ Object.entries(outputs).map(([status, schema]) => [
359
+ status,
360
+ {
361
+ content: {
362
+ ["application/json"]: { schema: toJsonSchema(schema) },
363
+ },
364
+ description:
365
+ status == "200"
366
+ ? "OK"
367
+ : status == "302"
368
+ ? "Found"
369
+ : "Bad Request",
370
+ },
371
+ ]),
372
+ ),
373
+ },
374
+ ];
375
+ }),
376
+ ),
377
+ ]),
378
+ ),
379
+ servers: [{ url: process.env["ORIGIN"] || "" }],
380
+ };
381
+
382
+ Po.set(async (lang) =>
383
+ po.parse(await readFile(`po/${encodeURIComponent(lang)}.po`, "utf-8")),
384
+ );
385
+
386
+ const poPattern = new URLPattern(`http://*:*/po/:lang`);
387
+
388
+ /**
389
+ * @param {import("node:http").IncomingMessage} req
390
+ * @param {import("node:http").ServerResponse<import("node:http").IncomingMessage>} res
391
+ * @param {() => void} [next]
392
+ */
393
+ async function onRequest(req, res, next) {
394
+ const lowercase = req.method?.toLowerCase();
395
+ const url = new URL(`http://${req.headers.host}${req.url}`);
396
+
397
+ const pathname = poPattern.exec(url)?.pathname;
398
+ if (pathname) {
399
+ const lang = acceptLanguage.get(pathname.groups["lang"]) || "en";
400
+ if (lang != "en") {
401
+ res.setHeader("content-type", "application/json");
402
+ res.end(JSON.stringify(await Po.get(lang)));
403
+ return;
404
+ }
405
+ }
406
+
407
+ for (const { controller, pattern } of Object.values(routes)) {
408
+ const pathname = pattern.exec(url)?.pathname;
409
+ if (pathname) {
410
+ for (const [method, { handler }] of Object.entries(controller)) {
411
+ if (method == lowercase) {
412
+ const lang =
413
+ acceptLanguage.get(req.headers["accept-language"]) || "en";
414
+ if (!url.searchParams.has(lang)) {
415
+ url.searchParams.set("lang", lang);
416
+ }
417
+ for (const [name, value] of Object.entries(pathname.groups)) {
418
+ if (!url.searchParams.has(name)) {
419
+ url.searchParams.set(name, value || "");
420
+ }
421
+ }
422
+ req.url = url.pathname + (url.search || "");
423
+ handler(req, res);
424
+ return;
425
+ }
426
+ }
427
+
428
+ if (lowercase == "options") {
429
+ res.statusCode = 204;
430
+ res.setHeader(
431
+ "allow",
432
+ [
433
+ ...new Set([
434
+ "OPTIONS",
435
+ ...Object.keys(controller).map((x) => x.toUpperCase()),
436
+ ]),
437
+ ].join(", "),
438
+ );
439
+ res.end();
440
+ return;
441
+ }
442
+
443
+ res.statusCode = 405;
444
+ res.end("Method Not Allowed");
445
+ return;
446
+ }
447
+ }
448
+
449
+ if (req.url == "/api" || req.url?.startsWith("/api/")) {
450
+ res.statusCode = 404;
451
+ res.end("Not Found");
452
+ return;
453
+ }
454
+
455
+ if (req.url == "/openapi.json") {
456
+ res.setHeader("content-type", "application/json");
457
+ res.end(JSON.stringify(openapi));
458
+ return;
459
+ }
460
+
461
+ if (!next) {
462
+ res.statusCode = 404;
463
+ res.end("Not Found");
464
+ return;
465
+ }
466
+
467
+ next();
468
+ }
469
+
470
+ return { onRequest, openapi };
471
+ }
472
+
473
+ /** @type {import("./server-generic.js").ServerText} */
474
+ export function text(output, status) {
475
+ return {
476
+ contentType: /** @type {any} */ ("text/plain"),
477
+ output,
478
+ status,
479
+ };
480
+ }
481
+
482
+ /** @param {import("valibot").ObjectSchema<any, any>} input */
483
+ function ArrayOf(input) {
484
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
485
+ const entries = Object.values(input);
486
+ /**
487
+ * @param {typeof entries[0]} entry
488
+ * @param {string} type
489
+ */
490
+ return function arrayOf(entry, type) {
491
+ if (entry.type != "array") {
492
+ return false;
493
+ }
494
+ /** @type {import("valibot").ArraySchema<any, any>} */
495
+ const array = /** @type {any} */ (entry);
496
+ return array.item.type == type;
497
+ };
498
+ }