evikit 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/server.js ADDED
@@ -0,0 +1,429 @@
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 groupBy from "lodash/groupBy.js";
6
+ import { readFile } from "node:fs/promises";
7
+ import { Fragment, h } from "preact";
8
+ import { renderToString } from "preact-render-to-string";
9
+ import {
10
+ object,
11
+ optional,
12
+ parse,
13
+ picklist,
14
+ record,
15
+ string,
16
+ union,
17
+ ValiError,
18
+ } from "valibot";
19
+
20
+ import { Po } from "./i18n.js";
21
+
22
+ /** @type {import("./server-generic.js").ServerEndpoint} */
23
+ export function endpoint({ input, outputs }, handler) {
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 | string[]>} */
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
+ : [
49
+ ...(typeof body[name] == "string"
50
+ ? [body[name]]
51
+ : body[name]),
52
+ value,
53
+ ];
54
+ });
55
+ await new Promise((resolve, reject) => {
56
+ busboy.on("error", reject).on("finish", resolve);
57
+ req.pipe(busboy);
58
+ });
59
+ }
60
+
61
+ const { contentType, cookies, headers, output, status } = parse(
62
+ schema,
63
+ await handler({
64
+ input: parse(input, {
65
+ ...(req.headers.cookie
66
+ ? Object.fromEntries(
67
+ req.headers.cookie
68
+ .split("; ")
69
+ .map((row) => row.split("=").map(decodeURIComponent)),
70
+ )
71
+ : undefined),
72
+ ...Object.fromEntries(
73
+ Object.entries(
74
+ groupBy(
75
+ [
76
+ ...new URL(
77
+ `http://${req.headers.host}${req.url}`,
78
+ ).searchParams.entries(),
79
+ ],
80
+ ([name]) => name || "",
81
+ ),
82
+ ).map(([name, group]) => {
83
+ const values = group.map((value) => value[1]);
84
+ return [name, values.length == 1 ? values[0] : values];
85
+ }),
86
+ ),
87
+ ...body,
88
+ }),
89
+ }),
90
+ );
91
+
92
+ const location = headers
93
+ ? Object.entries(headers).find(
94
+ ([name]) => name.toLowerCase() == "location",
95
+ )?.[1]
96
+ : undefined;
97
+ const opaqueRedirectWorkaround =
98
+ req.headers.accept == "application/json" && location
99
+ ? { location, status }
100
+ : undefined;
101
+ const workaroundHeaders = headers
102
+ ? Object.entries(headers).filter(([name]) => {
103
+ const lower = name.toLowerCase();
104
+ return (
105
+ lower != "content-type" &&
106
+ (lower != "location" || !opaqueRedirectWorkaround)
107
+ );
108
+ })
109
+ : [];
110
+
111
+ if (cookies) {
112
+ workaroundHeaders.push([
113
+ "set-cookie",
114
+ Object.entries(cookies || "")
115
+ .map(
116
+ ([name, { value }]) =>
117
+ `${encodeURIComponent(name)}=${encodeURIComponent(value)}`,
118
+ )
119
+ .join("; "),
120
+ ]);
121
+ }
122
+
123
+ if (contentType == "application/json") {
124
+ res.statusCode = status;
125
+ res.setHeader("content-type", "application/json; charset=utf-8");
126
+ for (const [name, value] of workaroundHeaders) {
127
+ res.setHeader(name, value);
128
+ }
129
+ res.end(JSON.stringify(output));
130
+ } else if (opaqueRedirectWorkaround) {
131
+ res.setHeader("content-type", "application/json; charset=utf-8");
132
+ for (const [name, value] of workaroundHeaders) {
133
+ res.setHeader(name, value);
134
+ }
135
+ res.end(JSON.stringify({ opaqueRedirectWorkaround }));
136
+ } else {
137
+ res.statusCode = status;
138
+ res.setHeader("content-type", "text/plain; charset=utf-8");
139
+ for (const [name, value] of workaroundHeaders) {
140
+ res.setHeader(name, value);
141
+ }
142
+ res.end(output);
143
+ }
144
+ } catch (/** @type {any} */ error) {
145
+ console.error(error);
146
+ res.statusCode = error instanceof ValiError ? 400 : 500;
147
+ res.setHeader("content-type", "text/plain; charset=utf-8");
148
+ res.end(error.message);
149
+ }
150
+ },
151
+ input,
152
+ outputs,
153
+ };
154
+ }
155
+
156
+ /** @type {import("./server-generic.js").ServerJson} */
157
+ export function json(output) {
158
+ return {
159
+ contentType: /** @type {any} */ ("application/json"),
160
+ output,
161
+ status: 200,
162
+ };
163
+ }
164
+
165
+ /** @param {{ listen: (port: number, callback: () => void) => { close(): void, on(event: "error", callback: (error: unknown) => void ): void } }} app */
166
+ export function listen(app) {
167
+ const port = Number(process.env["PORT"]) || 5173;
168
+ const server = app.listen(port, () => {
169
+ console.log(
170
+ `Server started at ${process.env["ORIGIN"] || `http://localhost:${port}`}`,
171
+ );
172
+ });
173
+ server.on("error", (error) => {
174
+ console.error(error);
175
+ process.exitCode = 1;
176
+ });
177
+ process.on("SIGTERM", () => {
178
+ server.close();
179
+ });
180
+ }
181
+
182
+ /** @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 */
183
+ export function production({ html, prerender }) {
184
+ /**
185
+ * @param {import("node:http").IncomingMessage} req
186
+ * @param {import("node:http").ServerResponse<import("node:http").IncomingMessage>} res
187
+ */
188
+ return async function onRequest(req, res) {
189
+ try {
190
+ const rendered = await prerender({
191
+ lang: acceptLanguage.get(req.headers["accept-language"]) || "en",
192
+ url: req.url || "/",
193
+ });
194
+ res.statusCode =
195
+ // https://indieweb.org/meta_http-equiv_status
196
+ Number(
197
+ [...rendered.head.elements.values()]
198
+ .find(
199
+ ({ props, type }) =>
200
+ type == "meta" && props["http-equiv"] == "status",
201
+ )
202
+ ?.props["content"]?.replace(/ .*/, ""),
203
+ ) || 200;
204
+ res.setHeader("content-type", "text/html; charset=utf-8");
205
+ res.end(
206
+ html
207
+ .replace(/lang=".+?"/, () => `lang="${rendered.head.lang}"`)
208
+ .replace(/<title>.+<\/title>/, () =>
209
+ renderToString(h("title", null, rendered.head.title)),
210
+ )
211
+ .replace(/<meta http-equiv="status" content=".+?">/, "")
212
+ .replace(
213
+ `<!--app-head-->`,
214
+ renderToString(
215
+ h(
216
+ Fragment,
217
+ null,
218
+ [...rendered.head.elements.values()].map(({ props, type }) =>
219
+ h(type, props),
220
+ ),
221
+ ),
222
+ ),
223
+ )
224
+ .replace(
225
+ /(<div id="app">)[\s\S]+<!--app-html-->/,
226
+ (_, div) =>
227
+ div +
228
+ rendered.html +
229
+ '<script type="application/json" id="preact-prerender-data">' +
230
+ JSON.stringify(rendered.data).replace(/\//g, "\\/") +
231
+ "</script>",
232
+ ),
233
+ );
234
+ } catch (error) {
235
+ console.error(error);
236
+ res.statusCode = 500;
237
+ res.end("Internal Server Error");
238
+ }
239
+ };
240
+ }
241
+
242
+ /** @type {import("./server-generic.js").ServerRedirect} */
243
+ export function redirect(location, status, init) {
244
+ return {
245
+ contentType: /** @type {any} */ ("text/plain"),
246
+ cookies: init?.cookies,
247
+ headers: { location },
248
+ output: location,
249
+ status,
250
+ };
251
+ }
252
+
253
+ /**
254
+ * @param {string} path
255
+ * @param {import("./server-generic.js").MethodServer} controller */
256
+ export function route(path, controller) {
257
+ return { controller, path, pattern: new URLPattern(`http://*:*${path}`) };
258
+ }
259
+
260
+ /**
261
+ * @param {{ pkg: { name: string, version: string } }} props
262
+ * @param {ReturnType<typeof route>[]} routes
263
+ */
264
+ export function router({ pkg }, ...routes) {
265
+ const openapi = {
266
+ info: { title: pkg.name, version: pkg.version },
267
+ openapi: "3.1.0",
268
+ paths: Object.fromEntries(
269
+ Object.values(routes).map(({ controller, path }) => [
270
+ path.replace(/:(\w+)/g, "{$1}"),
271
+ Object.fromEntries(
272
+ Object.entries(controller).map(([method, { input, outputs }]) => {
273
+ const parameters = path
274
+ .split("/")
275
+ .filter((component) => /^:/.test(component))
276
+ .map((component) => component.slice(1));
277
+ return [
278
+ method,
279
+ {
280
+ parameters: parameters.map((name) => ({
281
+ in: "path",
282
+ name,
283
+ required: true,
284
+ schema: { type: "string" },
285
+ })),
286
+ ...(method == "post"
287
+ ? {
288
+ requestBody: {
289
+ content: {
290
+ "application/x-www-form-urlencoded": {
291
+ schema: toJsonSchema(
292
+ object(
293
+ Object.fromEntries(
294
+ Object.entries(input.entries).filter(
295
+ ([name]) => !parameters.includes(name),
296
+ ),
297
+ ),
298
+ ),
299
+ { ignoreActions: ["trim"] },
300
+ ),
301
+ },
302
+ },
303
+ },
304
+ }
305
+ : undefined),
306
+ responses: Object.fromEntries(
307
+ Object.entries(outputs).map(([status, schema]) => [
308
+ status,
309
+ {
310
+ content: {
311
+ ["application/json"]: { schema: toJsonSchema(schema) },
312
+ },
313
+ description:
314
+ status == "200"
315
+ ? "OK"
316
+ : status == "302"
317
+ ? "Found"
318
+ : "Bad Request",
319
+ },
320
+ ]),
321
+ ),
322
+ },
323
+ ];
324
+ }),
325
+ ),
326
+ ]),
327
+ ),
328
+ servers: [{ url: process.env["ORIGIN"] || "" }],
329
+ };
330
+
331
+ Po.set(async (lang) =>
332
+ po.parse(await readFile(`po/${encodeURIComponent(lang)}.po`, "utf-8")),
333
+ );
334
+
335
+ const poPattern = new URLPattern(`http://*:*/po/:lang`);
336
+
337
+ /**
338
+ * @param {import("node:http").IncomingMessage} req
339
+ * @param {import("node:http").ServerResponse<import("node:http").IncomingMessage>} res
340
+ * @param {() => void} [next]
341
+ */
342
+ async function onRequest(req, res, next) {
343
+ const lowercase = req.method?.toLowerCase();
344
+ const url = new URL(`http://${req.headers.host}${req.url}`);
345
+
346
+ const pathname = poPattern.exec(url)?.pathname;
347
+ if (pathname) {
348
+ const lang = acceptLanguage.get(pathname.groups["lang"]) || "en";
349
+ if (lang != "en") {
350
+ res.setHeader("content-type", "application/json");
351
+ res.end(JSON.stringify(await Po.get(lang)));
352
+ return;
353
+ }
354
+ }
355
+
356
+ for (const { controller, pattern } of Object.values(routes)) {
357
+ const pathname = pattern.exec(url)?.pathname;
358
+ if (pathname) {
359
+ for (const [method, { handler }] of Object.entries(controller)) {
360
+ if (method == lowercase) {
361
+ const lang =
362
+ acceptLanguage.get(req.headers["accept-language"]) || "en";
363
+ if (!url.searchParams.has(lang)) {
364
+ url.searchParams.set("lang", lang);
365
+ }
366
+ for (const [name, value] of Object.entries(pathname.groups)) {
367
+ if (!url.searchParams.has(name)) {
368
+ url.searchParams.set(name, value || "");
369
+ }
370
+ }
371
+ req.url = url.pathname + (url.search || "");
372
+ handler(req, res);
373
+ return;
374
+ }
375
+ }
376
+
377
+ if (lowercase == "options") {
378
+ res.statusCode = 204;
379
+ res.setHeader(
380
+ "allow",
381
+ [
382
+ ...new Set([
383
+ "OPTIONS",
384
+ ...Object.keys(controller).map((x) => x.toUpperCase()),
385
+ ]),
386
+ ].join(", "),
387
+ );
388
+ res.end();
389
+ return;
390
+ }
391
+
392
+ res.statusCode = 405;
393
+ res.end("Method Not Allowed");
394
+ return;
395
+ }
396
+ }
397
+
398
+ if (req.url == "/api" || req.url?.startsWith("/api/")) {
399
+ res.statusCode = 404;
400
+ res.end("Not Found");
401
+ return;
402
+ }
403
+
404
+ if (req.url == "/openapi.json") {
405
+ res.setHeader("content-type", "application/json");
406
+ res.end(JSON.stringify(openapi));
407
+ return;
408
+ }
409
+
410
+ if (!next) {
411
+ res.statusCode = 404;
412
+ res.end("Not Found");
413
+ return;
414
+ }
415
+
416
+ next();
417
+ }
418
+
419
+ return { onRequest, openapi };
420
+ }
421
+
422
+ /** @type {import("./server-generic.js").ServerText} */
423
+ export function text(output, status) {
424
+ return {
425
+ contentType: /** @type {any} */ ("text/plain"),
426
+ output,
427
+ status,
428
+ };
429
+ }
package/sqlite.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ import type { ObjectSchema } from "valibot";
2
+
3
+ import type { Db } from "./db.d.ts";
4
+
5
+ export function sqlite<
6
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
7
+ Schema extends Record<string, ObjectSchema<any, any>>,
8
+ >(path: string, { schema }: { schema: Schema }): Db<Schema>;