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/index.js ADDED
@@ -0,0 +1,208 @@
1
+ import { toStatic } from "hoofd/preact";
2
+ import { h } from "preact";
3
+ import {
4
+ ErrorBoundary,
5
+ hydrate as iso,
6
+ Router as IsoRouter,
7
+ LocationProvider,
8
+ prerender,
9
+ } from "preact-iso";
10
+ import { locationStub } from "preact-iso/prerender";
11
+ import { useState } from "preact/hooks";
12
+
13
+ import {
14
+ a,
15
+ b,
16
+ button,
17
+ circle,
18
+ dialog,
19
+ div,
20
+ fieldset,
21
+ figure,
22
+ form,
23
+ h1,
24
+ h2,
25
+ h3,
26
+ h4,
27
+ h5,
28
+ h6,
29
+ header,
30
+ i,
31
+ img,
32
+ input,
33
+ label,
34
+ li,
35
+ main,
36
+ ol,
37
+ option,
38
+ p,
39
+ path,
40
+ select,
41
+ span,
42
+ svg,
43
+ title,
44
+ ul,
45
+ } from "./html.js";
46
+ import { getI18n, Po, RawI18nProvider, useI18n } from "./i18n.js";
47
+ import {
48
+ dehydrate,
49
+ Hydrate,
50
+ QueryClient,
51
+ QueryClientProvider,
52
+ useQueryClient,
53
+ } from "./query.js";
54
+ import { queryApi, useApi } from "./use-api.js";
55
+ import { useEnhance } from "./use-enhance.js";
56
+ import { useStatus } from "./use-status.js";
57
+
58
+ /**
59
+ * @param {(props: { dehydratedState?: ReturnType<import("./query.js").dehydrate> | undefined, i18n: ReturnType<import("./i18n.js").useI18n> } ) => import("preact").VNode} App
60
+ * @param {string} renderTarget
61
+ */
62
+ export async function hydrate(App, renderTarget) {
63
+ if (typeof window !== "undefined") {
64
+ const app = document.querySelector(renderTarget);
65
+ if (!app) {
66
+ throw new Error(`${renderTarget} not found`);
67
+ }
68
+ let data;
69
+ const prerender = document.querySelector("#preact-prerender-data");
70
+ if (prerender) {
71
+ data = JSON.parse(prerender.textContent);
72
+ prerender.remove();
73
+ }
74
+
75
+ // If api and hydrate run in the same process, keep value from api.
76
+ // Avoid infinite recursion.
77
+ if (!Po.value) {
78
+ Po.set(
79
+ async (lang) =>
80
+ await (
81
+ await fetch(`${location.origin}/po/${encodeURIComponent(lang)}`)
82
+ ).json(),
83
+ );
84
+ }
85
+
86
+ iso(
87
+ h(App, {
88
+ dehydratedState: data?.dehydratedState,
89
+ // `window` matters. We want JSDOM navigator in tests, not Node.js one.
90
+ i18n: await getI18n(...window.navigator.languages),
91
+ }),
92
+ app,
93
+ );
94
+ }
95
+ }
96
+
97
+ /** @param {{ children?: import("preact").ComponentChildren, dehydratedState?: ReturnType<import("./query.js").dehydrate> | undefined, i18n: ReturnType<import("./i18n.js").useI18n> }} props */
98
+ export function Router({ children, dehydratedState, i18n }) {
99
+ const [queryClient] = useState(
100
+ () =>
101
+ new QueryClient({
102
+ defaultOptions: { queries: { staleTime: 60 * 1000 } },
103
+ }),
104
+ );
105
+
106
+ return h(
107
+ RawI18nProvider,
108
+ { value: /** @type {any} */ (i18n) },
109
+ h(
110
+ QueryClientProvider,
111
+ { client: queryClient },
112
+ h(
113
+ Hydrate,
114
+ { state: dehydratedState },
115
+ h(
116
+ LocationProvider,
117
+ null,
118
+ h(ErrorBoundary, null, h(IsoRouter, null, children)),
119
+ ),
120
+ ),
121
+ ),
122
+ );
123
+ }
124
+
125
+ /**
126
+ * @param {(props: { dehydratedState?: ReturnType<import("./query.js").dehydrate> | undefined, i18n: ReturnType<import("./i18n.js").useI18n> }) => import("preact").VNode} App
127
+ * @param {{ lang?: string, url: string }} data
128
+ */
129
+ export async function ssr(App, data) {
130
+ const origin = process.env["ORIGIN"] || "http://localhost:5173";
131
+ const i18n = await getI18n(...(data.lang ? [data.lang] : []));
132
+
133
+ const queryClient = new QueryClient({
134
+ defaultOptions: { queries: { staleTime: 0 } },
135
+ });
136
+ await queryClient.prefetchQuery(
137
+ queryApi({ lang: i18n.getLocale(), origin, path: data.url }),
138
+ );
139
+ const dehydratedState = dehydrate(queryClient, {
140
+ shouldRedactErrors: (error) => {
141
+ const { name } = /** @type {any} */ (error);
142
+ return !(Number(name) && name == "404");
143
+ },
144
+ });
145
+
146
+ locationStub(data.url);
147
+ const { html, links } = await prerender(h(/** @type {any} */ (App), {}), {
148
+ props: { dehydratedState, i18n },
149
+ });
150
+ const { metas, title } = toStatic();
151
+
152
+ return {
153
+ data: { ...data, dehydratedState },
154
+ head: {
155
+ elements: new Set(
156
+ metas.map((props) => ({
157
+ props: Object.fromEntries(
158
+ Object.entries(props).map(([name, content]) => [
159
+ name,
160
+ String(content),
161
+ ]),
162
+ ),
163
+ type: "meta",
164
+ })),
165
+ ),
166
+ lang: i18n.getLocale(),
167
+ title,
168
+ },
169
+ html,
170
+ links,
171
+ };
172
+ }
173
+
174
+ export { getI18n, useApi, useEnhance, useI18n, useQueryClient, useStatus };
175
+
176
+ export {
177
+ a,
178
+ b,
179
+ button,
180
+ circle,
181
+ dialog,
182
+ div,
183
+ fieldset,
184
+ figure,
185
+ form,
186
+ h1,
187
+ h2,
188
+ h3,
189
+ h4,
190
+ h5,
191
+ h6,
192
+ header,
193
+ i,
194
+ img,
195
+ input,
196
+ label,
197
+ li,
198
+ main,
199
+ ol,
200
+ option,
201
+ p,
202
+ path,
203
+ select,
204
+ span,
205
+ svg,
206
+ title,
207
+ ul,
208
+ };
@@ -0,0 +1,5 @@
1
+ // Workaround because jsdom has no fetch api of its own: https://github.com/jsdom/jsdom/issues/1724
2
+ export const jsdomCookie = {
3
+ /** @type {string[]} Output of Headers.getSetCookie(). */
4
+ setCookie: [],
5
+ };
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "evikit",
3
+ "description": "Preact/SQLite SSR framework for small web apps",
4
+ "version": "1.0.0",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./index.js",
8
+ "./db": "./db.js",
9
+ "./server": "./server.js",
10
+ "./sqlite": "./sqlite.js",
11
+ "./testing": "./testing.js"
12
+ },
13
+ "bin": {
14
+ "evikit-extract": "bin/evikit-extract.js"
15
+ },
16
+ "scripts": {
17
+ "build": "tsc",
18
+ "format": "prettier --write . && eslint --fix .",
19
+ "lint": "prettier --check . && eslint ."
20
+ },
21
+ "author": "Denys Nykula <nykula@ukr.net>",
22
+ "license": "MIT",
23
+ "dependencies": {
24
+ "@fastify/busboy": "^3.2.0",
25
+ "@valibot/to-json-schema": "^1.7.0",
26
+ "gettext-parser": "^9.0.2",
27
+ "gettext.js": "^2.0.3",
28
+ "lodash": "^4.18.1",
29
+ "preact-render-to-string": "^6.7.0"
30
+ },
31
+ "peerDependencies": {
32
+ "accept-language": "^3.0.20",
33
+ "hoofd": "^1.7.3",
34
+ "preact": "^10.29.2",
35
+ "preact-iso": "^2.12.0",
36
+ "valibot": "^1.4.0"
37
+ },
38
+ "devDependencies": {
39
+ "@eslint/compat": "^2.1.0",
40
+ "@eslint/js": "^10.0.1",
41
+ "@types/gettext-parser": "^9.0.0",
42
+ "@types/gettext.js": "^2.0.0",
43
+ "@types/lodash": "^4.17.24",
44
+ "@types/node": "^25",
45
+ "eslint": "^10.4.0",
46
+ "eslint-config-prettier": "^10.1.8",
47
+ "eslint-plugin-perfectionist": "^5.9.0",
48
+ "globals": "^17.6.0",
49
+ "prettier": "^3.8.3",
50
+ "typescript": "^6.0.3",
51
+ "typescript-eslint": "^8.59.4"
52
+ }
53
+ }
package/query.js ADDED
@@ -0,0 +1,231 @@
1
+ import { createContext, h } from "preact";
2
+ import { useContext, useEffect, useState } from "preact/hooks";
3
+
4
+ /**
5
+ * @typedef {object} QueryState
6
+ * @property {any} data
7
+ * @property {number} dataUpdatedAt
8
+ * @property {unknown | null} error
9
+ * @property {boolean} isInvalidated
10
+ * @property {"error" | "loading" | "success"} status
11
+ */
12
+
13
+ /**
14
+ * @typedef {{ state: QueryState, queryHash: string }[]} Queries
15
+ */
16
+
17
+ /** @param {string[]} key */
18
+ const QueryHash = (key) => key.map(encodeURIComponent).join("/");
19
+
20
+ export class QueryClient {
21
+ state =
22
+ /** @type {{ queries: Queries, setQueries: (map: (queries: Queries) => Queries) => void }} */ ({
23
+ queries: [],
24
+ setQueries: (map) => {
25
+ this.state.queries = map(this.state.queries);
26
+ },
27
+ });
28
+
29
+ /** @param {{ defaultOptions: { queries: { staleTime: number }}}} props */
30
+ constructor({ defaultOptions }) {
31
+ this.defaultOptions = defaultOptions;
32
+ }
33
+
34
+ /** @param {{ queryKey?: string[] }} props */
35
+ invalidateQueries({ queryKey } = {}) {
36
+ if (queryKey) {
37
+ const queryHash = QueryHash(queryKey);
38
+ this.state.setQueries((queries) =>
39
+ queries.map((query) =>
40
+ query.queryHash == queryHash ||
41
+ query.queryHash.startsWith(queryHash + "/")
42
+ ? { ...query, state: { ...query.state, isInvalidated: true } }
43
+ : query,
44
+ ),
45
+ );
46
+ } else {
47
+ this.state.setQueries((queries) =>
48
+ queries.map((query) => ({
49
+ ...query,
50
+ state: { ...query.state, isInvalidated: true },
51
+ })),
52
+ );
53
+ }
54
+ }
55
+
56
+ /** @param {{ queryKey: string[], queryFn: () => Promise<any> }} props */
57
+ async prefetchQuery({ queryFn, queryKey }) {
58
+ 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
+ });
72
+
73
+ const state = this.state.queries.find(
74
+ (query) => query.queryHash == queryHash,
75
+ )?.state;
76
+ if (
77
+ !state ||
78
+ (state.status != "loading" &&
79
+ (state.isInvalidated ||
80
+ state.dataUpdatedAt <
81
+ Date.now() - this.defaultOptions.queries.staleTime))
82
+ ) {
83
+ setQueryState(() => ({
84
+ data: state?.data ?? null,
85
+ dataUpdatedAt: state?.dataUpdatedAt ?? 0,
86
+ error: state?.error ?? null,
87
+ isInvalidated: false,
88
+ status: "loading",
89
+ }));
90
+ try {
91
+ const data = await queryFn();
92
+ setQueryState(() => ({
93
+ data,
94
+ dataUpdatedAt: Date.now(),
95
+ error: null,
96
+ isInvalidated: false,
97
+ status: "success",
98
+ }));
99
+ } catch (error) {
100
+ setQueryState((queries) => ({
101
+ data:
102
+ queries.find((query) => query.queryHash == queryHash)?.state.data ??
103
+ null,
104
+ dataUpdatedAt: Date.now(),
105
+ error,
106
+ isInvalidated: false,
107
+ status: "error",
108
+ }));
109
+ }
110
+ }
111
+ }
112
+ }
113
+
114
+ /**
115
+ * @param {QueryClient} queryClient
116
+ * @param {{ shouldRedactErrors: (error: unknown) => boolean}} options
117
+ */
118
+ export function dehydrate(queryClient, options) {
119
+ return {
120
+ queries: queryClient.state.queries
121
+ .filter(
122
+ ({ state }) => !state.error || !options.shouldRedactErrors(state.error),
123
+ )
124
+ .map((query) => {
125
+ const error = /** @type {Error | undefined} */ (query.state.error);
126
+ return error && Number(error.name)
127
+ ? {
128
+ ...query,
129
+ state: {
130
+ ...query.state,
131
+ error: { message: error.message, name: error.name },
132
+ },
133
+ }
134
+ : error
135
+ ? {
136
+ ...query,
137
+ state: { ...query.state, error: { message: error.message } },
138
+ }
139
+ : query;
140
+ }),
141
+ };
142
+ }
143
+
144
+ const QueryClientContext = createContext(
145
+ /** @type {QueryClient} */ (/** @type {any} */ (undefined)),
146
+ );
147
+
148
+ /** @param {{ children?: import("preact").ComponentChildren, state: { queries: Queries } | undefined }} props */
149
+ export function Hydrate(props) {
150
+ const originalQueryClient = useContext(QueryClientContext);
151
+ const [queries, setQueries] = useState(() =>
152
+ (props.state?.queries || []).map(({ queryHash, state }) => {
153
+ const error =
154
+ /** @type {{ message: string, name?: string } | undefined} */ (
155
+ state.error
156
+ );
157
+ return {
158
+ queryHash,
159
+ state: {
160
+ ...state,
161
+ error:
162
+ error && error.name
163
+ ? (() => {
164
+ const e = new Error(error.message);
165
+ e.name = error.name;
166
+ return /** @type {unknown} */ (e);
167
+ })()
168
+ : error
169
+ ? /** @type {unknown} */ (new Error(error.message))
170
+ : error,
171
+ },
172
+ };
173
+ }),
174
+ );
175
+ const [queryClient, setQueryClient] = useState(() => {
176
+ originalQueryClient.state = { queries, setQueries };
177
+ return originalQueryClient;
178
+ });
179
+
180
+ useEffect(() => {
181
+ if (queryClient.state.queries != queries) {
182
+ const nextQueryClient = new QueryClient({
183
+ defaultOptions: queryClient.defaultOptions,
184
+ });
185
+ nextQueryClient.state = { queries, setQueries };
186
+ setQueryClient(nextQueryClient);
187
+ }
188
+ }, [queries]);
189
+
190
+ return h(QueryClientContext.Provider, { value: queryClient }, props.children);
191
+ }
192
+
193
+ /** @param {{ client: QueryClient, children?: import("preact").ComponentChildren }} props */
194
+ export function QueryClientProvider(props) {
195
+ const [queries, setQueries] = useState(/** @type {Queries} */ ([]));
196
+ const [queryClient, setQueryClient] = useState(() => {
197
+ props.client.state = { queries, setQueries };
198
+ return props.client;
199
+ });
200
+
201
+ useEffect(() => {
202
+ const nextQueryClient = new QueryClient({
203
+ defaultOptions: queryClient.defaultOptions,
204
+ });
205
+ nextQueryClient.state = { queries, setQueries };
206
+ setQueryClient(nextQueryClient);
207
+ }, [queries]);
208
+
209
+ return h(QueryClientContext.Provider, { value: queryClient }, props.children);
210
+ }
211
+
212
+ /** @param {{ queryKey: string[], queryFn: () => Promise<any> }} props */
213
+ export function useQuery({ queryFn, queryKey }) {
214
+ const queryClient = useQueryClient();
215
+ const queryHash = QueryHash(queryKey);
216
+ const query = queryClient.state.queries.find(
217
+ (query) => query.queryHash == queryHash,
218
+ );
219
+ const state = query?.state;
220
+
221
+ useEffect(() => {
222
+ queryClient.prefetchQuery({ queryFn, queryKey });
223
+ }, [queryHash, state?.status, state?.isInvalidated, state?.dataUpdatedAt]);
224
+
225
+ const { data, error, status } = query?.state || { status: "loading" };
226
+ return { data, error, status };
227
+ }
228
+
229
+ export function useQueryClient() {
230
+ return useContext(QueryClientContext);
231
+ }
@@ -0,0 +1,95 @@
1
+ /*
2
+ Inspired by:
3
+
4
+ - https://github.com/RobinTail/express-zod-api (API simplicity)
5
+ - https://github.com/blomqma/next-rest-framework (response type checking)
6
+ */
7
+ import type { IncomingMessage, ServerResponse } from "node:http";
8
+
9
+ import {
10
+ type InferOutput,
11
+ type ObjectEntries,
12
+ type ObjectSchema,
13
+ StringSchema,
14
+ } from "valibot";
15
+
16
+ export type MethodServer = Record<
17
+ string,
18
+ {
19
+ handler(req: IncomingMessage, res: ServerResponse<IncomingMessage>): void;
20
+ input: ObjectSchema<ObjectEntries, undefined>;
21
+ outputs: Record<
22
+ number,
23
+ ObjectSchema<ObjectEntries, undefined> | StringSchema<undefined>
24
+ >;
25
+ }
26
+ >;
27
+
28
+ export type ServerEndpoint = <
29
+ Input extends ObjectEntries,
30
+ Status extends number,
31
+ Output extends ObjectEntries,
32
+ Outputs extends Record<
33
+ Status,
34
+ ObjectSchema<Output, undefined> | StringSchema<undefined>
35
+ >,
36
+ RouteType extends {
37
+ input: ObjectSchema<Input, undefined>;
38
+ outputs: Outputs;
39
+ },
40
+ >(
41
+ routeType: RouteType,
42
+ handler: (req: {
43
+ input: InferOutput<RouteType["input"]>;
44
+ }) => Promise<
45
+ ServerOutput<
46
+ InferOutput<RouteType["outputs"][keyof RouteType["outputs"]]>,
47
+ keyof RouteType["outputs"]
48
+ >
49
+ >,
50
+ ) => {
51
+ handler(req: IncomingMessage, res: ServerResponse<IncomingMessage>): void;
52
+ input: typeof input;
53
+ outputs: Outputs;
54
+ };
55
+
56
+ export type ServerJson = <Output>(output: Output) => ServerOutput<Output, 200>;
57
+
58
+ export type ServerRedirect = <Status extends number>(
59
+ location: string,
60
+ status: Status,
61
+ init?: { cookies: ServerCookies },
62
+ ) => ServerOutput<string, Status>;
63
+
64
+ export type ServerText = <Status extends number>(
65
+ text: string,
66
+ status: Status,
67
+ ) => ServerOutput<string, Status>;
68
+
69
+ type Assign<T, U> = Omit<T, keyof U> & U;
70
+
71
+ type KnownContentType =
72
+ "application/json" | "application/x-www-form-urlencoded" | "text/plain";
73
+
74
+ type ServerCookies = Record<string, { value: string }>;
75
+
76
+ type ServerHeaders<ContentType extends KnownContentType> = Assign<
77
+ Record<string, string>,
78
+ Partial<Record<"Content-Type" | "content-type", ContentType>>
79
+ >;
80
+
81
+ interface ServerOutput<Output, Status extends number> {
82
+ contentType: ContentType;
83
+ cookies?: ServerCookies | undefined;
84
+ headers?: ServerHeaders<ContentType>;
85
+ output: Output;
86
+ status: Status;
87
+ }
88
+
89
+ interface ServerResponseInit<
90
+ Status extends number,
91
+ ContentType extends KnownContentType,
92
+ > {
93
+ headers?: ServerHeaders<ContentType>;
94
+ status: Status;
95
+ }
package/server.d.ts ADDED
@@ -0,0 +1,114 @@
1
+ import { IncomingMessage, ServerResponse } from "node:http";
2
+
3
+ import type {
4
+ MethodServer,
5
+ ServerEndpoint,
6
+ ServerJson,
7
+ ServerRedirect,
8
+ ServerText,
9
+ } from "./server-generic.js";
10
+
11
+ export const endpoint: ServerEndpoint;
12
+
13
+ export const json: ServerJson;
14
+
15
+ export function listen(app: {
16
+ listen: (
17
+ port: number,
18
+ callback: () => void,
19
+ ) => {
20
+ close(): void;
21
+ on(event: "error", callback: (error: unknown) => void): void;
22
+ };
23
+ }): void;
24
+
25
+ export function production({
26
+ html,
27
+ prerender,
28
+ }: {
29
+ html: string;
30
+ prerender({ lang, url }: { lang: string; url: string }): Promise<{
31
+ data: object;
32
+ head: {
33
+ elements: Set<{
34
+ props: Record<string, string>;
35
+ type: string;
36
+ }>;
37
+ lang: string;
38
+ title: string;
39
+ };
40
+ html: string;
41
+ }>;
42
+ }): (
43
+ req: IncomingMessage,
44
+ res: ServerResponse<IncomingMessage>,
45
+ ) => Promise<void>;
46
+
47
+ export const redirect: ServerRedirect;
48
+
49
+ export function route(
50
+ path: string,
51
+ controller: MethodServer,
52
+ ): {
53
+ controller: MethodServer;
54
+ path: string;
55
+ pattern: URLPattern;
56
+ };
57
+
58
+ export function router(
59
+ {
60
+ pkg,
61
+ }: {
62
+ pkg: { name: string; version: string };
63
+ },
64
+ ...routes: {
65
+ controller: MethodServer;
66
+ path: string;
67
+ pattern: URLPattern;
68
+ }[]
69
+ ): {
70
+ onRequest: (
71
+ req: IncomingMessage,
72
+ res: ServerResponse<IncomingMessage>,
73
+ next?: () => void,
74
+ ) => Promise<void>;
75
+ openapi: {
76
+ info: { title: string; version: string };
77
+ openapi: string;
78
+ paths: Record<
79
+ string,
80
+ Record<
81
+ string,
82
+ {
83
+ parameters: {
84
+ in: string;
85
+ name: string;
86
+ required: boolean;
87
+ schema: { type: string };
88
+ }[];
89
+ requestBody?:
90
+ | undefined
91
+ | {
92
+ content: {
93
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
94
+ "application/x-www-form-urlencoded": { schema: any };
95
+ };
96
+ };
97
+ responses: Record<
98
+ string,
99
+ {
100
+ content: {
101
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
102
+ "application/json": { schema: any };
103
+ };
104
+ description: string;
105
+ }
106
+ >;
107
+ }
108
+ >
109
+ >;
110
+ servers: { url: string }[];
111
+ };
112
+ };
113
+
114
+ export const text: ServerText;