@aklinker1/zeta 1.3.3 → 2.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/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@aklinker1/zeta",
3
3
  "description": "Composable, testable, OpenAPI-first backend framework with validation built-in",
4
- "version": "1.3.3",
4
+ "version": "2.0.0",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
- "packageManager": "bun@1.3.2",
7
+ "packageManager": "bun@1.3.5",
8
8
  "module": "src/index.ts",
9
9
  "types": "src/index.ts",
10
10
  "exports": {
@@ -41,23 +41,24 @@
41
41
  "bench": "bun run src/__tests__/bench.ts",
42
42
  "example": "bun --watch run example.ts",
43
43
  "example:prod": "NODE_ENV=production bun run example",
44
- "docs:dev": "vitepress dev docs",
44
+ "docs:dev": "zola -r docs serve",
45
45
  "docs:build": "vitepress build docs",
46
46
  "docs:preview": "vitepress preview docs"
47
47
  },
48
48
  "dependencies": {
49
49
  "@standard-schema/spec": "^1.0.0",
50
50
  "openapi-types": "^12.1.3",
51
- "rou3": "^0.7.1"
51
+ "rou3": "^0.7.12",
52
+ "scule": "^1.3.0"
52
53
  },
53
54
  "devDependencies": {
54
55
  "@aklinker1/check": "^2.2.0",
55
- "@typescript/native-preview": "^7.0.0-dev.20251114.1",
56
56
  "@types/bun": "latest",
57
+ "@typescript/native-preview": "^7.0.0-dev.20251114.1",
57
58
  "changelogen": "^0.6.2",
58
- "elysia": "^1.3.5",
59
+ "elysia": "^1.4.19",
59
60
  "expect-type": "^1.2.1",
60
- "hono": "^4.8.3",
61
+ "hono": "^4.11.2",
61
62
  "jsr": "^0.13.5",
62
63
  "mermaid": "^11.12.0",
63
64
  "oxlint": "^1.2.0",
@@ -2,11 +2,9 @@
2
2
  * Contains a schema adapter for app's using [`zod`](https://npmjs.com/package/zod).
3
3
  * @module
4
4
  */
5
+ import { z } from "zod";
5
6
  import type { SchemaAdapter } from "../types";
6
7
 
7
- const zod = "zod";
8
- const { z } = await import(zod);
9
-
10
8
  /**
11
9
  * Usage:
12
10
  *
@@ -25,21 +23,6 @@ export const zodSchemaAdapter: SchemaAdapter = {
25
23
  delete res.$schema;
26
24
  return res;
27
25
  },
28
- parseParamsRecord: (params: any) => {
29
- if (params?._zod?.def?.type !== "object")
30
- throw Error(
31
- "Query, params, and header schemas must be simple Zode objects defined with z.object({ ... })",
32
- );
33
-
34
- return Object.entries(params._zod.def.shape).map(
35
- ([key, schema]: [string, any]) => ({
36
- name: key,
37
- schema,
38
- optional: schema.safeParse(undefined).success,
39
- ...z.globalRegistry.get(schema),
40
- }),
41
- );
42
- },
43
26
  getMeta: (schema: any) => {
44
27
  return z.globalRegistry.get(schema);
45
28
  },
package/src/app.ts CHANGED
@@ -1,28 +1,23 @@
1
- import { callHandler } from "./internal/call-handler";
2
- import { HttpError } from "./errors";
3
- import { HttpStatus } from "./status";
4
1
  import type {
5
2
  App,
6
3
  RouterData,
7
4
  RouteDef,
8
5
  BasePath,
9
6
  ServerSideFetch,
10
- OnGlobalRequestContext,
11
- LifeCycleHooks,
12
7
  LifeCycleHook,
13
8
  DefaultAppData,
14
9
  BasePrefix,
15
10
  SchemaAdapter,
16
11
  Transport,
12
+ LifeCycleHookName,
17
13
  } from "./types";
18
- import { addRoute, createRouter, findRoute } from "rou3";
19
- import {
20
- callCtxModifierHooks,
21
- detectTransport,
22
- serializeErrorResponse,
23
- } from "./internal/utils";
14
+ import { addRoute, createRouter } from "rou3";
15
+ import { compileRouter } from "rou3/compiler";
16
+ import { detectTransport } from "./internal/utils";
24
17
  import type { OpenAPIV3_1 } from "openapi-types";
25
18
  import { buildOpenApiDocs, buildScalarHtml } from "./open-api";
19
+ import { compileRouteHandler } from "./internal/compile-route-handler";
20
+ import { compileFetchFunction } from "./internal/compile-fetch-function";
26
21
 
27
22
  let appIdInc = 0;
28
23
  const nextAppId = () => `app-${appIdInc++}`;
@@ -68,15 +63,7 @@ export function createApp<TPrefix extends BasePrefix = "">(
68
63
  const appId = nextAppId();
69
64
 
70
65
  const { origin = "http://localhost", prefix = "" } = options ?? {};
71
- const hooks: App["~zeta"]["hooks"] = {
72
- onAfterHandle: [],
73
- onGlobalAfterResponse: [],
74
- onBeforeHandle: [],
75
- onMapResponse: [],
76
- onGlobalError: [],
77
- onGlobalRequest: [],
78
- onTransform: [],
79
- };
66
+ const hooks: App["~zeta"]["hooks"] = {};
80
67
  const routes: App["~zeta"]["routes"] = {};
81
68
 
82
69
  const addRoutesEntry = (method: string, route: string, data: RouterData) => {
@@ -87,15 +74,13 @@ export function createApp<TPrefix extends BasePrefix = "">(
87
74
  routes[method][route] = data;
88
75
  };
89
76
 
90
- const cloneHooks = () => ({
91
- onAfterHandle: [...hooks.onAfterHandle],
92
- onGlobalAfterResponse: [...hooks.onGlobalAfterResponse],
93
- onBeforeHandle: [...hooks.onBeforeHandle],
94
- onMapResponse: [...hooks.onMapResponse],
95
- onGlobalError: [...hooks.onGlobalError],
96
- onGlobalRequest: [...hooks.onGlobalRequest],
97
- onTransform: [...hooks.onTransform],
98
- });
77
+ const cloneHooks = (): App["~zeta"]["hooks"] => {
78
+ const cloned: App["~zeta"]["hooks"] = {};
79
+ for (const key of Object.keys(hooks) as LifeCycleHookName[]) {
80
+ if (hooks[key]) cloned[key] = [...hooks[key]] as any;
81
+ }
82
+ return cloned;
83
+ };
99
84
 
100
85
  const app: App<DefaultAppData> = {
101
86
  // @ts-expect-error
@@ -146,77 +131,8 @@ export function createApp<TPrefix extends BasePrefix = "">(
146
131
  }
147
132
  }
148
133
 
149
- // const getRoute = compileRouter(router);
150
- const getRoute = (method: string, path: string) =>
151
- findRoute(router, method, path);
152
-
153
- return async (request) => {
154
- const url = new URL(request.url, origin);
155
- const ctx: any = {
156
- path: url.pathname,
157
- url,
158
- request,
159
- method: request.method,
160
- set: {
161
- status: HttpStatus.Ok,
162
- headers: {},
163
- },
164
- } satisfies OnGlobalRequestContext;
165
-
166
- try {
167
- const onGlobalRequestResponse = await callCtxModifierHooks(
168
- ctx,
169
- hooks.onGlobalRequest,
170
- );
171
- if (onGlobalRequestResponse) {
172
- ctx.response = onGlobalRequestResponse;
173
- return onGlobalRequestResponse;
174
- }
175
-
176
- const response = await callHandler(
177
- ctx,
178
- getRoute,
179
- options?.schemaAdapter,
180
- );
181
- ctx.response = response;
182
-
183
- return response;
184
- } catch (err) {
185
- ctx.error = err;
186
-
187
- for (const hook of hooks.onGlobalError) {
188
- const res = hook.callback(ctx);
189
- res instanceof Promise ? await res : res;
190
- }
191
-
192
- const status =
193
- err instanceof HttpError
194
- ? err.status
195
- : HttpStatus.InternalServerError;
196
- const res = Response.json(serializeErrorResponse(err), { status });
197
- ctx.response = res;
198
- return res;
199
- } finally {
200
- // Defer calls to the `onGlobalAfterResponse` hooks until after the response is sent
201
- if (hooks.onGlobalAfterResponse.length > 0) {
202
- setTimeout(async () => {
203
- try {
204
- for (const hook of hooks.onGlobalAfterResponse) {
205
- let res = hook.callback(ctx);
206
- if (res instanceof Promise) await res;
207
- }
208
- } catch (err) {
209
- ctx.error = err;
210
-
211
- for (const hook of hooks.onGlobalError) {
212
- const res = hook.callback(ctx);
213
- res instanceof Promise ? await res : res;
214
- }
215
- }
216
- }, 0);
217
- }
218
- }
219
- };
134
+ const getRoute = compileRouter(router);
135
+ return compileFetchFunction({ getRoute, hooks, origin });
220
136
  },
221
137
 
222
138
  getOpenApiSpec: () => {
@@ -241,6 +157,7 @@ export function createApp<TPrefix extends BasePrefix = "">(
241
157
  const obj: Record<string, any> =
242
158
  args.length === 2 ? { [args[0]]: args[1] } : args[0];
243
159
 
160
+ hooks.onTransform ??= [];
244
161
  hooks.onTransform.push({
245
162
  id: nextHookId(appId),
246
163
  applyTo: "local",
@@ -251,6 +168,7 @@ export function createApp<TPrefix extends BasePrefix = "">(
251
168
  },
252
169
 
253
170
  onGlobalRequest(callback: any) {
171
+ hooks.onGlobalRequest ??= [];
254
172
  hooks.onGlobalRequest.push({
255
173
  id: nextHookId(appId),
256
174
  applyTo: "global",
@@ -259,6 +177,7 @@ export function createApp<TPrefix extends BasePrefix = "">(
259
177
  return app;
260
178
  },
261
179
  onTransform(callback: any) {
180
+ hooks.onTransform ??= [];
262
181
  hooks.onTransform.push({
263
182
  id: nextHookId(appId),
264
183
  applyTo: "local",
@@ -267,6 +186,7 @@ export function createApp<TPrefix extends BasePrefix = "">(
267
186
  return app;
268
187
  },
269
188
  onBeforeHandle(callback: any) {
189
+ hooks.onBeforeHandle ??= [];
270
190
  hooks.onBeforeHandle.push({
271
191
  id: nextHookId(appId),
272
192
  applyTo: "local",
@@ -275,6 +195,7 @@ export function createApp<TPrefix extends BasePrefix = "">(
275
195
  return app;
276
196
  },
277
197
  onAfterHandle(callback: any) {
198
+ hooks.onAfterHandle ??= [];
278
199
  hooks.onAfterHandle.push({
279
200
  id: nextHookId(appId),
280
201
  applyTo: "local",
@@ -283,6 +204,7 @@ export function createApp<TPrefix extends BasePrefix = "">(
283
204
  return app;
284
205
  },
285
206
  onMapResponse(callback: any) {
207
+ hooks.onMapResponse ??= [];
286
208
  hooks.onMapResponse.push({
287
209
  id: nextHookId(appId),
288
210
  applyTo: "local",
@@ -291,6 +213,7 @@ export function createApp<TPrefix extends BasePrefix = "">(
291
213
  return app;
292
214
  },
293
215
  onGlobalError(callback: any) {
216
+ hooks.onGlobalError ??= [];
294
217
  hooks.onGlobalError.push({
295
218
  id: nextHookId(appId),
296
219
  applyTo: "global",
@@ -299,6 +222,7 @@ export function createApp<TPrefix extends BasePrefix = "">(
299
222
  return app;
300
223
  },
301
224
  onGlobalAfterResponse(callback: any) {
225
+ hooks.onGlobalAfterResponse ??= [];
302
226
  hooks.onGlobalAfterResponse.push({
303
227
  id: nextHookId(appId),
304
228
  applyTo: "global",
@@ -322,11 +246,21 @@ export function createApp<TPrefix extends BasePrefix = "">(
322
246
  const def: RouteDef = args.length === 2 ? args[0] : undefined;
323
247
  const handler = args[1] ?? args[0];
324
248
  const route = `${prefix}${path}`;
325
- addRoutesEntry(method, route, {
249
+ const hooks = cloneHooks();
250
+ const compiledHandler = compileRouteHandler({
251
+ schemaAdapter: options?.schemaAdapter,
326
252
  def,
253
+ hooks,
254
+ method,
255
+ route,
327
256
  handler,
257
+ });
258
+ addRoutesEntry(method, route, {
259
+ def,
328
260
  route,
329
- hooks: cloneHooks(),
261
+ hooks,
262
+ compiledHandler,
263
+ handler,
330
264
  });
331
265
  return app;
332
266
  },
@@ -348,11 +282,22 @@ export function createApp<TPrefix extends BasePrefix = "">(
348
282
  }
349
283
 
350
284
  const route = `${prefix}${path}/**`;
285
+ const hooks = cloneHooks();
286
+ const compiledHandler = compileRouteHandler({
287
+ schemaAdapter: options?.schemaAdapter,
288
+ hooks,
289
+ method: "ANY",
290
+ route,
291
+ fetch,
292
+ def,
293
+ });
294
+
351
295
  addRoutesEntry(Method.Any, route, {
352
296
  def,
353
297
  fetch,
354
298
  route,
355
- hooks: cloneHooks(),
299
+ hooks,
300
+ compiledHandler,
356
301
  });
357
302
 
358
303
  return app as any;
@@ -369,20 +314,23 @@ export function createApp<TPrefix extends BasePrefix = "">(
369
314
  }
370
315
  }
371
316
 
372
- // Add global hooks to parent app's hooks
373
- for (const _name of Object.keys(hooks)) {
374
- const name = _name as keyof LifeCycleHooks;
375
- for (const hook of childApp["~zeta"].hooks[name]) {
376
- if (hook.applyTo === "global" || childApp["~zeta"].exported) {
377
- hooks[name].push(hook as LifeCycleHook<any>);
317
+ // Add the child's global hooks to parent
318
+ for (const hookName of Object.keys(
319
+ childApp["~zeta"].hooks,
320
+ ) as LifeCycleHookName[]) {
321
+ for (const hook of childApp["~zeta"].hooks[
322
+ hookName
323
+ ]! as LifeCycleHook<any>[]) {
324
+ if (hook.applyTo !== "global" && !childApp["~zeta"].exported)
325
+ continue;
326
+
327
+ if (hooks[hookName]) {
328
+ // Don't add a hook if it's already there
329
+ if (!hooks[hookName].includes(hook)) hooks[hookName].push(hook);
330
+ } else {
331
+ hooks[hookName] = [hook];
378
332
  }
379
333
  }
380
- let seen = new Set<string>();
381
- hooks[name] = hooks[name].filter((hook) => {
382
- if (seen.has(hook.id)) return false;
383
- seen.add(hook.id);
384
- return true;
385
- }) as LifeCycleHook<any>[];
386
334
  }
387
335
 
388
336
  return app as any;
package/src/client.ts CHANGED
@@ -99,11 +99,12 @@ export function createAppClient<TApp extends App>(
99
99
  headers: { ...headers } as Record<string, string>,
100
100
  } satisfies RequestInit;
101
101
 
102
- const body =
102
+ const serializedBody =
103
103
  inputs.body == null ? undefined : smartSerialize(inputs.body);
104
- if (body) {
105
- init.body = body.serialized;
106
- if (body.contentType) init.headers["Content-Type"] = body.contentType;
104
+ if (serializedBody) {
105
+ init.body = serializedBody.value;
106
+ if (serializedBody.contentType)
107
+ init.headers["Content-Type"] = serializedBody.contentType;
107
108
  }
108
109
 
109
110
  try {
package/src/errors.ts CHANGED
@@ -1,19 +1,5 @@
1
- import type { StandardSchemaV1 } from "@standard-schema/spec";
2
1
  import { HttpStatus } from "./status";
3
2
 
4
- /**
5
- * Thrown when validation fails. Refer to `.issues` for details on what went wrong.
6
- */
7
- export class ValidationGlobalError extends Error {
8
- constructor(
9
- readonly input: any,
10
- readonly issues: ReadonlyArray<StandardSchemaV1.Issue>,
11
- ) {
12
- super("Validation error");
13
- this.name = "ValidationGlobalError";
14
- }
15
- }
16
-
17
3
  /**
18
4
  * Base class of all HTTP errors. You can throw this error or throw any of the
19
5
  * subclasses. Zeta will automatically detect and handle these errors, setting
@@ -0,0 +1,157 @@
1
+ import type { MatchedRoute } from "rou3";
2
+ import type { LifeCycleHooks, RouterData, ServerSideFetch } from "../types";
3
+ import {
4
+ cleanupCompiledWhitespace,
5
+ getRawPathname,
6
+ serializeErrorResponse,
7
+ } from "./utils";
8
+ import { Context } from "./context";
9
+ import { HttpError, NotFoundHttpError } from "../errors";
10
+ import { HttpStatus } from "../status";
11
+
12
+ export function compileFetchFunction(options: CompileOptions): ServerSideFetch {
13
+ const onGlobalRequestCount = options.hooks.onGlobalRequest?.length;
14
+ const onGlobalAfterResponseCount =
15
+ options.hooks.onGlobalAfterResponse?.length;
16
+ const onGlobalErrorCount = options.hooks.onGlobalError?.length;
17
+
18
+ const js = `
19
+ return (request) => {
20
+ const path = utils.getRawPathname(request);
21
+ const ctx = new utils.Context(request, path, utils.origin);
22
+
23
+ try {
24
+ ${onGlobalRequestCount ? compileOnGlobalRequestHook(onGlobalRequestCount) : ""}
25
+
26
+ const matchedRoute = utils.getRoute(request.method, path);
27
+ if (matchedRoute == null) {
28
+ throw new utils.NotFoundHttpError(undefined, {
29
+ method: request.method,
30
+ path,
31
+ });
32
+ } else {
33
+ ctx.matchedRoute = matchedRoute;
34
+ }
35
+
36
+ ctx.response = matchedRoute.data.compiledHandler(request, ctx);
37
+ if (typeof ctx.response.then !== utils.FUNCTION) return ctx.response;
38
+
39
+ return ctx.response.catch(error => {
40
+ ${onGlobalErrorCount ? compileOnGlobalErrorHook(onGlobalErrorCount, 3) : ""}
41
+
42
+ ${compileErrorResponse(3)}
43
+ })${onGlobalAfterResponseCount ? compileOnGlobalAfterResponsePromiseFinally(onGlobalAfterResponseCount, 2) : ""};
44
+ } catch (error) {
45
+ ${onGlobalErrorCount ? compileOnGlobalErrorHook(onGlobalErrorCount, 2) : ""}
46
+
47
+ ${compileErrorResponse(2)}
48
+ } ${onGlobalAfterResponseCount ? compileOnGlobalAfterResponseFinally(onGlobalAfterResponseCount, 1) : ""}
49
+ }
50
+ //#sourceURL=zeta-jit-generated://zeta-fetch-fn.js
51
+ `;
52
+ return new Function("utils", cleanupCompiledWhitespace(js))({
53
+ FUNCTION: "function",
54
+ getRawPathname,
55
+ hooks: options.hooks,
56
+ Context,
57
+ getRoute: options.getRoute,
58
+ NotFoundHttpError,
59
+ origin: options.origin,
60
+ HttpError,
61
+ HttpStatus,
62
+ serializeErrorResponse,
63
+ });
64
+ }
65
+
66
+ function compileOnGlobalRequestHook(hookCount: number): string {
67
+ const lines: string[] = [];
68
+
69
+ for (let i = 0; i < hookCount; i++) {
70
+ const resultVar = `onGlobalRequestRes${i}`;
71
+ lines.push(
72
+ ` const ${resultVar} = utils.hooks.onGlobalRequest[${i}].callback(ctx);`,
73
+ ...(process.env.NODE_ENV !== "production"
74
+ ? [
75
+ ` if (${resultVar} instanceof Promise)`,
76
+ ` console.warn("Warning: Promise returned from onGlobalRequest hook. Promises returned from onGlobalRequest are not awaited, ignoring the return value.");`,
77
+ ]
78
+ : []),
79
+ ` if (${resultVar})`,
80
+ ` if (typeof ${resultVar}.body?.bytes === utils.FUNCTION)`,
81
+ ` return ${resultVar};`,
82
+ ` else`,
83
+ ` for (const key of Object.keys(${resultVar}))`,
84
+ ` ctx[key] = ${resultVar}[key];`,
85
+ );
86
+ }
87
+
88
+ return lines.join("\n");
89
+ }
90
+
91
+ function compileOnGlobalErrorHook(hookCount: number, tabs: number): string {
92
+ const indent = " ".repeat(tabs);
93
+ const lines: string[] = [`${indent}ctx.error = error;`];
94
+
95
+ for (let i = 0; i < hookCount; i++) {
96
+ lines.push(`${indent}utils.hooks.onGlobalError[${i}].callback(ctx);`);
97
+ }
98
+
99
+ return lines.join("\n");
100
+ }
101
+
102
+ function compileOnGlobalAfterResponseFinally(
103
+ hookCount: number,
104
+ tabs: number,
105
+ ): string {
106
+ const indent = " ".repeat(tabs);
107
+ return `finally {
108
+ ${compileOnGlobalAfterResponseHook(hookCount, tabs + 1)}
109
+ ${indent}}
110
+ `;
111
+ }
112
+
113
+ function compileOnGlobalAfterResponsePromiseFinally(
114
+ hookCount: number,
115
+ tabs: number,
116
+ ): string {
117
+ const indent = " ".repeat(tabs);
118
+ return `.finally(() => {
119
+ ${compileOnGlobalAfterResponseHook(hookCount, tabs + 1)}
120
+ ${indent}})`;
121
+ }
122
+
123
+ function compileOnGlobalAfterResponseHook(
124
+ hookCount: number,
125
+ tabs: number,
126
+ ): string {
127
+ const indent = " ".repeat(tabs);
128
+ const lines: string[] = [`${indent}setTimeout(() => {`];
129
+
130
+ for (let i = 0; i < hookCount; i++) {
131
+ lines.push(
132
+ `${indent} utils.hooks.onGlobalAfterResponse[${i}].callback(ctx);`,
133
+ );
134
+ }
135
+
136
+ lines.push(`${indent}})`);
137
+
138
+ return lines.join("\n");
139
+ }
140
+
141
+ function compileErrorResponse(tabs: number): string {
142
+ const indent = " ".repeat(tabs);
143
+ return `${indent}const status =
144
+ ${indent} error instanceof utils.HttpError
145
+ ${indent} ? error.status
146
+ ${indent} : utils.HttpStatus.InternalServerError;
147
+ ${indent}return (ctx.response = Response.json(utils.serializeErrorResponse(error), { status }));`;
148
+ }
149
+
150
+ type CompileOptions = {
151
+ hooks: LifeCycleHooks;
152
+ getRoute: (
153
+ method: string,
154
+ path: string,
155
+ ) => MatchedRoute<RouterData> | undefined;
156
+ origin: string;
157
+ };