evikit 1.0.3 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -4
- package/db.d.ts +3 -3
- package/package.json +2 -3
- package/server.d.ts +3 -1
- package/server.js +68 -12
- package/sqlite.d.ts +7 -1
- package/sqlite.js +13 -2
- package/use-enhance.js +20 -8
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ Vite is a good foundation for building a server-rendered monolith SPA with an JS
|
|
|
13
13
|
- Show the language chosen by the user in browser settings
|
|
14
14
|
- Have SEO meta descriptions
|
|
15
15
|
- Generate API documentation with a Swagger overview
|
|
16
|
-
-
|
|
16
|
+
- Send multipart not urlencoded when there's a file
|
|
17
17
|
|
|
18
18
|
_TODO_ Add more simple language docs near code, don't overwhelm readers.
|
|
19
19
|
|
|
@@ -78,7 +78,6 @@ node_modules
|
|
|
78
78
|
*~
|
|
79
79
|
*swp
|
|
80
80
|
.env
|
|
81
|
-
*.db
|
|
82
81
|
```
|
|
83
82
|
|
|
84
83
|
### Database schema
|
|
@@ -114,16 +113,19 @@ Export the database at `src/lib/db/index.js`:
|
|
|
114
113
|
```js
|
|
115
114
|
import { sqlite } from "evikit/sqlite";
|
|
116
115
|
|
|
116
|
+
import pkg from "../../../package.json" with { type: "json" };
|
|
117
117
|
import * as schema from "./schema.js";
|
|
118
118
|
|
|
119
|
-
export const db = sqlite(
|
|
119
|
+
export const db = sqlite({ pkg, schema });
|
|
120
120
|
```
|
|
121
121
|
|
|
122
122
|
- See ChoiceServer below for an example of `upsert` returning a created row.
|
|
123
123
|
- See QuestionIdServer below for an example of `select` with a join.
|
|
124
124
|
- See VoteServer below for an example of `update` and `delete`.
|
|
125
125
|
|
|
126
|
-
|
|
126
|
+
The database is kept under the [OS-specific path](https://github.com/sindresorhus/env-paths) for your application, such as `~/.local/share/mysite-nodejs/db.sqlite3` on GNU/Linux.
|
|
127
|
+
|
|
128
|
+
If you want a temporary, in-memory database, omit `pkg`, pass just `{ schema }`.
|
|
127
129
|
|
|
128
130
|
### Routing
|
|
129
131
|
|
package/db.d.ts
CHANGED
|
@@ -225,17 +225,17 @@ export function flatten<
|
|
|
225
225
|
Schema extends Record<string, ObjectSchema<any, any>>, // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
226
226
|
>(relations: Relations<Schema>): Flatten<Schema>;
|
|
227
227
|
|
|
228
|
-
export function primary(): MetadataAction<
|
|
228
|
+
export function primary<T>(): MetadataAction<T, { primary: true }>;
|
|
229
229
|
|
|
230
230
|
export function references<
|
|
231
231
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
232
232
|
R extends { entries: any },
|
|
233
233
|
K extends keyof R["entries"],
|
|
234
|
+
T,
|
|
234
235
|
>(
|
|
235
236
|
references: R,
|
|
236
237
|
key: K,
|
|
237
|
-
|
|
238
|
-
): MetadataAction<any, { key: R["entries"][K]; references: R }>;
|
|
238
|
+
): MetadataAction<T, { key: R["entries"][K]; references: R }>;
|
|
239
239
|
|
|
240
240
|
export function relations<
|
|
241
241
|
Schema extends Record<string, ObjectSchema<any, any>>, // eslint-disable-line @typescript-eslint/no-explicit-any
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "evikit",
|
|
3
3
|
"description": "Preact/SQLite SSR framework for small web apps",
|
|
4
|
-
"version": "1.0
|
|
4
|
+
"version": "2.1.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": "./index.js",
|
|
@@ -24,12 +24,11 @@
|
|
|
24
24
|
"@fastify/busboy": "^3.2.0",
|
|
25
25
|
"@types/gettext-parser": "^9.0.0",
|
|
26
26
|
"@types/gettext.js": "^2.0.0",
|
|
27
|
-
"@types/lodash": "^4.17.24",
|
|
28
27
|
"@types/node": "^25",
|
|
29
28
|
"@valibot/to-json-schema": "^1.7.0",
|
|
29
|
+
"env-paths": "^4.0.0",
|
|
30
30
|
"gettext-parser": "^9.0.2",
|
|
31
31
|
"gettext.js": "^2.0.3",
|
|
32
|
-
"lodash": "^4.18.1",
|
|
33
32
|
"preact-render-to-string": "^6.7.0"
|
|
34
33
|
},
|
|
35
34
|
"peerDependencies": {
|
package/server.d.ts
CHANGED
|
@@ -91,7 +91,9 @@ export function router(
|
|
|
91
91
|
| {
|
|
92
92
|
content: {
|
|
93
93
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
94
|
-
"application/x-www-form-urlencoded"
|
|
94
|
+
"application/x-www-form-urlencoded"?: { schema: any };
|
|
95
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
96
|
+
"multipart/form-data"?: { schema: any };
|
|
95
97
|
};
|
|
96
98
|
};
|
|
97
99
|
responses: Record<
|
package/server.js
CHANGED
|
@@ -2,7 +2,6 @@ import { Busboy } from "@fastify/busboy";
|
|
|
2
2
|
import { toJsonSchema } from "@valibot/to-json-schema";
|
|
3
3
|
import acceptLanguage from "accept-language";
|
|
4
4
|
import { po } from "gettext-parser";
|
|
5
|
-
import groupBy from "lodash/groupBy.js";
|
|
6
5
|
import { readFile } from "node:fs/promises";
|
|
7
6
|
import { Fragment, h } from "preact";
|
|
8
7
|
import { renderToString } from "preact-render-to-string";
|
|
@@ -21,6 +20,7 @@ import { Po } from "./i18n.js";
|
|
|
21
20
|
|
|
22
21
|
/** @type {import("./server-generic.js").ServerEndpoint} */
|
|
23
22
|
export function endpoint({ input, outputs }, handler) {
|
|
23
|
+
const arrayOf = ArrayOf(input);
|
|
24
24
|
const schema = union(
|
|
25
25
|
Object.entries(outputs).map(([status, schema]) =>
|
|
26
26
|
object({
|
|
@@ -35,7 +35,7 @@ export function endpoint({ input, outputs }, handler) {
|
|
|
35
35
|
return {
|
|
36
36
|
handler: async (req, res) => {
|
|
37
37
|
try {
|
|
38
|
-
/** @type {Record<string, string | string[]>} */
|
|
38
|
+
/** @type {Record<string, string | File | (string | File)[]>} */
|
|
39
39
|
const body = {};
|
|
40
40
|
if (req.headers["content-type"]) {
|
|
41
41
|
const busboy = new Busboy({
|
|
@@ -45,13 +45,39 @@ export function endpoint({ input, outputs }, handler) {
|
|
|
45
45
|
body[name] =
|
|
46
46
|
typeof body[name] == "undefined"
|
|
47
47
|
? value
|
|
48
|
-
: [
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
: body[name]),
|
|
52
|
-
value,
|
|
53
|
-
];
|
|
48
|
+
: Array.isArray(body[name])
|
|
49
|
+
? [...body[name], value]
|
|
50
|
+
: [body[name], value];
|
|
54
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
|
+
|
|
55
81
|
await new Promise((resolve, reject) => {
|
|
56
82
|
busboy.on("error", reject).on("finish", resolve);
|
|
57
83
|
req.pipe(busboy);
|
|
@@ -71,7 +97,7 @@ export function endpoint({ input, outputs }, handler) {
|
|
|
71
97
|
: undefined),
|
|
72
98
|
...Object.fromEntries(
|
|
73
99
|
Object.entries(
|
|
74
|
-
groupBy(
|
|
100
|
+
Object.groupBy(
|
|
75
101
|
[
|
|
76
102
|
...new URL(
|
|
77
103
|
`http://${req.headers.host}${req.url}`,
|
|
@@ -80,7 +106,7 @@ export function endpoint({ input, outputs }, handler) {
|
|
|
80
106
|
([name]) => name || "",
|
|
81
107
|
),
|
|
82
108
|
).map(([name, group]) => {
|
|
83
|
-
const values = group.map((value) => value[1]);
|
|
109
|
+
const values = (group || []).map((value) => value[1]);
|
|
84
110
|
return [name, values.length == 1 ? values[0] : values];
|
|
85
111
|
}),
|
|
86
112
|
),
|
|
@@ -270,6 +296,7 @@ export function router({ pkg }, ...routes) {
|
|
|
270
296
|
path.replace(/:(\w+)/g, "{$1}"),
|
|
271
297
|
Object.fromEntries(
|
|
272
298
|
Object.entries(controller).map(([method, { input, outputs }]) => {
|
|
299
|
+
const arrayOf = ArrayOf(input);
|
|
273
300
|
const parameters = path
|
|
274
301
|
.split("/")
|
|
275
302
|
.filter((component) => /^:/.test(component))
|
|
@@ -287,7 +314,12 @@ export function router({ pkg }, ...routes) {
|
|
|
287
314
|
? {
|
|
288
315
|
requestBody: {
|
|
289
316
|
content: {
|
|
290
|
-
|
|
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"]: {
|
|
291
323
|
schema: toJsonSchema(
|
|
292
324
|
object(
|
|
293
325
|
Object.fromEntries(
|
|
@@ -296,7 +328,13 @@ export function router({ pkg }, ...routes) {
|
|
|
296
328
|
),
|
|
297
329
|
),
|
|
298
330
|
),
|
|
299
|
-
{
|
|
331
|
+
{
|
|
332
|
+
ignoreActions: ["trim"],
|
|
333
|
+
overrideSchema: (context) =>
|
|
334
|
+
context.valibotSchema.type == "file"
|
|
335
|
+
? { format: "binary", type: "string" }
|
|
336
|
+
: context.jsonSchema,
|
|
337
|
+
},
|
|
300
338
|
),
|
|
301
339
|
},
|
|
302
340
|
},
|
|
@@ -427,3 +465,21 @@ export function text(output, status) {
|
|
|
427
465
|
status,
|
|
428
466
|
};
|
|
429
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
|
+
}
|
package/sqlite.d.ts
CHANGED
|
@@ -5,4 +5,10 @@ import type { Db } from "./db.d.ts";
|
|
|
5
5
|
export function sqlite<
|
|
6
6
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
7
7
|
Schema extends Record<string, ObjectSchema<any, any>>,
|
|
8
|
-
>(
|
|
8
|
+
>({
|
|
9
|
+
pkg,
|
|
10
|
+
schema,
|
|
11
|
+
}: {
|
|
12
|
+
pkg?: { name: string; version: string };
|
|
13
|
+
schema: Schema;
|
|
14
|
+
}): Db<Schema>;
|
package/sqlite.js
CHANGED
|
@@ -1,12 +1,23 @@
|
|
|
1
|
+
import envPaths from "env-paths";
|
|
1
2
|
import { fail, ok } from "node:assert/strict";
|
|
3
|
+
import { mkdirSync } from "node:fs";
|
|
2
4
|
import { DatabaseSync } from "node:sqlite";
|
|
3
5
|
import { getMetadata, parse } from "valibot";
|
|
4
6
|
|
|
5
7
|
import { flatten, relations } from "./db.js";
|
|
6
8
|
|
|
7
9
|
/** @type {typeof import("./sqlite.d.ts").sqlite} */
|
|
8
|
-
export function sqlite(
|
|
9
|
-
const
|
|
10
|
+
export function sqlite({ pkg, schema }) {
|
|
11
|
+
const data = pkg
|
|
12
|
+
? (() => {
|
|
13
|
+
const { data } = envPaths(pkg.name);
|
|
14
|
+
mkdirSync(data, { recursive: true });
|
|
15
|
+
return data;
|
|
16
|
+
})()
|
|
17
|
+
: undefined;
|
|
18
|
+
const database = new DatabaseSync(data ? `${data}/db.sqlite3` : ":memory:", {
|
|
19
|
+
timeout: 1000,
|
|
20
|
+
});
|
|
10
21
|
const flat = flatten(relations(schema));
|
|
11
22
|
|
|
12
23
|
const haveTables = /** @type {{ name: string }[]} */ (
|
package/use-enhance.js
CHANGED
|
@@ -21,14 +21,23 @@ export function useEnhance(schema, onSuccess) {
|
|
|
21
21
|
setBusy(true);
|
|
22
22
|
const form = /** @type {HTMLFormElement} */ (event.target);
|
|
23
23
|
let output;
|
|
24
|
+
|
|
24
25
|
try {
|
|
25
|
-
const body =
|
|
26
|
+
const body = form.querySelector('[type="file"]')
|
|
27
|
+
? new FormData()
|
|
28
|
+
: new URLSearchParams();
|
|
26
29
|
for (const input of [...form.querySelectorAll("input")]) {
|
|
27
|
-
if (
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
if (!input.disabled) {
|
|
31
|
+
if (input.type == "file" && body instanceof FormData) {
|
|
32
|
+
for (const file of input.files || []) {
|
|
33
|
+
body.append(input.name, file, file.name);
|
|
34
|
+
}
|
|
35
|
+
} else if (
|
|
36
|
+
(input.type != "checkbox" && input.type != "radio") ||
|
|
37
|
+
input.checked
|
|
38
|
+
) {
|
|
39
|
+
body.append(input.name, input.value);
|
|
40
|
+
}
|
|
32
41
|
}
|
|
33
42
|
}
|
|
34
43
|
for (const select of [...form.querySelectorAll("select")]) {
|
|
@@ -41,11 +50,14 @@ export function useEnhance(schema, onSuccess) {
|
|
|
41
50
|
body.append(textarea.name, textarea.value);
|
|
42
51
|
}
|
|
43
52
|
}
|
|
53
|
+
|
|
44
54
|
const response = await fetch(form.action, {
|
|
45
|
-
body: body.toString(),
|
|
55
|
+
body: body instanceof FormData ? body : body.toString(),
|
|
46
56
|
headers: {
|
|
47
57
|
Accept: "application/json",
|
|
48
|
-
|
|
58
|
+
...(body instanceof FormData
|
|
59
|
+
? {}
|
|
60
|
+
: { "Content-Type": "application/x-www-form-urlencoded" }),
|
|
49
61
|
Cookie: jsdomCookie.setCookie.join("; "),
|
|
50
62
|
},
|
|
51
63
|
method: form.method,
|