evikit 2.3.1 → 2.3.2

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 (5) hide show
  1. package/html.js +49 -47
  2. package/index.d.ts +134 -132
  3. package/index.js +243 -239
  4. package/package.json +1 -1
  5. package/server.js +487 -485
package/server.js CHANGED
@@ -1,485 +1,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 { 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(/<meta http-equiv="status" content=".+?">/, "")
238
- .replace(
239
- `<!--app-head-->`,
240
- renderToString(
241
- h(
242
- Fragment,
243
- null,
244
- [...rendered.head.elements.values()].map(({ props, type }) =>
245
- h(type, props),
246
- ),
247
- ),
248
- ),
249
- )
250
- .replace(
251
- /(<div id="app">)[\s\S]+<!--app-html-->/,
252
- (_, div) =>
253
- div +
254
- rendered.html +
255
- '<script type="application/json" id="preact-prerender-data">' +
256
- JSON.stringify(rendered.data).replace(/\//g, "\\/") +
257
- "</script>",
258
- ),
259
- );
260
- } catch (error) {
261
- console.error(error);
262
- res.statusCode = 500;
263
- res.end("Internal Server Error");
264
- }
265
- };
266
- }
267
-
268
- /** @type {import("./server-generic.js").ServerRedirect} */
269
- export function redirect(location, status, init) {
270
- return {
271
- contentType: /** @type {any} */ ("text/plain"),
272
- cookies: init?.cookies,
273
- headers: { location },
274
- output: location,
275
- status,
276
- };
277
- }
278
-
279
- /**
280
- * @param {string} path
281
- * @param {import("./server-generic.js").MethodServer} controller */
282
- export function route(path, controller) {
283
- return { controller, path, pattern: new URLPattern(`http://*:*${path}`) };
284
- }
285
-
286
- /**
287
- * @param {{ pkg: { name: string, version: string } }} props
288
- * @param {ReturnType<typeof route>[]} routes
289
- */
290
- export function router({ pkg }, ...routes) {
291
- const openapi = {
292
- info: { title: pkg.name, version: pkg.version },
293
- openapi: "3.1.0",
294
- paths: Object.fromEntries(
295
- Object.values(routes).map(({ controller, path }) => [
296
- path.replace(/:(\w+)/g, "{$1}"),
297
- Object.fromEntries(
298
- Object.entries(controller).map(([method, { input, outputs }]) => {
299
- const arrayOf = ArrayOf(input);
300
- const parameters = path
301
- .split("/")
302
- .filter((component) => /^:/.test(component))
303
- .map((component) => component.slice(1));
304
- return [
305
- method,
306
- {
307
- parameters: parameters.map((name) => ({
308
- in: "path",
309
- name,
310
- required: true,
311
- schema: { type: "string" },
312
- })),
313
- ...(method == "post"
314
- ? {
315
- requestBody: {
316
- content: {
317
- [Object.values(input.entries).some(
318
- (entry) =>
319
- entry.type == "file" || arrayOf(entry, "file"),
320
- )
321
- ? "multipart/form-data"
322
- : "application/x-www-form-urlencoded"]: {
323
- schema: toJsonSchema(
324
- object(
325
- Object.fromEntries(
326
- Object.entries(input.entries).filter(
327
- ([name]) => !parameters.includes(name),
328
- ),
329
- ),
330
- ),
331
- {
332
- ignoreActions: ["trim"],
333
- overrideSchema: (context) =>
334
- context.valibotSchema.type == "file"
335
- ? { format: "binary", type: "string" }
336
- : context.jsonSchema,
337
- },
338
- ),
339
- },
340
- },
341
- },
342
- }
343
- : undefined),
344
- responses: Object.fromEntries(
345
- Object.entries(outputs).map(([status, schema]) => [
346
- status,
347
- {
348
- content: {
349
- ["application/json"]: { schema: toJsonSchema(schema) },
350
- },
351
- description:
352
- status == "200"
353
- ? "OK"
354
- : status == "302"
355
- ? "Found"
356
- : "Bad Request",
357
- },
358
- ]),
359
- ),
360
- },
361
- ];
362
- }),
363
- ),
364
- ]),
365
- ),
366
- servers: [{ url: process.env["ORIGIN"] || "" }],
367
- };
368
-
369
- Po.set(async (lang) =>
370
- po.parse(await readFile(`po/${encodeURIComponent(lang)}.po`, "utf-8")),
371
- );
372
-
373
- const poPattern = new URLPattern(`http://*:*/po/:lang`);
374
-
375
- /**
376
- * @param {import("node:http").IncomingMessage} req
377
- * @param {import("node:http").ServerResponse<import("node:http").IncomingMessage>} res
378
- * @param {() => void} [next]
379
- */
380
- async function onRequest(req, res, next) {
381
- const lowercase = req.method?.toLowerCase();
382
- const url = new URL(`http://${req.headers.host}${req.url}`);
383
-
384
- const pathname = poPattern.exec(url)?.pathname;
385
- if (pathname) {
386
- const lang = acceptLanguage.get(pathname.groups["lang"]) || "en";
387
- if (lang != "en") {
388
- res.setHeader("content-type", "application/json");
389
- res.end(JSON.stringify(await Po.get(lang)));
390
- return;
391
- }
392
- }
393
-
394
- for (const { controller, pattern } of Object.values(routes)) {
395
- const pathname = pattern.exec(url)?.pathname;
396
- if (pathname) {
397
- for (const [method, { handler }] of Object.entries(controller)) {
398
- if (method == lowercase) {
399
- const lang =
400
- acceptLanguage.get(req.headers["accept-language"]) || "en";
401
- if (!url.searchParams.has(lang)) {
402
- url.searchParams.set("lang", lang);
403
- }
404
- for (const [name, value] of Object.entries(pathname.groups)) {
405
- if (!url.searchParams.has(name)) {
406
- url.searchParams.set(name, value || "");
407
- }
408
- }
409
- req.url = url.pathname + (url.search || "");
410
- handler(req, res);
411
- return;
412
- }
413
- }
414
-
415
- if (lowercase == "options") {
416
- res.statusCode = 204;
417
- res.setHeader(
418
- "allow",
419
- [
420
- ...new Set([
421
- "OPTIONS",
422
- ...Object.keys(controller).map((x) => x.toUpperCase()),
423
- ]),
424
- ].join(", "),
425
- );
426
- res.end();
427
- return;
428
- }
429
-
430
- res.statusCode = 405;
431
- res.end("Method Not Allowed");
432
- return;
433
- }
434
- }
435
-
436
- if (req.url == "/api" || req.url?.startsWith("/api/")) {
437
- res.statusCode = 404;
438
- res.end("Not Found");
439
- return;
440
- }
441
-
442
- if (req.url == "/openapi.json") {
443
- res.setHeader("content-type", "application/json");
444
- res.end(JSON.stringify(openapi));
445
- return;
446
- }
447
-
448
- if (!next) {
449
- res.statusCode = 404;
450
- res.end("Not Found");
451
- return;
452
- }
453
-
454
- next();
455
- }
456
-
457
- return { onRequest, openapi };
458
- }
459
-
460
- /** @type {import("./server-generic.js").ServerText} */
461
- export function text(output, status) {
462
- return {
463
- contentType: /** @type {any} */ ("text/plain"),
464
- output,
465
- status,
466
- };
467
- }
468
-
469
- /** @param {import("valibot").ObjectSchema<any, any>} input */
470
- function ArrayOf(input) {
471
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
472
- const entries = Object.values(input);
473
- /**
474
- * @param {typeof entries[0]} entry
475
- * @param {string} type
476
- */
477
- return function arrayOf(entry, type) {
478
- if (entry.type != "array") {
479
- return false;
480
- }
481
- /** @type {import("valibot").ArraySchema<any, any>} */
482
- const array = /** @type {any} */ (entry);
483
- return array.item.type == type;
484
- };
485
- }
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
+ }