@aklinker1/zeta 1.3.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.
@@ -0,0 +1,194 @@
1
+ import type { MaybePromise } from "elysia";
2
+ import { getMeta } from "../meta";
3
+ import type {
4
+ CompiledRouteHandler,
5
+ LifeCycleHookName,
6
+ LifeCycleHooks,
7
+ OnBeforeHandleContext,
8
+ RouteDef,
9
+ SchemaAdapter,
10
+ ServerSideFetch,
11
+ } from "../types";
12
+ import { smartDeserialize, smartSerialize } from "./serialization";
13
+ import {
14
+ cleanupCompiledWhitespace,
15
+ IsStatusResult,
16
+ validateInputSchema,
17
+ validateOutputSchema,
18
+ } from "./utils";
19
+
20
+ export function compileRouteHandler(
21
+ options: CompileOptions,
22
+ ): CompiledRouteHandler {
23
+ if (options.fetch) {
24
+ return new Function(`
25
+ return (request, ctx) => ctx.matchedRoute.data.fetch(request)
26
+ //#sourceURL=${getSourceUrl(options)}
27
+ `)();
28
+ }
29
+
30
+ const responseContentTypeMap = getResponseContentTypeMap(options);
31
+
32
+ const js = `
33
+ return async (request, ctx) => {
34
+ ${options.method === "GET" ? "" : ADD_CTX_BODY}
35
+
36
+ ${options.hooks.onTransform?.length ? compileCtxModifierHookCall("onTransform", options.hooks.onTransform.length) : ""}
37
+
38
+ ${options.def?.body ? "ctx.body = utils.validateInputSchema(ctx.matchedRoute.data.def.body, ctx.body);" : ""}
39
+ ${options.def?.params ? "ctx.params = utils.validateInputSchema(ctx.matchedRoute.data.def.params, ctx.params);" : ""}
40
+ ${options.def?.query ? "ctx.query = utils.validateInputSchema(ctx.matchedRoute.data.def.query, ctx.query);" : ""}
41
+
42
+ ${options.hooks.onBeforeHandle?.length ? compileCtxModifierHookCall("onBeforeHandle", options.hooks.onBeforeHandle.length) : ""}
43
+
44
+ ctx.response = await ctx.matchedRoute.data.handler(ctx);
45
+ if (ctx.response) {
46
+ if (ctx.response[utils.IsStatusResult]) {
47
+ ctx.set.status = ctx.response.status;
48
+ ctx.response = ctx.response.body;
49
+ }
50
+ if (typeof ctx.response?.body?.bytes === utils.FUNCTION) return ctx.response;
51
+ }
52
+
53
+ ${compileValidateResponse(options)}
54
+
55
+ ${options.hooks.onAfterHandle?.length ? compileResponseModifierHookCall("onAfterHandle", options.hooks.onAfterHandle.length) : ""}
56
+
57
+ ${options.hooks.onMapResponse?.length ? compileResponseModifierHookCall("onMapResponse", options.hooks.onMapResponse.length) : ""}
58
+
59
+ if (ctx.response == null) {
60
+ return (
61
+ ctx.response = new Response(undefined, {
62
+ status: ctx.set.status,
63
+ headers: ctx.set.headers,
64
+ })
65
+ )
66
+ }
67
+
68
+ const serialized = utils.smartSerialize(ctx.response);
69
+ if (!ctx.set.headers["Content-Type"]) ctx.set.headers["Content-Type"] = ${responseContentTypeMap ? "responseContentTypeMap[ctx.set.status] ??" : ""} serialized.contentType
70
+ return (
71
+ ctx.response = new Response(serialized.value, {
72
+ status: ctx.set.status,
73
+ headers: ctx.set.headers,
74
+ })
75
+ )
76
+ }
77
+ //#sourceURL=${getSourceUrl(options)}
78
+ `;
79
+ return new Function(
80
+ "utils",
81
+ "responseContentTypeMap",
82
+ cleanupCompiledWhitespace(js),
83
+ )(UTILS, responseContentTypeMap);
84
+ }
85
+
86
+ // These functions are available in the generated code via the "utils" object.
87
+ const UTILS = {
88
+ smartDeserialize,
89
+ smartSerialize,
90
+ FUNCTION: "function",
91
+ IsStatusResult,
92
+ validateInputSchema,
93
+ validateOutputSchema,
94
+ };
95
+
96
+ type CompileOptions = {
97
+ schemaAdapter: SchemaAdapter | undefined;
98
+ def: RouteDef | undefined;
99
+ method: string;
100
+ route: string;
101
+ hooks: LifeCycleHooks;
102
+ fetch?: ServerSideFetch;
103
+ handler?: (ctx: OnBeforeHandleContext) => MaybePromise<any>;
104
+ };
105
+
106
+ function getSourceUrl(options: CompileOptions) {
107
+ return `zeta-jit-generated://${options.method.toLowerCase()}-${options.route.replace(/\s/gm, "").replaceAll("/", "-")}.js`;
108
+ }
109
+
110
+ const ADD_CTX_BODY = `
111
+ ctx.body = utils.smartDeserialize(request);
112
+ if (ctx.body) ctx.body = await ctx.body;
113
+ `;
114
+
115
+ function compileCtxModifierHookCall(
116
+ hook: LifeCycleHookName,
117
+ hookCount: number,
118
+ ): string {
119
+ const lines: string[] = [];
120
+
121
+ for (let i = 0; i < hookCount; i++) {
122
+ const resultVar = `${hook}Res${i}`;
123
+ lines.push(
124
+ ` const ${resultVar} = await ctx.matchedRoute.data.hooks.${hook}[${i}].callback(ctx);`,
125
+ ` if (${resultVar})`,
126
+ ` if (typeof ${resultVar}.body?.bytes === utils.FUNCTION)`,
127
+ ` return ${resultVar};`,
128
+ ` else`,
129
+ ` for (const key of Object.keys(${resultVar}))`,
130
+ ` ctx[key] = ${resultVar}[key];`,
131
+ );
132
+ }
133
+
134
+ return lines.join("\n");
135
+ }
136
+
137
+ function compileResponseModifierHookCall(
138
+ hook: LifeCycleHookName,
139
+ hookCount: number,
140
+ ): string {
141
+ const lines: string[] = [];
142
+
143
+ for (let i = 0; i < hookCount; i++) {
144
+ const resultVar = `${hook}Res${i}`;
145
+ lines.push(
146
+ ` const ${resultVar} = await ctx.matchedRoute.data.hooks.${hook}[${i}].callback(ctx);`,
147
+ ` if (${resultVar}) ctx.response = ${resultVar};`,
148
+ ` if (typeof ${resultVar}.body?.bytes === utils.FUNCTION)`,
149
+ ` return ${resultVar};`,
150
+ );
151
+ }
152
+
153
+ return lines.join("\n");
154
+ }
155
+
156
+ function compileValidateResponse(options: CompileOptions): string {
157
+ // No schemas defined
158
+ if (!options.def?.responses) return "";
159
+
160
+ // One schema defined
161
+ if ("~standard" in options.def.responses)
162
+ return "ctx.response = utils.validateOutputSchema(ctx.matchedRoute.data.def.responses, ctx.response);";
163
+
164
+ // Multiple schemas based on the status code
165
+ return "ctx.response = utils.validateOutputSchema(ctx.matchedRoute.data.def.responses[ctx.set.status], ctx.response);";
166
+ }
167
+
168
+ function getResponseContentTypeMap(
169
+ options: CompileOptions,
170
+ ): Record<number, string> | undefined {
171
+ // No schemas defined
172
+ if (!options.def?.responses) return;
173
+
174
+ // One schema defined
175
+ if ("~standard" in options.def.responses) {
176
+ const { contentType } = getMeta(
177
+ options.schemaAdapter,
178
+ options.def.responses,
179
+ );
180
+ if (!contentType) return;
181
+
182
+ return { [200]: contentType };
183
+ }
184
+
185
+ // Multiple schemas based on the status code
186
+ const map: Record<number, string> = {};
187
+ for (const [status, schema] of Object.entries(options.def.responses)) {
188
+ const { contentType } = getMeta(options.schemaAdapter, schema);
189
+ map[Number(status)] = contentType;
190
+ }
191
+ if (Object.keys(map).length === 0) return;
192
+
193
+ return map;
194
+ }
@@ -0,0 +1,47 @@
1
+ import type { MatchedRoute } from "rou3";
2
+ import { HttpStatus } from "../status";
3
+ import type { RouterData, StatusResult } from "../types";
4
+ import { getRawParams, getRawQuery, IsStatusResult } from "./utils";
5
+
6
+ export class Context {
7
+ set = {
8
+ status: HttpStatus.Ok,
9
+ headers: {},
10
+ };
11
+
12
+ matchedRoute: MatchedRoute<RouterData> | undefined;
13
+
14
+ constructor(
15
+ public request: Request,
16
+ public path: string,
17
+ public origin: string,
18
+ ) {}
19
+
20
+ get url(): URL {
21
+ return new URL(this.request.url, this.origin);
22
+ }
23
+
24
+ get params(): Record<string, string> {
25
+ return this.matchedRoute?.params ? getRawParams(this.matchedRoute) : {};
26
+ }
27
+
28
+ get query(): Record<string, string> {
29
+ return this.request.url.includes("?") ? getRawQuery(this.request) : {};
30
+ }
31
+
32
+ get route(): string | undefined {
33
+ return this.matchedRoute?.data.route;
34
+ }
35
+
36
+ get method(): string {
37
+ return this.request.method;
38
+ }
39
+
40
+ status(status: number, body?: unknown): StatusResult {
41
+ return {
42
+ [IsStatusResult]: true,
43
+ status,
44
+ body,
45
+ };
46
+ }
47
+ }
@@ -1,72 +1,71 @@
1
1
  export function smartSerialize(value: unknown):
2
2
  | {
3
3
  contentType: string | undefined;
4
- serialized: BodyInit;
4
+ value: BodyInit;
5
5
  }
6
6
  | undefined {
7
7
  if (value == null) return undefined;
8
8
 
9
+ switch (typeof value) {
10
+ case "string":
11
+ return { contentType: "text/plain", value };
12
+ case "number":
13
+ case "boolean":
14
+ case "bigint":
15
+ return { contentType: "text/plain", value: String(value) };
16
+ }
17
+
9
18
  if (value instanceof FormData) {
10
19
  return {
11
20
  contentType: undefined, // Let fetch set the content type with a boundary
12
- serialized: value,
21
+ value,
13
22
  };
14
23
  }
15
24
 
16
25
  if (value instanceof File) {
17
- const serialized = new FormData();
18
- serialized.append("file", value);
26
+ const form = new FormData();
27
+ form.append("file", value);
19
28
  return {
20
29
  contentType: undefined,
21
- serialized,
30
+ value: form,
22
31
  };
23
32
  }
24
33
 
25
34
  if (typeof FileList !== "undefined" && value instanceof FileList) {
26
- const serialized = new FormData();
35
+ const form = new FormData();
27
36
  for (let i = 0; i < value.length; i++) {
28
- serialized.append("files", value.item(i)!);
37
+ form.append("files", value.item(i)!);
29
38
  }
30
39
  return {
31
40
  contentType: undefined,
32
- serialized,
41
+ value: form,
33
42
  };
34
43
  }
35
44
 
36
45
  if (value instanceof Blob) {
37
46
  return {
38
47
  contentType: value.type,
39
- serialized: value,
48
+ value,
40
49
  };
41
50
  }
42
51
 
43
- switch (typeof value) {
44
- case "number":
45
- case "boolean":
46
- case "bigint":
47
- case "string":
48
- return { contentType: "text/plain", serialized: String(value) };
49
- case "object":
50
- return {
51
- contentType: "application/json",
52
- serialized: JSON.stringify(value),
53
- };
54
- }
55
-
56
- throw Error(
57
- "Could not serialize object for request: " + JSON.stringify(value),
58
- );
52
+ return {
53
+ contentType: "application/json",
54
+ value: JSON.stringify(value),
55
+ };
59
56
  }
60
57
 
61
- export async function smartDeserialize(
58
+ export function smartDeserialize(
62
59
  arg: Response | Request,
63
- ): Promise<unknown> {
60
+ ): Promise<unknown> | undefined {
61
+ if (arg instanceof Request && arg.method === "GET") return;
62
+
64
63
  const contentType = arg.headers.get("content-type");
65
64
  if (contentType == null) return;
66
65
 
67
66
  // JSON
68
67
  if (contentType.startsWith("application/json")) {
69
- return await arg.json();
68
+ return arg.json();
70
69
  }
71
70
 
72
71
  // Forms
@@ -74,17 +73,17 @@ export async function smartDeserialize(
74
73
  contentType.startsWith("application/x-www-form-urlencoded") ||
75
74
  contentType.startsWith("multipart/form-data")
76
75
  ) {
77
- return await arg.formData();
76
+ return arg.formData();
78
77
  }
79
78
 
80
79
  // Text
81
80
  if (contentType.startsWith("text/")) {
82
- return await arg.text();
81
+ return arg.text();
83
82
  }
84
83
 
85
84
  // Binary
86
85
  if (contentType.startsWith("application/octet-stream")) {
87
- return await arg.arrayBuffer();
86
+ return arg.arrayBuffer();
88
87
  }
89
88
 
90
89
  // Unknown
@@ -1,6 +1,10 @@
1
1
  import type { StandardSchemaV1 } from "@standard-schema/spec";
2
- import { HttpError, ValidationGlobalError } from "../errors";
2
+ import type { MatchedRoute } from "rou3";
3
+ import { HttpError } from "../errors";
4
+ import type { ErrorResponse } from "../schema";
3
5
  import { HttpStatus } from "../status";
6
+ import { createBunTransport } from "../transports/bun-transport";
7
+ import { createDenoTransport } from "../transports/deno-transport";
4
8
  import type {
5
9
  App,
6
10
  LifeCycleHook,
@@ -9,38 +13,28 @@ import type {
9
13
  StatusResult,
10
14
  Transport,
11
15
  } from "../types";
12
- import type { MatchedRoute } from "rou3";
13
- import type { ErrorResponse } from "../schema";
14
- import { createBunTransport } from "../transports/bun-transport";
15
- import { createDenoTransport } from "../transports/deno-transport";
16
16
 
17
17
  export function validateSchema<T>(
18
18
  schema: StandardSchemaV1<T, T>,
19
19
  input: unknown,
20
+ status: number,
21
+ message: string,
20
22
  ): T {
21
- let res = schema["~standard"].validate(input);
23
+ const res = schema["~standard"].validate(input);
22
24
  if (res instanceof Promise) throw Error("Async validation not supported");
23
25
 
24
- if (res.issues) throw new ValidationGlobalError(input, res.issues);
26
+ if (res.issues)
27
+ throw new HttpError(status, message, {
28
+ issues: res.issues,
29
+ input: input,
30
+ });
25
31
 
26
32
  return res.value;
27
33
  }
28
34
 
29
- function createHttpSchemaValidator(status: HttpStatus, message: string) {
30
- return <T>(schema: StandardSchemaV1<T, T>, input: unknown): T => {
31
- try {
32
- return validateSchema<T>(schema, input);
33
- } catch (err) {
34
- if (err instanceof ValidationGlobalError) {
35
- throw new HttpError(status, message, {
36
- issues: err.issues,
37
- input: err.input,
38
- });
39
- } else {
40
- throw err;
41
- }
42
- }
43
- };
35
+ function createHttpSchemaValidator(status: number, message: string) {
36
+ return <T>(schema: StandardSchemaV1<T, T>, input: unknown): T =>
37
+ validateSchema<T>(schema, input, status, message);
44
38
  }
45
39
 
46
40
  export const validateInputSchema = createHttpSchemaValidator(
@@ -56,32 +50,55 @@ export function isApp(obj: unknown): obj is App<any> {
56
50
  return (obj as any)[Symbol.toStringTag] === "ZetaApp";
57
51
  }
58
52
 
59
- export function getRawQuery(url: URL): Record<string, string> {
60
- const query: Record<string, string> = {};
61
- const params = url.searchParams;
62
- const entries = params.entries();
53
+ export function getRawPathname(request: Request): string {
54
+ // Fast path for common case: http://host/path
55
+ const start = request.url.indexOf("/", 8); // Skip 'http://' or 'https://'
56
+ if (start === -1) return "/";
57
+
58
+ // Find end of pathname (before ? or #)
59
+ for (let i = start + 1; i < request.url.length; i++) {
60
+ if (request.url[i] === "?" || request.url[i] === "#") {
61
+ return request.url.slice(start, i);
62
+ }
63
+ }
64
+ return request.url.slice(start);
65
+ }
63
66
 
64
- for (const entry of entries) query[entry[0]] = entry[1];
65
- return query;
67
+ export function getRawQuery(request: Request): Record<string, string> {
68
+ let index = request.url.indexOf("?");
69
+ if (index === -1) return {};
70
+
71
+ const res: Record<string, string> = {};
72
+ const str = request.url;
73
+ const len = str.length;
74
+ let start = index + 1;
75
+
76
+ for (let i = start; i < len; i++) {
77
+ if (str[i] === "&" || i === len - 1) {
78
+ const end = i === len - 1 ? len : i;
79
+ const eqIndex = str.indexOf("=", start);
80
+ if (eqIndex !== -1 && eqIndex < end) {
81
+ res[str.slice(start, eqIndex)] = str.slice(eqIndex + 1, end);
82
+ }
83
+ start = i + 1;
84
+ }
85
+ }
86
+ return res;
66
87
  }
67
88
 
68
89
  export function getRawParams(
69
90
  route: MatchedRoute<RouterData>,
70
91
  ): Record<string, string> {
71
- const rawParams = route.params ?? {};
72
- // Rename _ to ** for validation and consistency
73
- if ("_" in rawParams) {
74
- rawParams["**"] = rawParams["_"];
75
- delete rawParams["_"];
92
+ const params = route.params;
93
+ if (!params) return {};
94
+
95
+ const res: Record<string, string> = {};
96
+ for (const key in params) {
97
+ // Rename rou3's _ to ** to match type-system
98
+ const outKey = key === "_" ? "**" : key;
99
+ res[outKey] = decodeURIComponent(params[key]);
76
100
  }
77
-
78
- // Decode all values automatically
79
- return Object.fromEntries(
80
- Object.entries(rawParams).map(([key, value]) => [
81
- key,
82
- decodeURIComponent(value),
83
- ]),
84
- );
101
+ return res;
85
102
  }
86
103
 
87
104
  export function getErrorStack(err: Error): string[] | undefined {
@@ -122,15 +139,17 @@ export function serializeErrorResponse(err: unknown): ErrorResponse {
122
139
 
123
140
  export async function callCtxModifierHooks(
124
141
  ctx: any,
125
- hooks: LifeCycleHook<
126
- (ctx: any) => MaybePromise<Record<string, any> | void>
127
- >[],
142
+ hooks:
143
+ | LifeCycleHook<(ctx: any) => MaybePromise<Record<string, any> | void>>[]
144
+ | undefined,
128
145
  ): Promise<Response | undefined> {
146
+ if (!hooks) return;
147
+
129
148
  for (const hook of hooks) {
130
149
  let res = hook.callback(ctx);
131
- res = res instanceof Promise ? await res : res;
150
+ if (res instanceof Promise) res = await res;
132
151
  if (res instanceof Response) return res;
133
- if (res) Object.assign(ctx, res);
152
+ if (res) Object.assign(ctx, res); // TODO: Replace with manual property setting for performance?
134
153
  }
135
154
  }
136
155
 
@@ -158,3 +177,15 @@ export function detectTransport(): Transport {
158
177
  app.listen();
159
178
  ---`);
160
179
  }
180
+
181
+ export function cleanupCompiledWhitespace(code: string): string {
182
+ return (
183
+ code
184
+ // Remove lines only containing spaces
185
+ .replace(/^ +$/gm, "")
186
+ // Reduce multiple newlines to one
187
+ .replace(/\n\n+/gm, "\n\n")
188
+ // Remove blank lines after curly braces
189
+ .replaceAll("{\n\n", "{\n")
190
+ );
191
+ }
package/src/open-api.ts CHANGED
@@ -1,14 +1,15 @@
1
+ import type { StandardSchemaV1 } from "@standard-schema/spec";
1
2
  import type { OpenAPI } from "openapi-types";
2
- import type { App, BasePath, SchemaAdapter } from "./types";
3
+ import { titleCase } from "scule";
3
4
  import type { CreateAppOptions } from "./app";
4
- import type { StandardSchemaV1 } from "@standard-schema/spec";
5
- import { getHttpStatusName } from "./status";
5
+ import { getMeta } from "./meta";
6
6
  import {
7
7
  ErrorResponseJsonSchema,
8
8
  isZetaSchema,
9
9
  type ZetaSchema,
10
10
  } from "./schema";
11
- import { getMeta } from "./meta";
11
+ import { getHttpStatusName } from "./status";
12
+ import type { App, BasePath, SchemaAdapter } from "./types";
12
13
 
13
14
  export function buildOpenApiDocs(
14
15
  options: CreateAppOptions<any> | undefined,
@@ -39,11 +40,14 @@ export function buildOpenApiDocs(
39
40
  };
40
41
  for (const [method, methodEntry] of Object.entries(app["~zeta"].routes)) {
41
42
  for (const [path, routerData] of Object.entries(methodEntry)) {
42
- const openApiPath = path
43
- // Replace parameters with OpenAPI format
44
- .replace(/\/:([^/]+)/g, "/{$1}")
45
- // Remove trailing slash
46
- .replace(/\/$/, "");
43
+ const openApiPath =
44
+ path
45
+ // Replace parameters with OpenAPI format
46
+ .replace(/\/:([^/]+)/g, "/{$1}")
47
+ // Remove trailing slash
48
+ .replace(/\/$/, "") ||
49
+ // Convert "" -> "/"
50
+ "/";
47
51
  const { headers, params, query, body, responses, ...openApiOperation } =
48
52
  routerData.def ?? {};
49
53
  docs.paths ??= {};
@@ -51,6 +55,11 @@ export function buildOpenApiDocs(
51
55
 
52
56
  (docs.paths[openApiPath] as any)[method.toLowerCase()] = {
53
57
  ...openApiOperation,
58
+ summary:
59
+ openApiOperation.summary ??
60
+ (openApiOperation.operationId
61
+ ? titleCase(openApiOperation.operationId)
62
+ : undefined),
54
63
  requestBody: body
55
64
  ? {
56
65
  content: {
@@ -143,19 +152,25 @@ export function buildScalarHtml(
143
152
  function mapParameters(
144
153
  adapter: SchemaAdapter,
145
154
  schema: StandardSchemaV1 | undefined,
146
- _in: "query" | "path" | "header",
155
+ in_: "query" | "path" | "header",
147
156
  ): OpenAPI.Parameters {
148
157
  if (!schema) return [];
149
158
 
150
- return adapter
151
- .parseParamsRecord(schema)
152
- .map(({ schema, optional, description, name }) => ({
159
+ const openApiSchema = adapter.toJsonSchema(schema);
160
+ if (openApiSchema.type !== "object")
161
+ throw Error(
162
+ `Param in ${in_} must have { "type": "object", ... }, but got ${JSON.stringify(openApiSchema, null, 2)}`,
163
+ );
164
+
165
+ return Object.entries(openApiSchema.properties).map(
166
+ ([name, def]: [string, any]) => ({
153
167
  name,
154
- in: _in,
155
- description,
156
- schema: adapter.toJsonSchema(schema),
157
- required: !optional,
158
- }));
168
+ in: in_,
169
+ description: def.description,
170
+ schema: def,
171
+ required: !!openApiSchema.required?.includes(name),
172
+ }),
173
+ );
159
174
  }
160
175
 
161
176
  function buildResponse(