evikit 2.0.0 → 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
 
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": "2.0.0",
4
+ "version": "2.1.0",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": "./index.js",
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
@@ -20,6 +20,7 @@ import { Po } from "./i18n.js";
20
20
 
21
21
  /** @type {import("./server-generic.js").ServerEndpoint} */
22
22
  export function endpoint({ input, outputs }, handler) {
23
+ const arrayOf = ArrayOf(input);
23
24
  const schema = union(
24
25
  Object.entries(outputs).map(([status, schema]) =>
25
26
  object({
@@ -34,7 +35,7 @@ export function endpoint({ input, outputs }, handler) {
34
35
  return {
35
36
  handler: async (req, res) => {
36
37
  try {
37
- /** @type {Record<string, string | string[]>} */
38
+ /** @type {Record<string, string | File | (string | File)[]>} */
38
39
  const body = {};
39
40
  if (req.headers["content-type"]) {
40
41
  const busboy = new Busboy({
@@ -44,13 +45,39 @@ export function endpoint({ input, outputs }, handler) {
44
45
  body[name] =
45
46
  typeof body[name] == "undefined"
46
47
  ? value
47
- : [
48
- ...(typeof body[name] == "string"
49
- ? [body[name]]
50
- : body[name]),
51
- value,
52
- ];
48
+ : Array.isArray(body[name])
49
+ ? [...body[name], value]
50
+ : [body[name], value];
53
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
+
54
81
  await new Promise((resolve, reject) => {
55
82
  busboy.on("error", reject).on("finish", resolve);
56
83
  req.pipe(busboy);
@@ -269,6 +296,7 @@ export function router({ pkg }, ...routes) {
269
296
  path.replace(/:(\w+)/g, "{$1}"),
270
297
  Object.fromEntries(
271
298
  Object.entries(controller).map(([method, { input, outputs }]) => {
299
+ const arrayOf = ArrayOf(input);
272
300
  const parameters = path
273
301
  .split("/")
274
302
  .filter((component) => /^:/.test(component))
@@ -286,7 +314,12 @@ export function router({ pkg }, ...routes) {
286
314
  ? {
287
315
  requestBody: {
288
316
  content: {
289
- "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"]: {
290
323
  schema: toJsonSchema(
291
324
  object(
292
325
  Object.fromEntries(
@@ -295,7 +328,13 @@ export function router({ pkg }, ...routes) {
295
328
  ),
296
329
  ),
297
330
  ),
298
- { ignoreActions: ["trim"] },
331
+ {
332
+ ignoreActions: ["trim"],
333
+ overrideSchema: (context) =>
334
+ context.valibotSchema.type == "file"
335
+ ? { format: "binary", type: "string" }
336
+ : context.jsonSchema,
337
+ },
299
338
  ),
300
339
  },
301
340
  },
@@ -426,3 +465,21 @@ export function text(output, status) {
426
465
  status,
427
466
  };
428
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/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,