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 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
- - _TODO_ Multipart forms
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("data/local.db", { schema });
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
- Create an empty `data/.gitkeep` file.
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<any, { primary: true }>; // eslint-disable-line @typescript-eslint/no-explicit-any
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
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
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.3",
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": { schema: any };
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
- ...(typeof body[name] == "string"
50
- ? [body[name]]
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
- "application/x-www-form-urlencoded": {
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
- { ignoreActions: ["trim"] },
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
- >(path: string, { schema }: { schema: Schema }): Db<Schema>;
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(path, { schema }) {
9
- const database = new DatabaseSync(path, { timeout: 1000 });
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 = new URLSearchParams();
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
- !input.disabled &&
29
- ((input.type != "checkbox" && input.type != "radio") || input.checked)
30
- ) {
31
- body.append(input.name, input.value);
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
- "Content-Type": "application/x-www-form-urlencoded",
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,