1tube 0.1.28 → 0.1.30

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/eslint-plugin.js CHANGED
@@ -71,6 +71,15 @@ const plugin = {
71
71
  schema: [],
72
72
  },
73
73
  create(context) {
74
+ function hasRequireProperty(node) {
75
+ return node.properties.some(
76
+ (p) =>
77
+ p.type === "Property" &&
78
+ p.key.type === "Identifier" &&
79
+ p.key.name === "require",
80
+ );
81
+ }
82
+
74
83
  return {
75
84
  CallExpression(node) {
76
85
  if (
@@ -80,6 +89,16 @@ const plugin = {
80
89
  return;
81
90
  }
82
91
 
92
+ if (
93
+ node.arguments[0] &&
94
+ node.arguments[0].type === "ObjectExpression"
95
+ ) {
96
+ if (!hasRequireProperty(node.arguments[0])) {
97
+ context.report({ node, messageId: "missingRequire" });
98
+ }
99
+ return;
100
+ }
101
+
83
102
  const opts = node.arguments[1];
84
103
 
85
104
  if (!opts || opts.type !== "ObjectExpression") {
@@ -87,14 +106,7 @@ const plugin = {
87
106
  return;
88
107
  }
89
108
 
90
- const hasRequire = opts.properties.some(
91
- (p) =>
92
- p.type === "Property" &&
93
- p.key.type === "Identifier" &&
94
- p.key.name === "require",
95
- );
96
-
97
- if (!hasRequire) {
109
+ if (!hasRequireProperty(opts)) {
98
110
  context.report({ node, messageId: "missingRequire" });
99
111
  }
100
112
  },
@@ -102,6 +114,133 @@ const plugin = {
102
114
  },
103
115
  },
104
116
 
117
+ "no-ad-hoc-edge-json-response": {
118
+ meta: {
119
+ type: "problem",
120
+ docs: {
121
+ description:
122
+ "Disallow constructing JSON responses directly in route-first edge functions",
123
+ },
124
+ messages: {
125
+ useSharedResponse:
126
+ "Route-first edge functions must use shared response helpers from _shared/handler.ts instead of constructing JSON responses directly.",
127
+ },
128
+ schema: [],
129
+ },
130
+ create(context) {
131
+ const serveBindings = new Set();
132
+ const handlerNamespaceBindings = new Set();
133
+ const routeFirstServeCalls = new WeakSet();
134
+ let hasRouteFirstServeCall = false;
135
+
136
+ function isHandlerImportSource(value) {
137
+ return typeof value === "string" &&
138
+ /(?:^|\/)_shared\/handler(?:\.[cm]?[jt]s)?$/.test(value);
139
+ }
140
+
141
+ function propertyName(prop) {
142
+ if (prop.type === "Identifier") return prop.name;
143
+ if (prop.type === "Literal") return String(prop.value);
144
+ return null;
145
+ }
146
+
147
+ function hasObjectProperty(node, name) {
148
+ return node.properties.some((p) =>
149
+ p.type === "Property" && propertyName(p.key) === name
150
+ );
151
+ }
152
+
153
+ function isKnownServeCallee(node) {
154
+ if (node.type === "Identifier") return serveBindings.has(node.name);
155
+ return node.type === "MemberExpression" &&
156
+ node.object.type === "Identifier" &&
157
+ handlerNamespaceBindings.has(node.object.name) &&
158
+ propertyName(node.property) === "serve";
159
+ }
160
+
161
+ function isServeConfig(node) {
162
+ return node.type === "CallExpression" &&
163
+ isKnownServeCallee(node.callee) &&
164
+ node.arguments[0] &&
165
+ node.arguments[0].type === "ObjectExpression" &&
166
+ hasObjectProperty(node.arguments[0], "routes");
167
+ }
168
+
169
+ function isJsonStringifyCall(node) {
170
+ return node &&
171
+ node.type === "CallExpression" &&
172
+ node.callee.type === "MemberExpression" &&
173
+ node.callee.object.type === "Identifier" &&
174
+ node.callee.object.name === "JSON" &&
175
+ node.callee.property.type === "Identifier" &&
176
+ node.callee.property.name === "stringify";
177
+ }
178
+
179
+ function isNewJsonResponse(node) {
180
+ return node.type === "NewExpression" &&
181
+ node.callee.type === "Identifier" &&
182
+ node.callee.name === "Response" &&
183
+ node.arguments &&
184
+ node.arguments[0] &&
185
+ isJsonStringifyCall(node.arguments[0]);
186
+ }
187
+
188
+ function isResponseJsonCall(node) {
189
+ return node.type === "CallExpression" &&
190
+ node.callee.type === "MemberExpression" &&
191
+ node.callee.object.type === "Identifier" &&
192
+ node.callee.object.name === "Response" &&
193
+ node.callee.property.type === "Identifier" &&
194
+ node.callee.property.name === "json";
195
+ }
196
+
197
+ function isInsideRouteFirstServeCall(node) {
198
+ let current = node.parent;
199
+ while (current) {
200
+ if (routeFirstServeCalls.has(current)) return true;
201
+ current = current.parent;
202
+ }
203
+ return false;
204
+ }
205
+
206
+ function shouldReport(node) {
207
+ return hasRouteFirstServeCall || isInsideRouteFirstServeCall(node);
208
+ }
209
+
210
+ return {
211
+ ImportDeclaration(node) {
212
+ if (!isHandlerImportSource(node.source.value)) return;
213
+ for (const specifier of node.specifiers) {
214
+ if (
215
+ specifier.type === "ImportSpecifier" &&
216
+ propertyName(specifier.imported) === "serve"
217
+ ) {
218
+ serveBindings.add(specifier.local.name);
219
+ }
220
+ if (specifier.type === "ImportNamespaceSpecifier") {
221
+ handlerNamespaceBindings.add(specifier.local.name);
222
+ }
223
+ }
224
+ },
225
+ CallExpression(node) {
226
+ if (isServeConfig(node)) {
227
+ routeFirstServeCalls.add(node);
228
+ hasRouteFirstServeCall = true;
229
+ return;
230
+ }
231
+ if (shouldReport(node) && isResponseJsonCall(node)) {
232
+ context.report({ node, messageId: "useSharedResponse" });
233
+ }
234
+ },
235
+ NewExpression(node) {
236
+ if (shouldReport(node) && isNewJsonResponse(node)) {
237
+ context.report({ node, messageId: "useSharedResponse" });
238
+ }
239
+ },
240
+ };
241
+ },
242
+ },
243
+
105
244
  "no-direct-edge-url": {
106
245
  meta: {
107
246
  type: "problem",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "1tube",
3
- "version": "0.1.28",
3
+ "version": "0.1.30",
4
4
  "private": false,
5
5
  "description": "Self-hosted Supabase Edge Functions gateway for Deno, plus an ESLint plugin for consumers",
6
6
  "license": "MIT",
@@ -14,6 +14,8 @@
14
14
  "url": "https://github.com/lofcz/1tube/issues"
15
15
  },
16
16
  "exports": {
17
+ "./edge": "./src/edge.ts",
18
+ "./edge-caller": "./src/edge-caller.ts",
17
19
  "./eslint-plugin": "./eslint-plugin.js",
18
20
  "./package.json": "./package.json"
19
21
  },
@@ -36,7 +38,7 @@
36
38
  "scripts": {
37
39
  "dev": "deno task dev",
38
40
  "serve": "deno task serve",
39
- "typecheck": "deno check src/server.ts src/cli.ts eslint-plugin.js",
41
+ "typecheck": "deno check src/server.ts src/cli.ts src/edge.ts src/edge-caller.ts eslint-plugin.js",
40
42
  "check": "deno cache --allow-scripts=npm:protobufjs src/server.ts",
41
43
  "smoke:bin": "node ./scripts/npm-cli.js --version",
42
44
  "prepublishOnly": "bun run typecheck && bun run check && bun run smoke:bin"
@@ -25,6 +25,7 @@ const child = spawn(
25
25
  ],
26
26
  {
27
27
  stdio: "inherit",
28
+ shell: process.platform === "win32",
28
29
  windowsHide: false,
29
30
  },
30
31
  );
@@ -0,0 +1,141 @@
1
+ export type CallEdgeAuth = "service" | "user" | { bearer: string };
2
+
3
+ export interface CallEdgeFunctionOptions {
4
+ /** HTTP method. Defaults to `"POST"`. */
5
+ method?: string;
6
+ /** Optional route path inside the target function, e.g. `/sync`. */
7
+ path?: string;
8
+ /** Query parameters appended to the function URL. */
9
+ query?: Record<string, string | number | boolean | null | undefined>;
10
+ body?: unknown;
11
+ auth?: CallEdgeAuth;
12
+ headers?: Record<string, string>;
13
+ signal?: AbortSignal;
14
+ }
15
+
16
+ export interface EdgeCallerRuntime {
17
+ getBaseUrl(): string;
18
+ /** Public prefix in front of the function name. Defaults to `/functions/v1`. */
19
+ pathPrefix?: string;
20
+ getServiceRoleToken(): string | null | undefined;
21
+ getAnonKey?(): string | null | undefined;
22
+ getUserToken(): string | null | undefined;
23
+ fetch?: typeof fetch;
24
+ waitUntil?: (promise: Promise<unknown>) => void;
25
+ }
26
+
27
+ export interface EdgeCaller {
28
+ callEdgeFunction(
29
+ name: string,
30
+ options?: CallEdgeFunctionOptions,
31
+ ): Promise<Response>;
32
+ callEdgeFunctionInBackground(
33
+ name: string,
34
+ options?: CallEdgeFunctionOptions,
35
+ ): void;
36
+ }
37
+
38
+ function resolveBearerToken(
39
+ runtime: EdgeCallerRuntime,
40
+ auth: CallEdgeAuth,
41
+ ): string {
42
+ if (typeof auth === "object" && auth !== null && "bearer" in auth) {
43
+ return auth.bearer;
44
+ }
45
+
46
+ if (auth === "user") {
47
+ const token = runtime.getUserToken();
48
+ if (!token) {
49
+ throw new Error(
50
+ "[callEdgeFunction] auth: 'user' requires an authenticated caller " +
51
+ "(no user token available).",
52
+ );
53
+ }
54
+ return token;
55
+ }
56
+
57
+ const serviceToken = runtime.getServiceRoleToken();
58
+ if (!serviceToken) {
59
+ throw new Error(
60
+ "[callEdgeFunction] service auth requires a service role token.",
61
+ );
62
+ }
63
+ return serviceToken;
64
+ }
65
+
66
+ function serializeBody(
67
+ body: unknown,
68
+ ): { body: BodyInit | null | undefined; contentType?: string } {
69
+ if (body === undefined || body === null) return { body: undefined };
70
+ if (typeof body === "string") {
71
+ return { body, contentType: "text/plain;charset=UTF-8" };
72
+ }
73
+ if (
74
+ body instanceof Uint8Array ||
75
+ body instanceof ArrayBuffer ||
76
+ body instanceof Blob ||
77
+ body instanceof FormData ||
78
+ body instanceof ReadableStream
79
+ ) {
80
+ return { body: body as BodyInit };
81
+ }
82
+ return { body: JSON.stringify(body), contentType: "application/json" };
83
+ }
84
+
85
+ function buildRequest(
86
+ runtime: EdgeCallerRuntime,
87
+ name: string,
88
+ opts: CallEdgeFunctionOptions,
89
+ ): { url: string; init: RequestInit } {
90
+ const pathPrefix = runtime.pathPrefix ?? "/functions/v1";
91
+ const routePath = opts.path ? `/${opts.path.replace(/^\/+/, "")}` : "";
92
+ const url = new URL(
93
+ `${runtime.getBaseUrl().replace(/\/+$/, "")}/${
94
+ pathPrefix.replace(/^\/+|\/+$/g, "")
95
+ }/${name}${routePath}`,
96
+ );
97
+ for (const [key, value] of Object.entries(opts.query ?? {})) {
98
+ if (value !== undefined && value !== null) {
99
+ url.searchParams.set(key, String(value));
100
+ }
101
+ }
102
+ const token = resolveBearerToken(runtime, opts.auth ?? "service");
103
+ const { body, contentType } = serializeBody(opts.body);
104
+ const anonKey = runtime.getAnonKey?.();
105
+
106
+ const headers: Record<string, string> = {
107
+ Authorization: `Bearer ${token}`,
108
+ ...(contentType ? { "Content-Type": contentType } : {}),
109
+ ...(anonKey ? { apikey: anonKey } : {}),
110
+ ...opts.headers,
111
+ };
112
+
113
+ return {
114
+ url: url.toString(),
115
+ init: {
116
+ method: opts.method ?? "POST",
117
+ headers,
118
+ body: body ?? null,
119
+ signal: opts.signal,
120
+ },
121
+ };
122
+ }
123
+
124
+ export function createEdgeCaller(runtime: EdgeCallerRuntime): EdgeCaller {
125
+ const fetchImpl = runtime.fetch ?? fetch;
126
+
127
+ return {
128
+ async callEdgeFunction(name, options = {}) {
129
+ const { url, init } = buildRequest(runtime, name, options);
130
+ return await fetchImpl(url, init);
131
+ },
132
+
133
+ callEdgeFunctionInBackground(name, options = {}) {
134
+ const { url, init } = buildRequest(runtime, name, options);
135
+ const promise = fetchImpl(url, init).catch((err) => {
136
+ console.error(`[callEdgeFunctionInBackground] ${name} failed:`, err);
137
+ });
138
+ runtime.waitUntil?.(promise);
139
+ },
140
+ };
141
+ }
package/src/edge.ts ADDED
@@ -0,0 +1,911 @@
1
+ export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
2
+
3
+ export interface SchemaLike<T = unknown> {
4
+ safeParse(
5
+ value: unknown,
6
+ ): { success: true; data: T } | { success: false; error: unknown };
7
+ }
8
+
9
+ export interface JsonSchemaAdapter {
10
+ toJsonSchema(schema: SchemaLike): unknown;
11
+ }
12
+
13
+ type SchemaOutput<TSchema> = TSchema extends SchemaLike<infer T> ? T
14
+ : undefined;
15
+ type ParamsOutput<TSchema> = TSchema extends SchemaLike<infer T> ? T
16
+ : Record<string, string>;
17
+ type QueryOutput<TSchema> = TSchema extends SchemaLike<infer T> ? T
18
+ : Record<string, string>;
19
+
20
+ export enum HttpStatus {
21
+ OK = 200,
22
+ CREATED = 201,
23
+ ACCEPTED = 202,
24
+ NO_CONTENT = 204,
25
+ RESET_CONTENT = 205,
26
+ BAD_REQUEST = 400,
27
+ UNAUTHORIZED = 401,
28
+ FORBIDDEN = 403,
29
+ NOT_FOUND = 404,
30
+ METHOD_NOT_ALLOWED = 405,
31
+ REQUEST_TIMEOUT = 408,
32
+ CONFLICT = 409,
33
+ GONE = 410,
34
+ PRECONDITION_FAILED = 412,
35
+ PAYLOAD_TOO_LARGE = 413,
36
+ UNSUPPORTED_MEDIA_TYPE = 415,
37
+ UNPROCESSABLE_CONTENT = 422,
38
+ LOCKED = 423,
39
+ PRECONDITION_REQUIRED = 428,
40
+ TOO_MANY_REQUESTS = 429,
41
+ INTERNAL_SERVER_ERROR = 500,
42
+ NOT_IMPLEMENTED = 501,
43
+ BAD_GATEWAY = 502,
44
+ SERVICE_UNAVAILABLE = 503,
45
+ GATEWAY_TIMEOUT = 504,
46
+ }
47
+
48
+ export type ErrorHttpStatus =
49
+ | HttpStatus.BAD_REQUEST
50
+ | HttpStatus.UNAUTHORIZED
51
+ | HttpStatus.FORBIDDEN
52
+ | HttpStatus.NOT_FOUND
53
+ | HttpStatus.METHOD_NOT_ALLOWED
54
+ | HttpStatus.REQUEST_TIMEOUT
55
+ | HttpStatus.CONFLICT
56
+ | HttpStatus.GONE
57
+ | HttpStatus.PRECONDITION_FAILED
58
+ | HttpStatus.PAYLOAD_TOO_LARGE
59
+ | HttpStatus.UNSUPPORTED_MEDIA_TYPE
60
+ | HttpStatus.UNPROCESSABLE_CONTENT
61
+ | HttpStatus.LOCKED
62
+ | HttpStatus.PRECONDITION_REQUIRED
63
+ | HttpStatus.TOO_MANY_REQUESTS
64
+ | HttpStatus.INTERNAL_SERVER_ERROR
65
+ | HttpStatus.NOT_IMPLEMENTED
66
+ | HttpStatus.BAD_GATEWAY
67
+ | HttpStatus.SERVICE_UNAVAILABLE
68
+ | HttpStatus.GATEWAY_TIMEOUT;
69
+
70
+ export type RouteResponses = Partial<Record<HttpStatus, SchemaLike>>;
71
+
72
+ export interface RouteRequest<
73
+ TParams extends SchemaLike | undefined,
74
+ TQuery extends SchemaLike | undefined,
75
+ TBody extends SchemaLike | undefined,
76
+ TContext,
77
+ > {
78
+ req: Request;
79
+ ctx: TContext;
80
+ params: ParamsOutput<TParams>;
81
+ query: QueryOutput<TQuery>;
82
+ body: SchemaOutput<TBody>;
83
+ }
84
+
85
+ export type RouteHandler<
86
+ TParams extends SchemaLike | undefined = undefined,
87
+ TQuery extends SchemaLike | undefined = undefined,
88
+ TBody extends SchemaLike | undefined = undefined,
89
+ TContext = unknown,
90
+ > = (
91
+ input: RouteRequest<TParams, TQuery, TBody, TContext>,
92
+ ) => Response | Promise<Response>;
93
+
94
+ export interface RouteOptions<
95
+ TParams extends SchemaLike | undefined = undefined,
96
+ TQuery extends SchemaLike | undefined = undefined,
97
+ TBody extends SchemaLike | undefined = undefined,
98
+ TResponses extends RouteResponses = RouteResponses,
99
+ TContext = unknown,
100
+ > {
101
+ params?: TParams;
102
+ query?: TQuery;
103
+ body?: TBody;
104
+ responses: TResponses;
105
+ summary?: string;
106
+ description?: string;
107
+ handler: RouteHandler<TParams, TQuery, TBody, TContext>;
108
+ }
109
+
110
+ export interface RouteDefinition<
111
+ TParams extends SchemaLike | undefined = SchemaLike | undefined,
112
+ TQuery extends SchemaLike | undefined = SchemaLike | undefined,
113
+ TBody extends SchemaLike | undefined = SchemaLike | undefined,
114
+ TResponses extends RouteResponses = RouteResponses,
115
+ TContext = unknown,
116
+ > extends RouteOptions<TParams, TQuery, TBody, TResponses, TContext> {
117
+ method: HttpMethod;
118
+ path: string;
119
+ operationId: string;
120
+ }
121
+
122
+ export interface JsonRouteOptions<
123
+ TBody extends SchemaLike,
124
+ TResponses extends RouteResponses,
125
+ TContext,
126
+ > {
127
+ body: TBody;
128
+ responses: TResponses;
129
+ summary?: string;
130
+ description?: string;
131
+ handler: RouteHandler<undefined, undefined, TBody, TContext>;
132
+ }
133
+
134
+ function defineRoute<
135
+ TParams extends SchemaLike | undefined,
136
+ TQuery extends SchemaLike | undefined,
137
+ TBody extends SchemaLike | undefined,
138
+ TResponses extends RouteResponses,
139
+ TContext,
140
+ >(
141
+ method: HttpMethod,
142
+ path: string,
143
+ operationId: string,
144
+ options: RouteOptions<TParams, TQuery, TBody, TResponses, TContext>,
145
+ ): RouteDefinition<TParams, TQuery, TBody, TResponses, TContext> {
146
+ return { method, path: normalizeRoutePath(path), operationId, ...options };
147
+ }
148
+
149
+ function defineJsonRoute<
150
+ TBody extends SchemaLike,
151
+ TResponses extends RouteResponses,
152
+ TContext,
153
+ >(
154
+ method: HttpMethod,
155
+ path: string,
156
+ operationId: string,
157
+ options: JsonRouteOptions<TBody, TResponses, TContext>,
158
+ ): RouteDefinition<undefined, undefined, TBody, TResponses, TContext> {
159
+ return defineRoute(method, path, operationId, options);
160
+ }
161
+
162
+ export function createRouteBuilder<TDefaultContext = unknown>() {
163
+ return {
164
+ get: <
165
+ TParams extends SchemaLike | undefined = undefined,
166
+ TQuery extends SchemaLike | undefined = undefined,
167
+ TResponses extends RouteResponses = RouteResponses,
168
+ >(
169
+ path: string,
170
+ operationId: string,
171
+ options: RouteOptions<
172
+ TParams,
173
+ TQuery,
174
+ undefined,
175
+ TResponses,
176
+ TDefaultContext
177
+ >,
178
+ ) => defineRoute("GET", path, operationId, options),
179
+
180
+ post: <
181
+ TParams extends SchemaLike | undefined = undefined,
182
+ TQuery extends SchemaLike | undefined = undefined,
183
+ TBody extends SchemaLike | undefined = undefined,
184
+ TResponses extends RouteResponses = RouteResponses,
185
+ >(
186
+ path: string,
187
+ operationId: string,
188
+ options: RouteOptions<
189
+ TParams,
190
+ TQuery,
191
+ TBody,
192
+ TResponses,
193
+ TDefaultContext
194
+ >,
195
+ ) => defineRoute("POST", path, operationId, options),
196
+
197
+ put: <
198
+ TParams extends SchemaLike | undefined = undefined,
199
+ TQuery extends SchemaLike | undefined = undefined,
200
+ TBody extends SchemaLike | undefined = undefined,
201
+ TResponses extends RouteResponses = RouteResponses,
202
+ >(
203
+ path: string,
204
+ operationId: string,
205
+ options: RouteOptions<
206
+ TParams,
207
+ TQuery,
208
+ TBody,
209
+ TResponses,
210
+ TDefaultContext
211
+ >,
212
+ ) => defineRoute("PUT", path, operationId, options),
213
+
214
+ patch: <
215
+ TParams extends SchemaLike | undefined = undefined,
216
+ TQuery extends SchemaLike | undefined = undefined,
217
+ TBody extends SchemaLike | undefined = undefined,
218
+ TResponses extends RouteResponses = RouteResponses,
219
+ >(
220
+ path: string,
221
+ operationId: string,
222
+ options: RouteOptions<
223
+ TParams,
224
+ TQuery,
225
+ TBody,
226
+ TResponses,
227
+ TDefaultContext
228
+ >,
229
+ ) => defineRoute("PATCH", path, operationId, options),
230
+
231
+ delete: <
232
+ TParams extends SchemaLike | undefined = undefined,
233
+ TQuery extends SchemaLike | undefined = undefined,
234
+ TResponses extends RouteResponses = RouteResponses,
235
+ >(
236
+ path: string,
237
+ operationId: string,
238
+ options: RouteOptions<
239
+ TParams,
240
+ TQuery,
241
+ undefined,
242
+ TResponses,
243
+ TDefaultContext
244
+ >,
245
+ ) => defineRoute("DELETE", path, operationId, options),
246
+
247
+ postJson: <
248
+ TBody extends SchemaLike,
249
+ TResponses extends RouteResponses,
250
+ >(
251
+ path: string,
252
+ operationId: string,
253
+ options: JsonRouteOptions<TBody, TResponses, TDefaultContext>,
254
+ ) => defineJsonRoute("POST", path, operationId, options),
255
+
256
+ putJson: <
257
+ TBody extends SchemaLike,
258
+ TResponses extends RouteResponses,
259
+ >(
260
+ path: string,
261
+ operationId: string,
262
+ options: JsonRouteOptions<TBody, TResponses, TDefaultContext>,
263
+ ) => defineJsonRoute("PUT", path, operationId, options),
264
+
265
+ patchJson: <
266
+ TBody extends SchemaLike,
267
+ TResponses extends RouteResponses,
268
+ >(
269
+ path: string,
270
+ operationId: string,
271
+ options: JsonRouteOptions<TBody, TResponses, TDefaultContext>,
272
+ ) => defineJsonRoute("PATCH", path, operationId, options),
273
+ };
274
+ }
275
+
276
+ export const route = createRouteBuilder();
277
+
278
+ export function json(
279
+ body: unknown,
280
+ init: number | ResponseInit = HttpStatus.OK,
281
+ ): Response {
282
+ const responseInit: ResponseInit = typeof init === "number"
283
+ ? { status: init }
284
+ : init;
285
+ const headers = new Headers(responseInit.headers);
286
+ if (!headers.has("Content-Type")) {
287
+ headers.set("Content-Type", "application/json");
288
+ }
289
+ return new Response(JSON.stringify(body), { ...responseInit, headers });
290
+ }
291
+
292
+ export function jsonError(
293
+ status: ErrorHttpStatus,
294
+ message: string,
295
+ details?: unknown,
296
+ init: Omit<ResponseInit, "status"> = {},
297
+ ): Response {
298
+ return json(
299
+ { error: message, ...(details === undefined ? {} : { details }) },
300
+ { ...init, status },
301
+ );
302
+ }
303
+
304
+ export function empty(
305
+ status: HttpStatus = HttpStatus.NO_CONTENT,
306
+ init: Omit<ResponseInit, "status"> = {},
307
+ ): Response {
308
+ return new Response(null, { ...init, status });
309
+ }
310
+
311
+ export function text(
312
+ body: string,
313
+ init: number | ResponseInit = HttpStatus.OK,
314
+ ): Response {
315
+ const responseInit: ResponseInit = typeof init === "number"
316
+ ? { status: init }
317
+ : init;
318
+ const headers = new Headers(responseInit.headers);
319
+ if (!headers.has("Content-Type")) {
320
+ headers.set("Content-Type", "text/plain;charset=UTF-8");
321
+ }
322
+ return new Response(body, { ...responseInit, headers });
323
+ }
324
+
325
+ export function created(
326
+ body: unknown,
327
+ init: Omit<ResponseInit, "status"> = {},
328
+ ): Response {
329
+ return json(body, { ...init, status: HttpStatus.CREATED });
330
+ }
331
+
332
+ export function accepted(
333
+ body: unknown,
334
+ init: Omit<ResponseInit, "status"> = {},
335
+ ): Response {
336
+ return json(body, { ...init, status: HttpStatus.ACCEPTED });
337
+ }
338
+
339
+ export function noContent(init: Omit<ResponseInit, "status"> = {}): Response {
340
+ return empty(HttpStatus.NO_CONTENT, init);
341
+ }
342
+
343
+ export function badRequest(
344
+ message = "Bad request",
345
+ details?: unknown,
346
+ ): Response {
347
+ return jsonError(HttpStatus.BAD_REQUEST, message, details);
348
+ }
349
+
350
+ export function unauthorized(
351
+ message = "Unauthorized",
352
+ details?: unknown,
353
+ ): Response {
354
+ return jsonError(HttpStatus.UNAUTHORIZED, message, details);
355
+ }
356
+
357
+ export function forbidden(message = "Forbidden", details?: unknown): Response {
358
+ return jsonError(HttpStatus.FORBIDDEN, message, details);
359
+ }
360
+
361
+ export function notFound(message = "Not found", details?: unknown): Response {
362
+ return jsonError(HttpStatus.NOT_FOUND, message, details);
363
+ }
364
+
365
+ export function methodNotAllowed(
366
+ allowedMethods: readonly string[],
367
+ message = "Method not allowed",
368
+ ): Response {
369
+ return jsonError(HttpStatus.METHOD_NOT_ALLOWED, message, undefined, {
370
+ headers: { Allow: [...allowedMethods].sort().join(", ") },
371
+ });
372
+ }
373
+
374
+ export function conflict(message = "Conflict", details?: unknown): Response {
375
+ return jsonError(HttpStatus.CONFLICT, message, details);
376
+ }
377
+
378
+ export function gone(message = "Gone", details?: unknown): Response {
379
+ return jsonError(HttpStatus.GONE, message, details);
380
+ }
381
+
382
+ export function payloadTooLarge(
383
+ message = "Payload too large",
384
+ details?: unknown,
385
+ ): Response {
386
+ return jsonError(HttpStatus.PAYLOAD_TOO_LARGE, message, details);
387
+ }
388
+
389
+ export function unsupportedMediaType(
390
+ message = "Unsupported media type",
391
+ details?: unknown,
392
+ ): Response {
393
+ return jsonError(HttpStatus.UNSUPPORTED_MEDIA_TYPE, message, details);
394
+ }
395
+
396
+ export function unprocessableContent(
397
+ message = "Unprocessable content",
398
+ details?: unknown,
399
+ ): Response {
400
+ return jsonError(HttpStatus.UNPROCESSABLE_CONTENT, message, details);
401
+ }
402
+
403
+ export function tooManyRequests(input: {
404
+ message?: string;
405
+ retryAfterSeconds?: number;
406
+ } = {}): Response {
407
+ const headers = input.retryAfterSeconds === undefined
408
+ ? undefined
409
+ : { "Retry-After": String(input.retryAfterSeconds) };
410
+ return json(
411
+ {
412
+ error: input.message ?? "Rate limit exceeded",
413
+ ...(input.retryAfterSeconds === undefined
414
+ ? {}
415
+ : { retryAfterSeconds: input.retryAfterSeconds }),
416
+ },
417
+ { status: HttpStatus.TOO_MANY_REQUESTS, headers },
418
+ );
419
+ }
420
+
421
+ export function internalServerError(
422
+ message = "Internal server error",
423
+ details?: unknown,
424
+ ): Response {
425
+ return jsonError(HttpStatus.INTERNAL_SERVER_ERROR, message, details);
426
+ }
427
+
428
+ export function badGateway(
429
+ message = "Bad gateway",
430
+ details?: unknown,
431
+ ): Response {
432
+ return jsonError(HttpStatus.BAD_GATEWAY, message, details);
433
+ }
434
+
435
+ export function serviceUnavailable(
436
+ message = "Service unavailable",
437
+ details?: unknown,
438
+ ): Response {
439
+ return jsonError(HttpStatus.SERVICE_UNAVAILABLE, message, details);
440
+ }
441
+
442
+ export function gatewayTimeout(
443
+ message = "Gateway timeout",
444
+ details?: unknown,
445
+ ): Response {
446
+ return jsonError(HttpStatus.GATEWAY_TIMEOUT, message, details);
447
+ }
448
+
449
+ export interface ServeConfig<TContext = unknown> {
450
+ name: string;
451
+ require: unknown;
452
+ timeoutMs?: number;
453
+ routes: RouteDefinition<
454
+ SchemaLike | undefined,
455
+ SchemaLike | undefined,
456
+ SchemaLike | undefined,
457
+ RouteResponses,
458
+ TContext
459
+ >[];
460
+ /**
461
+ * Public prefix in front of the function name. Supabase-compatible hosts use
462
+ * `/functions/v1`; ASP.NET Core or other reverse proxies may expose a
463
+ * different prefix while keeping the same function modules.
464
+ */
465
+ pathPrefix?: string;
466
+ }
467
+
468
+ export interface MatchedRoute {
469
+ route: RouteDefinition<
470
+ SchemaLike | undefined,
471
+ SchemaLike | undefined,
472
+ SchemaLike | undefined,
473
+ RouteResponses,
474
+ any
475
+ >;
476
+ params: Record<string, string>;
477
+ }
478
+
479
+ export interface RouteDispatcherContext<TContext> {
480
+ req: Request;
481
+ ctx: TContext;
482
+ matched: MatchedRoute;
483
+ getCorsHeaders?: (req: Request) => HeadersInit;
484
+ }
485
+
486
+ export type RouteDispatcher<TContext> = (
487
+ input: RouteDispatcherContext<TContext>,
488
+ ) => Promise<Response>;
489
+
490
+ export interface FetchHandlerOptions<TContext> {
491
+ getContext(
492
+ req: Request,
493
+ ): TContext | Response | Promise<TContext | Response>;
494
+ getCorsHeaders?: (req: Request) => HeadersInit;
495
+ onError?: (error: unknown, req: Request) => Response | Promise<Response>;
496
+ afterResponse?: (
497
+ response: Response,
498
+ req: Request,
499
+ ) => Response | Promise<Response>;
500
+ }
501
+
502
+ export interface StandaloneServe {
503
+ (handler: (req: Request) => Response | Promise<Response>): void;
504
+ }
505
+
506
+ export function getStandaloneServe(): StandaloneServe | null {
507
+ const runtime = globalThis as { Deno?: { serve?: StandaloneServe } };
508
+ const standaloneServe = runtime.Deno?.serve;
509
+ return typeof standaloneServe === "function"
510
+ ? standaloneServe.bind(runtime.Deno)
511
+ : null;
512
+ }
513
+
514
+ export function normalizeRoutePath(path: string): string {
515
+ const trimmed = path.trim();
516
+ if (!trimmed || trimmed === "/") return "/";
517
+ return `/${trimmed.replace(/^\/+|\/+$/g, "")}`;
518
+ }
519
+
520
+ export function splitPath(path: string): string[] {
521
+ return path.split("/").filter(Boolean);
522
+ }
523
+
524
+ export function getRelativePath(req: Request, functionName: string): string {
525
+ const parts = splitPath(new URL(req.url).pathname);
526
+ return getRelativePathFromParts(parts, functionName);
527
+ }
528
+
529
+ export function getRelativePathFromParts(
530
+ pathnameParts: string[],
531
+ functionName: string,
532
+ pathPrefix?: string,
533
+ ): string {
534
+ const prefixParts = pathPrefix ? splitPath(pathPrefix) : ["functions", "v1"];
535
+ const hasPrefix = prefixParts.length > 0 &&
536
+ prefixParts.every((part, index) => pathnameParts[index] === part);
537
+ const parts = hasPrefix
538
+ ? pathnameParts.slice(prefixParts.length)
539
+ : pathnameParts;
540
+
541
+ if (parts[0] === functionName) {
542
+ return normalizeRoutePath(parts.slice(1).join("/"));
543
+ }
544
+
545
+ if (
546
+ pathnameParts[0] === "functions" && pathnameParts[1] === "v1" &&
547
+ pathnameParts[2] === functionName
548
+ ) {
549
+ return normalizeRoutePath(pathnameParts.slice(3).join("/"));
550
+ }
551
+
552
+ return normalizeRoutePath(parts.join("/"));
553
+ }
554
+
555
+ export function matchRoutePath(
556
+ routePath: string,
557
+ requestPath: string,
558
+ ): Record<string, string> | null {
559
+ const routeParts = splitPath(routePath);
560
+ const requestParts = splitPath(requestPath);
561
+ if (routeParts.length !== requestParts.length) return null;
562
+
563
+ const params: Record<string, string> = {};
564
+ for (let i = 0; i < routeParts.length; i++) {
565
+ const expected = routeParts[i];
566
+ const actual = requestParts[i];
567
+ if (expected.startsWith(":")) {
568
+ params[expected.slice(1)] = decodeURIComponent(actual);
569
+ continue;
570
+ }
571
+ if (expected !== actual) return null;
572
+ }
573
+ return params;
574
+ }
575
+
576
+ export function findRoute<TContext>(
577
+ config: ServeConfig<TContext>,
578
+ req: Request,
579
+ ): MatchedRoute | Response {
580
+ const requestPath = getRelativePathFromParts(
581
+ splitPath(new URL(req.url).pathname),
582
+ config.name,
583
+ config.pathPrefix,
584
+ );
585
+ const pathMatches = config.routes
586
+ .map((routeDef) => ({
587
+ route: routeDef,
588
+ params: matchRoutePath(routeDef.path, requestPath),
589
+ }))
590
+ .filter((match): match is MatchedRoute => match.params !== null);
591
+
592
+ if (pathMatches.length === 0) {
593
+ return notFound();
594
+ }
595
+
596
+ const allowedMethods = [
597
+ ...new Set(pathMatches.map((match) => match.route.method)),
598
+ ].sort();
599
+ if (req.method === "OPTIONS") {
600
+ return new Response(null, {
601
+ status: HttpStatus.NO_CONTENT,
602
+ headers: { Allow: allowedMethods.join(", ") },
603
+ });
604
+ }
605
+
606
+ const method = req.method.toUpperCase();
607
+ const match = pathMatches.find((candidate) =>
608
+ candidate.route.method === method
609
+ );
610
+ if (!match) {
611
+ return methodNotAllowed(allowedMethods);
612
+ }
613
+
614
+ return match;
615
+ }
616
+
617
+ export function queryObject(req: Request): Record<string, string> {
618
+ return Object.fromEntries(new URL(req.url).searchParams.entries());
619
+ }
620
+
621
+ export async function parseJsonBody(req: Request): Promise<unknown> {
622
+ try {
623
+ return await req.json();
624
+ } catch {
625
+ throw new Error("Invalid JSON body");
626
+ }
627
+ }
628
+
629
+ export function flattenSchemaError(error: unknown): unknown {
630
+ if (
631
+ error &&
632
+ typeof error === "object" &&
633
+ "flatten" in error &&
634
+ typeof (error as { flatten?: unknown }).flatten === "function"
635
+ ) {
636
+ return (error as { flatten: () => unknown }).flatten();
637
+ }
638
+ return error;
639
+ }
640
+
641
+ export function validateSchema<TSchema extends SchemaLike | undefined>(
642
+ schema: TSchema,
643
+ value: unknown,
644
+ label: "params" | "query" | "body",
645
+ ): SchemaOutput<TSchema> | Response {
646
+ if (!schema) return value as SchemaOutput<TSchema>;
647
+ const parsed = schema.safeParse(value);
648
+ if (!parsed.success) {
649
+ return jsonError(
650
+ HttpStatus.BAD_REQUEST,
651
+ `Invalid ${label}`,
652
+ flattenSchemaError(parsed.error),
653
+ );
654
+ }
655
+ return parsed.data as SchemaOutput<TSchema>;
656
+ }
657
+
658
+ export function withCors(
659
+ req: Request,
660
+ response: Response,
661
+ getCorsHeaders?: (req: Request) => HeadersInit,
662
+ ): Response {
663
+ if (!getCorsHeaders) return response;
664
+ const headers = new Headers(response.headers);
665
+ for (const [key, value] of new Headers(getCorsHeaders(req)).entries()) {
666
+ headers.set(key, value);
667
+ }
668
+ return new Response(response.body, {
669
+ status: response.status,
670
+ statusText: response.statusText,
671
+ headers,
672
+ });
673
+ }
674
+
675
+ export async function dispatchRoute<TContext>(
676
+ input: RouteDispatcherContext<TContext>,
677
+ ): Promise<Response> {
678
+ const { req, ctx, matched } = input;
679
+ const { route: routeDef, params: rawParams } = matched;
680
+
681
+ const params = validateSchema(routeDef.params, rawParams, "params");
682
+ if (params instanceof Response) {
683
+ return withCors(req, params, input.getCorsHeaders);
684
+ }
685
+
686
+ const query = validateSchema(routeDef.query, queryObject(req), "query");
687
+ if (query instanceof Response) {
688
+ return withCors(req, query, input.getCorsHeaders);
689
+ }
690
+
691
+ const rawBody = routeDef.body ? await parseJsonBody(req) : undefined;
692
+ const body = validateSchema(routeDef.body, rawBody, "body");
693
+ if (body instanceof Response) {
694
+ return withCors(req, body, input.getCorsHeaders);
695
+ }
696
+
697
+ return withCors(
698
+ req,
699
+ await routeDef.handler({ req, ctx, params, query, body }),
700
+ input.getCorsHeaders,
701
+ );
702
+ }
703
+
704
+ async function defaultOnError(error: unknown): Promise<Response> {
705
+ const message =
706
+ error instanceof Error && error.message === "Invalid JSON body"
707
+ ? "Invalid JSON body"
708
+ : "Internal server error";
709
+ return message === "Invalid JSON body"
710
+ ? badRequest(message)
711
+ : internalServerError(message);
712
+ }
713
+
714
+ export function createFetchHandler<TContext>(
715
+ config: ServeConfig<TContext>,
716
+ options: FetchHandlerOptions<TContext>,
717
+ ): (req: Request) => Promise<Response> {
718
+ return async (req) => {
719
+ try {
720
+ const matched = findRoute(config, req);
721
+ if (matched instanceof Response) {
722
+ const response = withCors(req, matched, options.getCorsHeaders);
723
+ return options.afterResponse
724
+ ? await options.afterResponse(response, req)
725
+ : response;
726
+ }
727
+
728
+ const context = await options.getContext(req);
729
+ if (context instanceof Response) {
730
+ const response = withCors(req, context, options.getCorsHeaders);
731
+ return options.afterResponse
732
+ ? await options.afterResponse(response, req)
733
+ : response;
734
+ }
735
+
736
+ const response = await dispatchRoute({
737
+ req,
738
+ ctx: context,
739
+ matched,
740
+ getCorsHeaders: options.getCorsHeaders,
741
+ });
742
+ return options.afterResponse
743
+ ? await options.afterResponse(response, req)
744
+ : response;
745
+ } catch (error) {
746
+ const response = withCors(
747
+ req,
748
+ options.onError
749
+ ? await options.onError(error, req)
750
+ : await defaultOnError(error),
751
+ options.getCorsHeaders,
752
+ );
753
+ return options.afterResponse
754
+ ? await options.afterResponse(response, req)
755
+ : response;
756
+ }
757
+ };
758
+ }
759
+
760
+ export interface OpenApiDocumentOptions {
761
+ title: string;
762
+ version: string;
763
+ servers?: { url: string; description?: string }[];
764
+ schemaAdapter?: JsonSchemaAdapter;
765
+ }
766
+
767
+ function schemaToOpenApi(
768
+ schema: SchemaLike | undefined,
769
+ adapter: JsonSchemaAdapter | undefined,
770
+ ): unknown {
771
+ if (!schema || !adapter) return undefined;
772
+ return adapter.toJsonSchema(schema);
773
+ }
774
+
775
+ function pathToOpenApiPath(path: string): string {
776
+ return normalizeRoutePath(path).replace(/:([^/]+)/g, "{$1}");
777
+ }
778
+
779
+ function pathParameterNames(path: string): string[] {
780
+ return splitPath(path)
781
+ .filter((part) => part.startsWith(":"))
782
+ .map((part) => part.slice(1));
783
+ }
784
+
785
+ function objectSchemaProperties(schema: unknown): Record<string, unknown> {
786
+ if (
787
+ schema &&
788
+ typeof schema === "object" &&
789
+ "properties" in schema &&
790
+ typeof (schema as { properties?: unknown }).properties === "object" &&
791
+ (schema as { properties?: unknown }).properties !== null
792
+ ) {
793
+ return (schema as { properties: Record<string, unknown> }).properties;
794
+ }
795
+ return {};
796
+ }
797
+
798
+ function objectSchemaRequired(schema: unknown): Set<string> {
799
+ if (
800
+ schema &&
801
+ typeof schema === "object" &&
802
+ "required" in schema &&
803
+ Array.isArray((schema as { required?: unknown }).required)
804
+ ) {
805
+ return new Set((schema as { required: string[] }).required);
806
+ }
807
+ return new Set();
808
+ }
809
+
810
+ function responseObject(
811
+ status: string,
812
+ schema: unknown,
813
+ ): Record<string, unknown> {
814
+ if (status === String(HttpStatus.NO_CONTENT) || !schema) {
815
+ return { description: `${status} response` };
816
+ }
817
+ return {
818
+ description: `${status} response`,
819
+ content: {
820
+ "application/json": { schema },
821
+ },
822
+ };
823
+ }
824
+
825
+ export function createOpenApiDocument(
826
+ config: ServeConfig<any>,
827
+ options: OpenApiDocumentOptions,
828
+ ): Record<string, unknown> {
829
+ const paths: Record<string, Record<string, unknown>> = {};
830
+
831
+ for (const routeDef of config.routes) {
832
+ const path = pathToOpenApiPath(routeDef.path);
833
+ const pathItem = paths[path] ?? {};
834
+ const paramsSchema = schemaToOpenApi(
835
+ routeDef.params,
836
+ options.schemaAdapter,
837
+ );
838
+ const paramsProperties = objectSchemaProperties(paramsSchema);
839
+ const parameters: Record<string, unknown>[] = pathParameterNames(
840
+ routeDef.path,
841
+ ).map((name) => ({
842
+ name,
843
+ in: "path",
844
+ required: true,
845
+ schema: paramsProperties[name] ?? { type: "string" },
846
+ }));
847
+
848
+ const querySchema = schemaToOpenApi(routeDef.query, options.schemaAdapter);
849
+ if (querySchema) {
850
+ const queryProperties = objectSchemaProperties(querySchema);
851
+ const queryRequired = objectSchemaRequired(querySchema);
852
+ for (const [name, schema] of Object.entries(queryProperties)) {
853
+ parameters.push({
854
+ name,
855
+ in: "query",
856
+ required: queryRequired.has(name),
857
+ schema,
858
+ });
859
+ }
860
+ if (Object.keys(queryProperties).length === 0) {
861
+ parameters.push({
862
+ name: "query",
863
+ in: "query",
864
+ required: false,
865
+ schema: querySchema,
866
+ });
867
+ }
868
+ }
869
+
870
+ pathItem[routeDef.method.toLowerCase()] = {
871
+ operationId: routeDef.operationId,
872
+ ...(routeDef.summary ? { summary: routeDef.summary } : {}),
873
+ ...(routeDef.description ? { description: routeDef.description } : {}),
874
+ ...(parameters.length > 0 ? { parameters } : {}),
875
+ ...(routeDef.body
876
+ ? {
877
+ requestBody: {
878
+ required: true,
879
+ content: {
880
+ "application/json": {
881
+ schema: schemaToOpenApi(routeDef.body, options.schemaAdapter) ??
882
+ {},
883
+ },
884
+ },
885
+ },
886
+ }
887
+ : {}),
888
+ responses: Object.fromEntries(
889
+ Object.entries(routeDef.responses).map(([status, schema]) => [
890
+ status,
891
+ responseObject(
892
+ status,
893
+ schemaToOpenApi(schema, options.schemaAdapter),
894
+ ),
895
+ ]),
896
+ ),
897
+ };
898
+
899
+ paths[path] = pathItem;
900
+ }
901
+
902
+ return {
903
+ openapi: "3.1.0",
904
+ info: {
905
+ title: options.title,
906
+ version: options.version,
907
+ },
908
+ ...(options.servers ? { servers: options.servers } : {}),
909
+ paths,
910
+ };
911
+ }
@@ -50,7 +50,7 @@ export function watchdogBody(
50
50
  }
51
51
 
52
52
  const reader = source.getReader();
53
- let timer: number | undefined;
53
+ let timer: ReturnType<typeof setTimeout> | undefined;
54
54
  let didStall = false;
55
55
  /** Set by the timer callback so the in-flight pull() can surface it as an
56
56
  * error to the consumer instead of treating the cancel-induced EOF as a
@@ -86,7 +86,10 @@ export function watchdogBody(
86
86
  clear();
87
87
  timer = setTimeout(() => {
88
88
  didStall = true;
89
- const reason = new DOMException(`Body read stalled (${idleMs}ms)`, "AbortError");
89
+ const reason = new DOMException(
90
+ `Body read stalled (${idleMs}ms)`,
91
+ "AbortError",
92
+ );
90
93
  stallReason = reason;
91
94
  onStall?.(idleMs);
92
95
  // Abort the request signal so the handler's `req.json()` / consumers
package/src/log-buffer.ts CHANGED
@@ -31,7 +31,7 @@ export interface LogBufferConfig {
31
31
 
32
32
  class StreamBuffer {
33
33
  private buf: string[] = [];
34
- private timer: number | undefined;
34
+ private timer: ReturnType<typeof setTimeout> | undefined;
35
35
  private bytesPending = 0;
36
36
 
37
37
  constructor(
@@ -150,6 +150,9 @@ export function _resetLogBufferForTests(): void {
150
150
  stderrBuf = new StreamBuffer(Deno.stderr, cfg);
151
151
  }
152
152
 
153
- export function _logBufferStatsForTests(): { stdoutPending: number; stderrPending: number } {
153
+ export function _logBufferStatsForTests(): {
154
+ stdoutPending: number;
155
+ stderrPending: number;
156
+ } {
154
157
  return { stdoutPending: stdoutBuf.pending, stderrPending: stderrBuf.pending };
155
158
  }