evikit 2.7.3 → 2.8.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
@@ -6,6 +6,8 @@ Vite is a good foundation for building a server-rendered monolith SPA with an JS
6
6
 
7
7
  - For each `/path`, fetch `/api/path` and SSR the page
8
8
  - Send urlencoded forms and refresh `/api/path` without a full page reload
9
+ - Live-refresh `/api/path` whenever its database tables update
10
+ - Note: added in 2.8. To opt in, pass `db` to `router`
9
11
  - Validate API input and output against your schemas
10
12
  - Input is: cookies + url params + body (each overrides previous if key exists)
11
13
  - Note: PHP [removed cookies from this list](https://stackoverflow.com/questions/51538622/php-ini-request-order-security-concerns#comment90071163_51538946) but I think it's ok if used carefully
@@ -194,6 +196,7 @@ Add an API router at `src/api.js` (uncomment the routes one by one when we add t
194
196
  import { route, router } from "evikit/server";
195
197
 
196
198
  import pkg from "../package.json" with { type: "json" };
199
+ import { db } from "./lib/db/index.js";
197
200
  import { acceptLanguages } from "./lib/index.js";
198
201
  // import { ChoiceServer } from "./routes/api/choice/server.js";
199
202
  // import { QuestionIdServer } from "./routes/api/question/[id]/server.js";
@@ -204,7 +207,7 @@ acceptLanguages();
204
207
 
205
208
  export function api() {
206
209
  return router(
207
- { pkg },
210
+ { db, pkg },
208
211
  // route("/api", HomeServer),
209
212
  // route("/api/choice", ChoiceServer),
210
213
  // route("/api/question/:id", QuestionIdServer),
package/db.d.ts CHANGED
@@ -46,6 +46,9 @@ export type Db<
46
46
  ): InferOutput<Schema[Table]>[];
47
47
  }
48
48
  : never;
49
+ } & {
50
+ schema: Schema;
51
+ subscribe(onMessage: ({ table }: { table: string }) => void): () => void;
49
52
  };
50
53
 
51
54
  export type Flatten<
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.7.3",
4
+ "version": "2.8.0",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": "./index.js",
package/query.js CHANGED
@@ -6,6 +6,7 @@ import { useContext, useEffect, useState } from "preact/hooks";
6
6
  * @property {any} data
7
7
  * @property {number} dataUpdatedAt
8
8
  * @property {unknown | null} error
9
+ * @property {boolean} fromEventSource
9
10
  * @property {boolean} isInvalidated
10
11
  * @property {"error" | "loading" | "success"} status
11
12
  */
@@ -37,18 +38,23 @@ export class QueryClient {
37
38
  const queryHash = QueryHash(queryKey);
38
39
  this.state.setQueries((queries) =>
39
40
  queries.map((query) =>
40
- query.queryHash == queryHash ||
41
- query.queryHash.startsWith(queryHash + "/")
41
+ (query.queryHash == queryHash ||
42
+ query.queryHash.startsWith(queryHash + "/")) &&
43
+ !query.state.fromEventSource
42
44
  ? { ...query, state: { ...query.state, isInvalidated: true } }
43
45
  : query,
44
46
  ),
45
47
  );
46
48
  } else {
47
49
  this.state.setQueries((queries) =>
48
- queries.map((query) => ({
49
- ...query,
50
- state: { ...query.state, isInvalidated: true },
51
- })),
50
+ queries.map((query) =>
51
+ query.state.fromEventSource
52
+ ? query
53
+ : {
54
+ ...query,
55
+ state: { ...query.state, isInvalidated: true },
56
+ },
57
+ ),
52
58
  );
53
59
  }
54
60
  }
@@ -56,19 +62,7 @@ export class QueryClient {
56
62
  /** @param {{ queryKey: string[], queryFn: () => Promise<any> }} props */
57
63
  async prefetchQuery({ queryFn, queryKey }) {
58
64
  const queryHash = QueryHash(queryKey);
59
- /** @param {(queries: Queries) => QueryState} map */
60
- const setQueryState = (map) =>
61
- this.state.setQueries((queries) => {
62
- const state = map(queries);
63
- const existing = queries.find(
64
- (query) => query.queryHash == queryHash,
65
- )?.state;
66
- return existing
67
- ? queries.map((query) =>
68
- query.state == existing ? { queryHash, state } : query,
69
- )
70
- : [...queries, { queryHash, state }];
71
- });
65
+ const setQueryState = SetQueryState({ queryClient: this, queryKey });
72
66
 
73
67
  const state = this.state.queries.find(
74
68
  (query) => query.queryHash == queryHash,
@@ -76,6 +70,7 @@ export class QueryClient {
76
70
  if (
77
71
  !state ||
78
72
  (state.status != "loading" &&
73
+ !state.fromEventSource &&
79
74
  (state.isInvalidated ||
80
75
  state.dataUpdatedAt <
81
76
  Date.now() - this.defaultOptions.queries.staleTime))
@@ -84,6 +79,7 @@ export class QueryClient {
84
79
  data: state?.data ?? null,
85
80
  dataUpdatedAt: state?.dataUpdatedAt ?? 0,
86
81
  error: state?.error ?? null,
82
+ fromEventSource: state?.fromEventSource ?? false,
87
83
  isInvalidated: false,
88
84
  status: "loading",
89
85
  }));
@@ -93,16 +89,16 @@ export class QueryClient {
93
89
  data,
94
90
  dataUpdatedAt: Date.now(),
95
91
  error: null,
92
+ fromEventSource: false,
96
93
  isInvalidated: false,
97
94
  status: "success",
98
95
  }));
99
96
  } catch (error) {
100
- setQueryState((queries) => ({
101
- data:
102
- queries.find((query) => query.queryHash == queryHash)?.state.data ??
103
- null,
97
+ setQueryState((existing) => ({
98
+ data: existing?.data ?? null,
104
99
  dataUpdatedAt: Date.now(),
105
100
  error,
101
+ fromEventSource: false,
106
102
  isInvalidated: false,
107
103
  status: "error",
108
104
  }));
@@ -141,6 +137,26 @@ export function dehydrate(queryClient, options) {
141
137
  };
142
138
  }
143
139
 
140
+ /** @param {{ queryClient: QueryClient, queryKey: string[] }} props */
141
+ export function SetQueryState({ queryClient, queryKey }) {
142
+ const queryHash = QueryHash(queryKey);
143
+ /** @param {(existing: QueryState | undefined) => QueryState | undefined} map */
144
+ return (map) =>
145
+ queryClient.state.setQueries((queries) => {
146
+ const existing = queries.find(
147
+ (query) => query.queryHash == queryHash,
148
+ )?.state;
149
+ const state = map(existing);
150
+ return state
151
+ ? existing
152
+ ? queries.map((query) =>
153
+ query.state == existing ? { queryHash, state } : query,
154
+ )
155
+ : [...queries, { queryHash, state }]
156
+ : queries;
157
+ });
158
+ }
159
+
144
160
  const QueryClientContext = createContext(
145
161
  /** @type {QueryClient} */ (/** @type {any} */ (undefined)),
146
162
  );
@@ -16,7 +16,14 @@ import {
16
16
  export type MethodServer = Record<
17
17
  string,
18
18
  {
19
- handler(req: IncomingMessage, res: ServerResponse<IncomingMessage>): void;
19
+ handler(
20
+ req: IncomingMessage,
21
+ res: {
22
+ end(body?: string): void;
23
+ setHeader(name: string, value: string): void;
24
+ set statusCode(code: number): void;
25
+ },
26
+ ): void;
20
27
  input: ObjectSchema<ObjectEntries, undefined>;
21
28
  outputs: Record<
22
29
  number,
package/server.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { IncomingMessage, ServerResponse } from "node:http";
2
2
 
3
+ import type { Db } from "./db.d.ts";
3
4
  import type {
4
5
  MethodServer,
5
6
  ServerEndpoint,
@@ -57,10 +58,15 @@ export function route(
57
58
  pattern: URLPattern;
58
59
  };
59
60
 
60
- export function router(
61
+ export function router<
62
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
63
+ Schema extends Record<string, ObjectSchema<any, any>>,
64
+ >(
61
65
  {
66
+ db,
62
67
  pkg,
63
68
  }: {
69
+ db?: Db<Schema>;
64
70
  pkg: { name: string; version: string };
65
71
  },
66
72
  ...routes: {
package/server.js CHANGED
@@ -211,8 +211,20 @@ export function listen(app) {
211
211
  Number(process.env["PARALLELISM"]) || availableParallelism();
212
212
  const port = Number(process.env["PORT"]) || 5173;
213
213
  if (cluster.isPrimary && parallelism > 1) {
214
+ /** @type {{ on(event: "message", callback: (message: any) => void): void, send(message: any, _: any, options: { keepOpen: boolean }): void }[]} */
215
+ const forks = [];
214
216
  for (let i = 0; i < parallelism; i++) {
215
- cluster.fork({ i });
217
+ forks.push(cluster.fork({ i }));
218
+ }
219
+ for (const fork of forks) {
220
+ fork.on("message", (maybeMessage) => {
221
+ const message = /** @type {any} */ (maybeMessage);
222
+ for (let i = 0; i < forks.length; i++) {
223
+ if (i != message.i) {
224
+ forks[i]?.send(message, undefined, { keepOpen: true });
225
+ }
226
+ }
227
+ });
216
228
  }
217
229
  } else {
218
230
  const server = app.listen(port, () => {
@@ -318,10 +330,11 @@ export function route(path, controller) {
318
330
  }
319
331
 
320
332
  /**
321
- * @param {{ pkg: { name: string, version: string } }} props
333
+ * // eslint-disable-next-line @typescript-eslint/no-explicit-any
334
+ * @param {{ db?: import("./db.js").Db<any>, pkg: { name: string, version: string } }} props
322
335
  * @param {ReturnType<typeof route>[]} routes
323
336
  */
324
- export function router({ pkg }, ...routes) {
337
+ export function router({ db, pkg }, ...routes) {
325
338
  const openapi = {
326
339
  info: { title: pkg.name, version: pkg.version },
327
340
  openapi: "3.1.0",
@@ -423,6 +436,46 @@ export function router({ pkg }, ...routes) {
423
436
 
424
437
  const poPattern = new URLPattern(`http://*:*/po/:lang`);
425
438
 
439
+ /** @type {Map<(req: any, res: any) => void, string[]>} */
440
+ const TablesInOutput = new Map();
441
+ for (const { controller } of Object.values(routes)) {
442
+ for (const [method, { handler, outputs }] of Object.entries(controller)) {
443
+ if (method == "get") {
444
+ /** @type {string[]} */
445
+ let tablesInOutput = [];
446
+
447
+ /** @param {import("valibot").ArraySchema<any, any> | import("valibot").ObjectSchema<import("valibot").ObjectEntries, any> | import("valibot").StringSchema<any>} schema */
448
+ function walk(schema) {
449
+ if (schema.type == "array") {
450
+ walk(schema.item);
451
+ } else if (schema.type == "object") {
452
+ for (const entry of Object.values(schema.entries)) {
453
+ walk(/** @type {any} */ (entry));
454
+ }
455
+ } else if (db) {
456
+ for (const [name, table] of Object.entries(
457
+ /** @type {Record<string, import("valibot").ObjectSchema<import("valibot").ObjectEntries, any>>} */ (
458
+ db.schema
459
+ ),
460
+ )) {
461
+ for (const column of Object.values(table.entries)) {
462
+ if (column == schema) {
463
+ tablesInOutput.push(name);
464
+ }
465
+ }
466
+ }
467
+ }
468
+ }
469
+ for (const schema of Object.values(outputs)) {
470
+ walk(schema);
471
+ }
472
+ if (tablesInOutput.length) {
473
+ TablesInOutput.set(handler, [...new Set(tablesInOutput)]);
474
+ }
475
+ }
476
+ }
477
+ }
478
+
426
479
  /**
427
480
  * @param {import("node:http").IncomingMessage} req
428
481
  * @param {import("node:http").ServerResponse<import("node:http").IncomingMessage>} res
@@ -458,6 +511,61 @@ export function router({ pkg }, ...routes) {
458
511
  }
459
512
  }
460
513
  req.url = url.pathname + (url.search || "");
514
+
515
+ if (method == "get" && req.headers.accept == "text/event-stream") {
516
+ res.setHeader("x-accel-buffering", "no");
517
+ res.setHeader("content-type", "text/event-stream");
518
+ res.setHeader("cache-control", "no-cache");
519
+ let lastData = "";
520
+ let lastId = 0;
521
+ const tablesInOutput = TablesInOutput.get(handler);
522
+ if (tablesInOutput && db) {
523
+ req.on(
524
+ "close",
525
+ db.subscribe(({ table }) => {
526
+ if (tablesInOutput.includes(table)) {
527
+ /** @type {Record<string, string>} */
528
+ const headers = {};
529
+ let statusCode = 200;
530
+ let id = ++lastId;
531
+ handler(req, {
532
+ end(body) {
533
+ if (id == lastId) {
534
+ const data = JSON.stringify({
535
+ body,
536
+ headers,
537
+ statusCode,
538
+ });
539
+ if (lastData != data) {
540
+ lastData = data;
541
+ res.write(`data: ${data}\n\n`);
542
+ const expressCompressionResponse =
543
+ /** @type {any} */ (res);
544
+ if (
545
+ typeof expressCompressionResponse.flush ==
546
+ "function"
547
+ ) {
548
+ expressCompressionResponse.flush();
549
+ }
550
+ }
551
+ }
552
+ },
553
+ setHeader(name, value) {
554
+ headers[name] = value;
555
+ },
556
+ set statusCode(/** @type {number} */ value) {
557
+ statusCode = value;
558
+ },
559
+ });
560
+ }
561
+ }),
562
+ );
563
+ } else {
564
+ res.end("data: null\n\n");
565
+ }
566
+ return;
567
+ }
568
+
461
569
  handler(req, res);
462
570
  return;
463
571
  }
package/sqlite.js CHANGED
@@ -277,109 +277,153 @@ export function sqlite({ pkg, schema }) {
277
277
  database.exec(sql);
278
278
  }
279
279
 
280
- return /** @type {any} */ (
281
- Object.fromEntries(
282
- Object.entries(schema).map(([maybeTable]) => {
283
- const table = /** @type {keyof typeof schema & string} */ (maybeTable);
284
-
285
- /** @param {{ [K in keyof (typeof schema)[typeof table]["entries"]]?: import("valibot").InferOutput<(typeof schema)[typeof table]["entries"][K]> | import("valibot").InferOutput<(typeof schema)[typeof table]["entries"][K]>[]; }} where */
286
- function ensureArrayRecord(where) {
287
- for (const key of Object.keys(where)) {
288
- if (!Array.isArray(where[key])) {
289
- Object.assign(where, { [key]: [where[key]] });
280
+ /** @type {(({ table }: { table: string }) => void)[]} */
281
+ const listeners = [];
282
+ if (process.env["i"]) {
283
+ process.on("message", (maybeMessage) => {
284
+ for (const onMessage of listeners) {
285
+ onMessage(/** @type {any} */ (maybeMessage));
286
+ }
287
+ });
288
+ }
289
+ /** @param {{ table: string }} message */
290
+ function publish(message) {
291
+ Object.assign(message, { i: process.env["i"] });
292
+ process.send?.(message, undefined, { keepOpen: true });
293
+ for (const onMessage of listeners) {
294
+ onMessage(message);
295
+ }
296
+ }
297
+
298
+ return {
299
+ .../** @type {any} */ (
300
+ Object.fromEntries(
301
+ Object.entries(schema).map(([maybeTable]) => {
302
+ const table = /** @type {keyof typeof schema & string} */ (
303
+ maybeTable
304
+ );
305
+
306
+ /** @param {{ [K in keyof (typeof schema)[typeof table]["entries"]]?: import("valibot").InferOutput<(typeof schema)[typeof table]["entries"][K]> | import("valibot").InferOutput<(typeof schema)[typeof table]["entries"][K]>[]; }} where */
307
+ function ensureArrayRecord(where) {
308
+ for (const key of Object.keys(where)) {
309
+ if (!Array.isArray(where[key])) {
310
+ Object.assign(where, { [key]: [where[key]] });
311
+ }
290
312
  }
291
313
  }
292
- }
293
314
 
294
- ok(schema[table]);
295
- const columns = Object.entries(schema[table].entries);
296
- const primaryKey = columns.find(
297
- ([, column]) =>
298
- /** @type {{ primary?: true }} */ (getMetadata(column)).primary,
299
- );
300
- ok(primaryKey);
301
- const upsert = database.prepare(
302
- `insert into ${table} (${columns.map(([name]) => name).join(", ")}) values\n` +
303
- `(${columns.map(() => "?").join(", ")})\n` +
304
- `on conflict (${primaryKey[0]}) do update set\n` +
305
- columns.map(([name]) => `${name} = ?`).join(",\n"),
306
- );
315
+ ok(schema[table]);
316
+ const columns = Object.entries(schema[table].entries);
317
+ const primaryKey = columns.find(
318
+ ([, column]) =>
319
+ /** @type {{ primary?: true }} */ (getMetadata(column)).primary,
320
+ );
321
+ ok(primaryKey);
322
+ const upsert = database.prepare(
323
+ `insert into ${table} (${columns.map(([name]) => name).join(", ")}) values\n` +
324
+ `(${columns.map(() => "?").join(", ")})\n` +
325
+ `on conflict (${primaryKey[0]}) do update set\n` +
326
+ columns.map(([name]) => `${name} = ?`).join(",\n"),
327
+ );
307
328
 
308
- return [
309
- table,
310
- {
311
- /** @param {{ [K in keyof (typeof schema)[typeof table]["entries"]]?: import("valibot").InferOutput<(typeof schema)[typeof table]["entries"][K]> | import("valibot").InferOutput<(typeof schema)[typeof table]["entries"][K]>[]; }} where */
312
- delete(where) {
313
- ensureArrayRecord(where);
314
- database
315
- .prepare(
316
- `delete from ${table} where true\n` +
317
- Object.entries(where)
318
- .map(
319
- ([name, values]) =>
320
- `and ${name} in (${values.map(() => "?").join(", ")})`,
321
- )
322
- .join("\n"),
323
- )
324
- .run(
325
- ...Object.values(where)
326
- .flatMap((values) => values)
327
- .map((value) => value ?? null),
328
- );
329
- },
329
+ return [
330
+ table,
331
+ {
332
+ /** @param {{ [K in keyof (typeof schema)[typeof table]["entries"]]?: import("valibot").InferOutput<(typeof schema)[typeof table]["entries"][K]> | import("valibot").InferOutput<(typeof schema)[typeof table]["entries"][K]>[]; }} where */
333
+ delete(where) {
334
+ ensureArrayRecord(where);
335
+ if (
336
+ database
337
+ .prepare(
338
+ `delete from ${table} where true\n` +
339
+ Object.entries(where)
340
+ .map(
341
+ ([name, values]) =>
342
+ `and ${name} in (${values.map(() => "?").join(", ")})`,
343
+ )
344
+ .join("\n"),
345
+ )
346
+ .run(
347
+ ...Object.values(where)
348
+ .flatMap((values) => values)
349
+ .map((value) => value ?? null),
350
+ ).changes
351
+ ) {
352
+ publish({ table });
353
+ }
354
+ },
330
355
 
331
- /** @param {any} join */
332
- select(join) {
333
- if (join?.where) {
334
- ensureArrayRecord(join.where);
335
- }
336
- return select({ database, flat, join, schema, table });
337
- },
356
+ /** @param {any} join */
357
+ select(join) {
358
+ if (join?.where) {
359
+ ensureArrayRecord(join.where);
360
+ }
361
+ return select({ database, flat, join, schema, table });
362
+ },
338
363
 
339
- /** @param {{ set: Partial<import("valibot").InferOutput<(typeof schema)[typeof table]>>, where: { [K in keyof (typeof schema)[typeof table]["entries"]]?: import("valibot").InferOutput<(typeof schema)[typeof table]["entries"][K]> | import("valibot").InferOutput<(typeof schema)[typeof table]["entries"][K]>[]; } }} props */
340
- update({ set, where }) {
341
- ensureArrayRecord(where);
342
- database
343
- .prepare(
344
- `update ${table} set\n` +
345
- Object.keys(set)
346
- .map((name) => `${name} = ?`)
347
- .join(",\n") +
348
- `\nwhere true\n` +
349
- Object.entries(where)
350
- .map(
351
- ([name, values]) =>
352
- `and ${name} in (${values.map(() => "?").join(", ")})`,
353
- )
354
- .join("\n"),
355
- )
356
- .run(
357
- ...[
358
- ...Object.values(set).map((value) => value ?? null),
359
- ...Object.values(where)
360
- .flatMap((values) => values)
361
- .map((value) => value ?? null),
362
- ],
363
- );
364
- },
364
+ /** @param {{ set: Partial<import("valibot").InferOutput<(typeof schema)[typeof table]>>, where: { [K in keyof (typeof schema)[typeof table]["entries"]]?: import("valibot").InferOutput<(typeof schema)[typeof table]["entries"][K]> | import("valibot").InferOutput<(typeof schema)[typeof table]["entries"][K]>[]; } }} props */
365
+ update({ set, where }) {
366
+ ensureArrayRecord(where);
367
+ if (
368
+ database
369
+ .prepare(
370
+ `update ${table} set\n` +
371
+ Object.keys(set)
372
+ .map((name) => `${name} = ?`)
373
+ .join(",\n") +
374
+ `\nwhere true\n` +
375
+ Object.entries(where)
376
+ .map(
377
+ ([name, values]) =>
378
+ `and ${name} in (${values.map(() => "?").join(", ")})`,
379
+ )
380
+ .join("\n"),
381
+ )
382
+ .run(
383
+ ...[
384
+ ...Object.values(set).map((value) => value ?? null),
385
+ ...Object.values(where)
386
+ .flatMap((values) => values)
387
+ .map((value) => value ?? null),
388
+ ],
389
+ ).changes
390
+ ) {
391
+ publish({ table });
392
+ }
393
+ },
365
394
 
366
- /** @param {any[]} values */
367
- upsert(...values) {
368
- ok(schema[table]);
369
- const outputs = [];
370
- for (const row of values) {
371
- const output = parse(schema[table], row);
372
- const values = columns.map(([name]) => output[name] ?? null);
373
- upsert.run(...values, ...values);
374
- outputs.push(output);
375
- }
376
- return outputs;
395
+ /** @param {any[]} values */
396
+ upsert(...values) {
397
+ ok(schema[table]);
398
+ const outputs = [];
399
+ /** @type {boolean} */
400
+ let changes = false;
401
+ for (const row of values) {
402
+ const output = parse(schema[table], row);
403
+ const values = columns.map(([name]) => output[name] ?? null);
404
+ if (upsert.run(...values, ...values).changes) {
405
+ changes = true;
406
+ }
407
+ outputs.push(output);
408
+ }
409
+ if (changes) {
410
+ publish({ table });
411
+ }
412
+ return outputs;
413
+ },
377
414
  },
378
- },
379
- ];
380
- }),
381
- )
382
- );
415
+ ];
416
+ }),
417
+ )
418
+ ),
419
+ schema,
420
+ subscribe(onMessage) {
421
+ listeners.push(onMessage);
422
+ return () => {
423
+ listeners.splice(listeners.indexOf(onMessage), 1);
424
+ };
425
+ },
426
+ };
383
427
  }
384
428
 
385
429
  /** @type {<Schema extends Record<string, import("valibot").ObjectSchema<any, any>>, R extends import("./db.d.ts").Relations<Schema> = import("./db.d.ts").Relations<Schema>, F extends import("./db.d.ts").Flatten<Schema> = import("./db.d.ts").Flatten<Schema>, Table extends keyof F & keyof Schema = keyof F & keyof Schema >(props: { database: DatabaseSync; flat: F; join?: import("./db.d.ts").Join<R, Table> & { where?: { [K in keyof Schema[Table]["entries"]]?: import("valibot").InferOutput<Schema[Table]["entries"][K]> | import("valibot").InferOutput<Schema[Table]["entries"][K]>[]; } }; schema: Schema; table: Table; }) => any[]} */
package/use-api.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { useRoute } from "preact-iso";
2
+ import { useEffect, useState } from "preact/hooks";
2
3
  import { parse } from "valibot";
3
4
 
4
5
  import { jsdomCookie } from "./jsdom-cookie.js";
5
- import { useQuery } from "./query.js";
6
+ import { SetQueryState, useQuery, useQueryClient } from "./query.js";
6
7
 
7
8
  /** @param {{ lang?: string, origin: string | undefined, path: string, schema?: any }} props */
8
9
  export function queryApi({ lang, origin, path, schema }) {
@@ -11,16 +12,12 @@ export function queryApi({ lang, origin, path, schema }) {
11
12
 
12
13
  return {
13
14
  queryFn: async () => {
14
- const response = await fetch(
15
- (origin || "") +
16
- path.replace(/^\//, "/api/").replace(/^\/api\/(\?|$)/, "/api$1"),
17
- {
18
- headers: {
19
- ...(lang ? { "Accept-Language": lang } : {}),
20
- Cookie: jsdomCookie.setCookie.join("; "),
21
- },
15
+ const response = await fetch(ApiPath({ origin, path }), {
16
+ headers: {
17
+ ...(lang ? { "Accept-Language": lang } : {}),
18
+ Cookie: jsdomCookie.setCookie.join("; "),
22
19
  },
23
- );
20
+ });
24
21
  if (response.status >= 400) {
25
22
  const error = new Error(await response.text());
26
23
  error.name = response.status.toString();
@@ -35,13 +32,74 @@ export function queryApi({ lang, origin, path, schema }) {
35
32
 
36
33
  /** @param {{ outputs: { 200: any } }} routeType */
37
34
  export function useApi(routeType) {
38
- const { path, query } = useRoute();
35
+ const { origin } = location;
36
+ const { path: pathWithoutSearch, query } = useRoute();
39
37
  const search = new URLSearchParams(query).toString();
40
- const { queryFn, queryKey } = queryApi({
41
- origin: location.origin,
42
- path: path + (search ? `?${search}` : ""),
43
- schema: routeType.outputs[200],
44
- });
38
+ const path = pathWithoutSearch + (search ? `?${search}` : "");
39
+ const schema = routeType.outputs[200];
40
+ const { queryFn, queryKey } = queryApi({ origin, path, schema });
45
41
  const { data, error } = useQuery({ queryFn, queryKey });
42
+ const queryClient = useQueryClient();
43
+ const setQueryState = SetQueryState({ queryClient, queryKey });
44
+ const [sourceUpdate, setSourceUpdate] = useState(Date.now());
45
+ useEffect(() => {
46
+ if (typeof EventSource == "undefined") {
47
+ return;
48
+ }
49
+ const source = new EventSource(ApiPath({ origin, path: path }));
50
+ source.onerror = () => {
51
+ setTimeout(() => {
52
+ setSourceUpdate(Date.now());
53
+ }, 1e3);
54
+ };
55
+ source.onmessage = ({ data }) => {
56
+ data = JSON.parse(data);
57
+ if (!data) {
58
+ source.close();
59
+ setQueryState((existing) =>
60
+ existing ? { ...existing, fromEventSource: false } : undefined,
61
+ );
62
+ } else if (Number(data.statusCode) >= 400) {
63
+ setQueryState((existing) => ({
64
+ data: existing?.data ?? null,
65
+ dataUpdatedAt: Date.now(),
66
+ error,
67
+ fromEventSource: true,
68
+ isInvalidated: false,
69
+ /** @type {"error"} */
70
+ status: "error",
71
+ }));
72
+ } else {
73
+ setQueryState(() => ({
74
+ data: schema
75
+ ? parse(schema, JSON.parse(data.body))
76
+ : JSON.parse(data.body),
77
+ dataUpdatedAt: Date.now(),
78
+ error: null,
79
+ fromEventSource: true,
80
+ isInvalidated: false,
81
+ /** @type {"success"} */
82
+ status: "success",
83
+ }));
84
+ }
85
+ };
86
+ setQueryState((existing) =>
87
+ existing ? { ...existing, fromEventSource: true } : undefined,
88
+ );
89
+ return () => {
90
+ source.close();
91
+ setQueryState((existing) =>
92
+ existing ? { ...existing, fromEventSource: false } : undefined,
93
+ );
94
+ };
95
+ }, [path, sourceUpdate]);
46
96
  return { data, error };
47
97
  }
98
+
99
+ /** @param {{ origin: string | undefined, path: string }} props */
100
+ function ApiPath({ origin, path }) {
101
+ return (
102
+ (origin || "") +
103
+ path.replace(/^\//, "/api/").replace(/^\/api\/(\?|$)/, "/api$1")
104
+ );
105
+ }