1tube 0.1.44 → 0.1.46

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/dist/edge.d.ts CHANGED
@@ -117,6 +117,52 @@ export declare function internalServerError(message?: string, details?: unknown)
117
117
  export declare function badGateway(message?: string, details?: unknown): Response;
118
118
  export declare function serviceUnavailable(message?: string, details?: unknown): Response;
119
119
  export declare function gatewayTimeout(message?: string, details?: unknown): Response;
120
+ /**
121
+ * How the rate limiter buckets requests for a function:
122
+ * - "identity" (default): one bucket per authenticated user, falling back to
123
+ * the client IP for unauthenticated/public functions.
124
+ * - "ip": always bucket by client IP, even when a user is authenticated.
125
+ * - "global": a single shared bucket for the whole function.
126
+ *
127
+ * Defined here (the self-contained `edge` surface) rather than in `manifest.ts`
128
+ * so the published `dist/edge.d.ts` has no dangling cross-module reference;
129
+ * `manifest.ts` re-exports it for the gateway side.
130
+ */
131
+ export type RateLimitBy = "identity" | "ip" | "global";
132
+ /** Full-control form of `serve({ rateLimit })`. */
133
+ export interface RateLimitConfig {
134
+ /**
135
+ * Requests per minute. `0` = unlimited (the function is exempt). Omit to use
136
+ * the gateway default / any `1tube.json` value.
137
+ */
138
+ rpm?: number;
139
+ /**
140
+ * Bucket key strategy. Defaults to "identity" (per authenticated user,
141
+ * falling back to client IP). See {@link RateLimitBy}.
142
+ */
143
+ by?: RateLimitBy;
144
+ }
145
+ /**
146
+ * Per-function rate-limit declaration for `serve({ rateLimit })`:
147
+ * - `false` → exempt from rate limiting entirely (signed webhooks, trusted
148
+ * server-to-server callers).
149
+ * - `true` → gateway default limit, keyed by identity (the implicit default
150
+ * when `rateLimit` is omitted).
151
+ * - `number` → requests-per-minute, keyed by identity.
152
+ * - object → full control over rpm + key strategy.
153
+ */
154
+ export type RateLimitOption = boolean | number | RateLimitConfig;
155
+ /** Normalized shape consumed by the registry / Worker `register()` contract. */
156
+ export interface NormalizedRateLimit {
157
+ rpm?: number;
158
+ rateLimitBy?: RateLimitBy;
159
+ }
160
+ /**
161
+ * Collapse the friendly {@link RateLimitOption} into `{ rpm?, rateLimitBy? }`.
162
+ * `undefined`/`true` leave both unset (gateway default); `false` maps to the
163
+ * `rpm: 0` "unlimited" sentinel the gateway limiter understands.
164
+ */
165
+ export declare function normalizeRateLimit(rl: RateLimitOption | undefined): NormalizedRateLimit;
120
166
  export interface ServeConfig<TContext = unknown> {
121
167
  name: string;
122
168
  require: unknown;
@@ -128,6 +174,13 @@ export interface ServeConfig<TContext = unknown> {
128
174
  * different prefix while keeping the same function modules.
129
175
  */
130
176
  pathPrefix?: string;
177
+ /**
178
+ * Per-function rate-limit declaration. Overrides any `1tube.json` `rpm`.
179
+ * Use `false` to exempt the function from rate limiting (e.g. a signed
180
+ * server-to-server webhook). Honoured by the 1tube gateway; standalone
181
+ * runtimes that front their own limiter ignore it.
182
+ */
183
+ rateLimit?: RateLimitOption;
131
184
  }
132
185
  export interface MatchedRoute {
133
186
  route: RouteDefinition<SchemaLike | undefined, SchemaLike | undefined, SchemaLike | undefined, RouteResponses, any>;
package/dist/edge.js CHANGED
@@ -135,6 +135,20 @@ export function serviceUnavailable(message = "Service unavailable", details) {
135
135
  export function gatewayTimeout(message = "Gateway timeout", details) {
136
136
  return jsonError(HttpStatus.GATEWAY_TIMEOUT, message, details);
137
137
  }
138
+ /**
139
+ * Collapse the friendly {@link RateLimitOption} into `{ rpm?, rateLimitBy? }`.
140
+ * `undefined`/`true` leave both unset (gateway default); `false` maps to the
141
+ * `rpm: 0` "unlimited" sentinel the gateway limiter understands.
142
+ */
143
+ export function normalizeRateLimit(rl) {
144
+ if (rl === undefined || rl === true)
145
+ return {};
146
+ if (rl === false)
147
+ return { rpm: 0 };
148
+ if (typeof rl === "number")
149
+ return { rpm: rl };
150
+ return { rpm: rl.rpm, rateLimitBy: rl.by };
151
+ }
138
152
  export function getStandaloneServe() {
139
153
  const runtime = globalThis;
140
154
  const standaloneServe = runtime.Deno?.serve;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "1tube",
3
- "version": "0.1.44",
3
+ "version": "0.1.46",
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",
@@ -95,7 +95,10 @@ export interface DenoReloadDebouncerOptions {
95
95
  * `debounceMs`. Unset = classic trailing-only behavior.
96
96
  */
97
97
  leadingMs?: number;
98
- flushFn: (paths: readonly string[]) => Promise<void>;
98
+ // Awaited at the call site, so a synchronous callback is fine too (a test
99
+ // that just records the paths needn't be `async`, which would trip
100
+ // require-await). The real reloader flush is async.
101
+ flushFn: (paths: readonly string[]) => void | Promise<void>;
99
102
  setTimer?: (cb: () => void, ms: number) => number;
100
103
  clearTimer?: (id: number) => void;
101
104
  }
@@ -5,18 +5,18 @@
5
5
  * bootstrap hosts EVERY function in a single isolate. The point is
6
6
  * dependency sharing: a 100-function project where most functions import
7
7
  * the same heavy npm graph (`ai`, `@supabase/supabase-js`, `@google/genai`,
8
- * `zod`, �) pays to compile + evaluate that graph exactly once here,
8
+ * `zod`, ?) pays to compile + evaluate that graph exactly once here,
9
9
  * instead of once per function across 100 isolates. On a real project that
10
10
  * collapses warm-boot from ~per-function-sum/cores down to roughly the
11
11
  * one-time cost of the union graph (measured ~6s vs ~18s).
12
12
  *
13
13
  * Trade-off: functions share globals and a crash takes the whole isolate
14
- * down. That's why this mode is dev-only � production keeps isolate-per-
14
+ * down. That's why this mode is dev-only ? production keeps isolate-per-
15
15
  * function (or workerd) for real isolation.
16
16
  *
17
17
  * Lifecycle:
18
18
  *
19
- * host ? init : { functions: [{name, entryUrl, manifest}], � }
19
+ * host ? init : { functions: [{name, entryUrl, manifest}], ? }
20
20
  * Imported SERIALLY so each function's top-level
21
21
  * `serve()` registration can be attributed to the
22
22
  * right name (parallel imports would interleave the
@@ -26,7 +26,7 @@
26
26
  * Streams `fn_ready` / `fn_error` per function, then
27
27
  * `all_done`.
28
28
  *
29
- * host ? dispatch : { id, name, � } ? look up the captured handler by
29
+ * host ? dispatch : { id, name, ? } ? look up the captured handler by
30
30
  * name and run it (per-request AsyncLocalStorage so
31
31
  * concurrent invocations attribute console output).
32
32
  *
@@ -42,7 +42,11 @@
42
42
  /// <reference lib="deno.worker" />
43
43
 
44
44
  import { AsyncLocalStorage } from "node:async_hooks";
45
- import type { FunctionManifest } from "../../manifest.ts";
45
+ import {
46
+ type FunctionManifest,
47
+ mergeServeRateLimit,
48
+ type RateLimitBy,
49
+ } from "../../manifest.ts";
46
50
  import type { AuthContext } from "../../registry.ts";
47
51
 
48
52
  interface FnSpec {
@@ -96,7 +100,12 @@ type Handler = (
96
100
  interface RegistryStub {
97
101
  register(
98
102
  handler: Handler,
99
- opts: { public: boolean; timeoutMs?: number },
103
+ opts: {
104
+ public: boolean;
105
+ timeoutMs?: number;
106
+ rpm?: number;
107
+ rateLimitBy?: RateLimitBy;
108
+ },
100
109
  ): void;
101
110
  }
102
111
 
@@ -104,6 +113,8 @@ interface CapturedHandler {
104
113
  handler: Handler;
105
114
  isPublic: boolean;
106
115
  timeoutMs?: number;
116
+ rpm?: number;
117
+ rateLimitBy?: RateLimitBy;
107
118
  manifest: FunctionManifest;
108
119
  }
109
120
 
@@ -159,7 +170,7 @@ function installConsoleCapture(): void {
159
170
  try {
160
171
  let message = args.map(formatConsoleArg).join(" ");
161
172
  if (message.length > MAX_CONSOLE_MESSAGE) {
162
- message = message.slice(0, MAX_CONSOLE_MESSAGE) + "�";
173
+ message = message.slice(0, MAX_CONSOLE_MESSAGE) + "?";
163
174
  }
164
175
  postMessage({
165
176
  type: "console",
@@ -184,7 +195,7 @@ function installConsoleCapture(): void {
184
195
 
185
196
  const stub: RegistryStub = {
186
197
  register(handler, opts) {
187
- if (!importing) return; // serve() called outside an import window � ignore
198
+ if (!importing) return; // serve() called outside an import window ? ignore
188
199
  if (importing.captured) {
189
200
  console.warn(
190
201
  `[1tube] "${importing.name}" called serve() twice; using the last handler`,
@@ -194,6 +205,8 @@ const stub: RegistryStub = {
194
205
  handler,
195
206
  isPublic: opts.public,
196
207
  timeoutMs: opts.timeoutMs,
208
+ rpm: opts.rpm,
209
+ rateLimitBy: opts.rateLimitBy,
197
210
  manifest: { runtime: "deno" } as unknown as FunctionManifest,
198
211
  };
199
212
  },
@@ -282,7 +295,14 @@ async function loadFunction(
282
295
  });
283
296
  return;
284
297
  }
285
- captured.manifest = fn.manifest;
298
+ // Fold any `serve({ rateLimit })` override into the manifest reported to the
299
+ // gateway so its rate-limiter (which reads `manifestFor()`) honours
300
+ // code-declared limits ? this isolate, not the gateway, ran the module.
301
+ const reportedManifest = mergeServeRateLimit(fn.manifest, {
302
+ rpm: captured.rpm,
303
+ rateLimitBy: captured.rateLimitBy,
304
+ });
305
+ captured.manifest = reportedManifest;
286
306
  handlers.set(fn.name, captured);
287
307
  postMessage({
288
308
  type: "fn_ready",
@@ -290,7 +310,7 @@ async function loadFunction(
290
310
  ms: performance.now() - t0,
291
311
  isPublic: captured.isPublic,
292
312
  timeoutMs: captured.timeoutMs,
293
- manifest: fn.manifest,
313
+ manifest: reportedManifest,
294
314
  });
295
315
  }
296
316
 
@@ -27,7 +27,11 @@
27
27
  /// <reference lib="deno.worker" />
28
28
 
29
29
  import { AsyncLocalStorage } from "node:async_hooks";
30
- import type { FunctionManifest } from "../../manifest.ts";
30
+ import {
31
+ type FunctionManifest,
32
+ mergeServeRateLimit,
33
+ type RateLimitBy,
34
+ } from "../../manifest.ts";
31
35
  import type { AuthContext } from "../../registry.ts";
32
36
 
33
37
  interface InitMessage {
@@ -70,7 +74,12 @@ type Handler = (
70
74
  interface RegistryStub {
71
75
  register(
72
76
  handler: Handler,
73
- opts: { public: boolean; timeoutMs?: number },
77
+ opts: {
78
+ public: boolean;
79
+ timeoutMs?: number;
80
+ rpm?: number;
81
+ rateLimitBy?: RateLimitBy;
82
+ },
74
83
  ): void;
75
84
  }
76
85
 
@@ -78,6 +87,8 @@ interface CapturedHandler {
78
87
  handler: Handler;
79
88
  isPublic: boolean;
80
89
  timeoutMs?: number;
90
+ rpm?: number;
91
+ rateLimitBy?: RateLimitBy;
81
92
  }
82
93
 
83
94
  let captured: CapturedHandler | null = null;
@@ -202,6 +213,8 @@ const stub: RegistryStub = {
202
213
  handler,
203
214
  isPublic: opts.public,
204
215
  timeoutMs: opts.timeoutMs,
216
+ rpm: opts.rpm,
217
+ rateLimitBy: opts.rateLimitBy,
205
218
  };
206
219
  },
207
220
  };
@@ -322,12 +335,22 @@ self.onmessage = (ev: MessageEvent<HostMessage>) => {
322
335
  });
323
336
  return;
324
337
  }
338
+ // Fold any `serve({ rateLimit })` override into the manifest we report
339
+ // so the gateway's rate-limiter (which reads `manifestFor()`) honours
340
+ // code-declared limits — the function module runs in this Worker, not in
341
+ // the gateway process, so this `ready` handshake is the only channel.
342
+ const reportedManifest = manifest
343
+ ? mergeServeRateLimit(manifest, {
344
+ rpm: captured.rpm,
345
+ rateLimitBy: captured.rateLimitBy,
346
+ })
347
+ : manifest;
325
348
  postMessage({
326
349
  type: "ready",
327
350
  name: functionName,
328
351
  isPublic: captured.isPublic,
329
352
  timeoutMs: captured.timeoutMs,
330
- manifest,
353
+ manifest: reportedManifest,
331
354
  });
332
355
  })();
333
356
  return;
@@ -1188,6 +1188,13 @@ export function createDenoWorkerHost(
1188
1188
  */
1189
1189
  let coloReloadChain: Promise<unknown> = Promise.resolve();
1190
1190
 
1191
+ // In-flight worker-retirement drains. A retiring worker leaves `coloWorkers`
1192
+ // immediately but lingers (its `kill` armed on a timer) so in-flight
1193
+ // requests can finish. Tracked so `stop()` can both cancel the timer AND
1194
+ // run the kill now — otherwise the timer (and the Worker) outlive the host
1195
+ // and leak, which Deno's test sanitizer flags.
1196
+ const coloDrains = new Set<{ timer: number; kill: () => void }>();
1197
+
1191
1198
  interface ColoFnResult {
1192
1199
  name: string;
1193
1200
  ok: boolean;
@@ -1379,7 +1386,12 @@ export function createDenoWorkerHost(
1379
1386
  */
1380
1387
  function terminateColoWorker(w: Worker, drainMs: number): void {
1381
1388
  coloWorkers.delete(w);
1389
+ let entry: { timer: number; kill: () => void } | null = null;
1382
1390
  const kill = () => {
1391
+ if (entry) {
1392
+ clearTimeout(entry.timer);
1393
+ coloDrains.delete(entry);
1394
+ }
1383
1395
  try {
1384
1396
  w.terminate();
1385
1397
  } catch { /* already gone */ }
@@ -1390,8 +1402,13 @@ export function createDenoWorkerHost(
1390
1402
  }
1391
1403
  }
1392
1404
  };
1393
- if (drainMs > 0) setTimeout(kill, drainMs);
1394
- else kill();
1405
+ if (drainMs > 0) {
1406
+ const timer = setTimeout(kill, drainMs) as unknown as number;
1407
+ entry = { timer, kill };
1408
+ coloDrains.add(entry);
1409
+ } else {
1410
+ kill();
1411
+ }
1395
1412
  }
1396
1413
 
1397
1414
  /**
@@ -1768,6 +1785,11 @@ export function createDenoWorkerHost(
1768
1785
  }
1769
1786
 
1770
1787
  function stopColocated(): void {
1788
+ // Cancel + finish any in-flight retirement drains first: this clears their
1789
+ // timers and terminates the lingering workers (which already left
1790
+ // `coloWorkers`), so nothing outlives the host.
1791
+ for (const d of [...coloDrains]) d.kill();
1792
+ coloDrains.clear();
1771
1793
  for (const p of coloPending.values()) {
1772
1794
  p.reject(new Error("gateway shutting down"));
1773
1795
  }
package/src/edge.ts CHANGED
@@ -446,6 +446,64 @@ export function gatewayTimeout(
446
446
  return jsonError(HttpStatus.GATEWAY_TIMEOUT, message, details);
447
447
  }
448
448
 
449
+ /**
450
+ * How the rate limiter buckets requests for a function:
451
+ * - "identity" (default): one bucket per authenticated user, falling back to
452
+ * the client IP for unauthenticated/public functions.
453
+ * - "ip": always bucket by client IP, even when a user is authenticated.
454
+ * - "global": a single shared bucket for the whole function.
455
+ *
456
+ * Defined here (the self-contained `edge` surface) rather than in `manifest.ts`
457
+ * so the published `dist/edge.d.ts` has no dangling cross-module reference;
458
+ * `manifest.ts` re-exports it for the gateway side.
459
+ */
460
+ export type RateLimitBy = "identity" | "ip" | "global";
461
+
462
+ /** Full-control form of `serve({ rateLimit })`. */
463
+ export interface RateLimitConfig {
464
+ /**
465
+ * Requests per minute. `0` = unlimited (the function is exempt). Omit to use
466
+ * the gateway default / any `1tube.json` value.
467
+ */
468
+ rpm?: number;
469
+ /**
470
+ * Bucket key strategy. Defaults to "identity" (per authenticated user,
471
+ * falling back to client IP). See {@link RateLimitBy}.
472
+ */
473
+ by?: RateLimitBy;
474
+ }
475
+
476
+ /**
477
+ * Per-function rate-limit declaration for `serve({ rateLimit })`:
478
+ * - `false` → exempt from rate limiting entirely (signed webhooks, trusted
479
+ * server-to-server callers).
480
+ * - `true` → gateway default limit, keyed by identity (the implicit default
481
+ * when `rateLimit` is omitted).
482
+ * - `number` → requests-per-minute, keyed by identity.
483
+ * - object → full control over rpm + key strategy.
484
+ */
485
+ export type RateLimitOption = boolean | number | RateLimitConfig;
486
+
487
+ /** Normalized shape consumed by the registry / Worker `register()` contract. */
488
+ export interface NormalizedRateLimit {
489
+ rpm?: number;
490
+ rateLimitBy?: RateLimitBy;
491
+ }
492
+
493
+ /**
494
+ * Collapse the friendly {@link RateLimitOption} into `{ rpm?, rateLimitBy? }`.
495
+ * `undefined`/`true` leave both unset (gateway default); `false` maps to the
496
+ * `rpm: 0` "unlimited" sentinel the gateway limiter understands.
497
+ */
498
+ export function normalizeRateLimit(
499
+ rl: RateLimitOption | undefined,
500
+ ): NormalizedRateLimit {
501
+ if (rl === undefined || rl === true) return {};
502
+ if (rl === false) return { rpm: 0 };
503
+ if (typeof rl === "number") return { rpm: rl };
504
+ return { rpm: rl.rpm, rateLimitBy: rl.by };
505
+ }
506
+
449
507
  export interface ServeConfig<TContext = unknown> {
450
508
  name: string;
451
509
  require: unknown;
@@ -463,6 +521,13 @@ export interface ServeConfig<TContext = unknown> {
463
521
  * different prefix while keeping the same function modules.
464
522
  */
465
523
  pathPrefix?: string;
524
+ /**
525
+ * Per-function rate-limit declaration. Overrides any `1tube.json` `rpm`.
526
+ * Use `false` to exempt the function from rate limiting (e.g. a signed
527
+ * server-to-server webhook). Honoured by the 1tube gateway; standalone
528
+ * runtimes that front their own limiter ignore it.
529
+ */
530
+ rateLimit?: RateLimitOption;
466
531
  }
467
532
 
468
533
  export interface MatchedRoute {
@@ -1,6 +1,13 @@
1
1
  /**
2
2
  * In-memory token bucket rate limiter.
3
- * Keyed by user ID (set by the auth middleware) or remote IP.
3
+ *
4
+ * Buckets are keyed by the per-function `rateLimitBy` strategy (manifest /
5
+ * `serve({ rateLimit })`), defaulting to "identity": user ID when the auth
6
+ * middleware set one, otherwise remote IP. `"ip"` forces IP keying even for
7
+ * authenticated callers; `"global"` shares one bucket across all callers.
8
+ *
9
+ * A resolved rpm of `0` (or less) exempts the function entirely — used for
10
+ * trusted, already-authenticated traffic like signed server-to-server webhooks.
4
11
  *
5
12
  * X-Forwarded-For is only honored when the immediate connection comes from a
6
13
  * trusted proxy (env `1TUBE_TRUSTED_PROXIES`, comma-separated CIDRs/IPs).
@@ -11,6 +18,7 @@
11
18
  import type { Context, Next } from "npm:hono@4";
12
19
  import { getConnInfo } from "npm:hono@4/deno";
13
20
  import type { FunctionRegistry } from "../registry.ts";
21
+ import type { RateLimitBy } from "../manifest.ts";
14
22
  import { routeRemainder } from "./route-prefix.ts";
15
23
 
16
24
  interface Bucket {
@@ -81,9 +89,19 @@ function getOrCreateBucket(key: string, rpm: number): Bucket {
81
89
  return bucket;
82
90
  }
83
91
 
84
- function resolveKey(c: Context): string {
85
- const userId = c.get("userId") as string | undefined;
86
- if (userId) return `user:${userId}`;
92
+ function resolveKey(c: Context, by: RateLimitBy): string {
93
+ // A single shared bucket for the whole function — every caller draws from
94
+ // the same tokens regardless of identity or IP.
95
+ if (by === "global") return "global";
96
+
97
+ // Identity-first (the default): one bucket per authenticated user so a
98
+ // noisy IP can't starve everyone behind a shared NAT, and a single user
99
+ // can't mint fresh buckets by hopping IPs. `by: "ip"` opts out and always
100
+ // keys by IP even for authenticated callers.
101
+ if (by !== "ip") {
102
+ const userId = c.get("userId") as string | undefined;
103
+ if (userId) return `user:${userId}`;
104
+ }
87
105
 
88
106
  const trusted = loadTrustedProxies();
89
107
  let socketAddr = "unknown";
@@ -138,12 +156,21 @@ export function createRateLimiter(config: Partial<RateLimitConfig> = {}) {
138
156
  // (configurable) prefix, before any nested route.
139
157
  const fnName = routeRemainder(c.req.path).split("/", 1)[0] || "";
140
158
 
141
- const manifestRpm = fnName
142
- ? cfg.registry?.manifestFor(fnName)?.rpm
143
- : undefined;
144
- const rpm = manifestRpm ??
159
+ const manifest = fnName ? cfg.registry?.manifestFor(fnName) : undefined;
160
+ // `??` (not `||`) so a manifest `rpm: 0` survives — it's the "unlimited"
161
+ // sentinel, distinct from "unset" (undefined → fall back to overrides).
162
+ const rpm = manifest?.rpm ??
145
163
  ((fnName && cfg.overrides[fnName]) || cfg.defaultRpm);
146
- const key = `${resolveKey(c)}:${fnName || "global"}`;
164
+
165
+ // rpm <= 0 means the function is explicitly exempt (e.g. a signed
166
+ // server-to-server webhook). Skip the bucket entirely.
167
+ if (rpm <= 0) {
168
+ await next();
169
+ return;
170
+ }
171
+
172
+ const by: RateLimitBy = manifest?.rateLimitBy ?? "identity";
173
+ const key = `${resolveKey(c, by)}:${fnName || "global"}`;
147
174
  const bucket = getOrCreateBucket(key, rpm);
148
175
 
149
176
  if (bucket.tokens <= 0) {
package/src/manifest.ts CHANGED
@@ -8,7 +8,8 @@
8
8
  * metadata — the in-process TS gateway has no way to enforce them inside
9
9
  * a single V8 isolate; treat them as documentation for now.
10
10
  * - `timeoutMs` — wall-clock dispatch timeout (real today).
11
- * - `rpm` — per-function rate-limit override (real today).
11
+ * - `rpm` — per-function rate-limit override (real today). `0` = unlimited.
12
+ * - `rateLimitBy` — rate-limit bucket key strategy (identity/ip/global).
12
13
  * - `memoryMB` — recorded only; cannot be enforced from inside V8. Use the
13
14
  * host process's cgroup / job-object cap instead (visible on /health as
14
15
  * `memory.limit_mb`).
@@ -26,6 +27,13 @@
26
27
  // without depending on a shared import-map entry.
27
28
  import { join } from "node:path";
28
29
 
30
+ // `RateLimitBy` is defined on the self-contained `edge` surface so the
31
+ // published `dist/edge.d.ts` stays free of cross-module references. The gateway
32
+ // side imports it from here; this re-export is type-only and erased at runtime,
33
+ // so manifest.ts gains no runtime dependency on edge.ts.
34
+ import type { RateLimitBy } from "./edge.ts";
35
+ export type { RateLimitBy };
36
+
29
37
  export interface ManifestPermissions {
30
38
  /** Hostnames the function may reach (e.g. "api.example.com"). [] = none. */
31
39
  net: string[];
@@ -52,8 +60,17 @@ export interface FunctionManifest {
52
60
  permissions: ManifestPermissions;
53
61
  /** Per-function timeout in ms. Undefined = use gateway default. */
54
62
  timeoutMs?: number;
55
- /** Per-function rate limit (requests per minute). Undefined = use gateway default. */
63
+ /**
64
+ * Per-function rate limit (requests per minute). Undefined = use gateway
65
+ * default. `0` = unlimited — the function is exempt from rate limiting (use
66
+ * for signed server-to-server webhooks and other trusted callers).
67
+ */
56
68
  rpm?: number;
69
+ /**
70
+ * Bucket key strategy for this function's rate limit. Undefined = "identity".
71
+ * See {@link RateLimitBy}.
72
+ */
73
+ rateLimitBy?: RateLimitBy;
57
74
  /** Memory cap (MB). Recorded only; not enforced by the TS runtime. */
58
75
  memoryMB?: number;
59
76
  /** Eagerly load + keep alive. Recorded only by the TS runtime. */
@@ -97,6 +114,21 @@ function asPositiveInt(v: unknown): number | undefined {
97
114
  return n > 0 ? n : undefined;
98
115
  }
99
116
 
117
+ /**
118
+ * Like {@link asPositiveInt} but preserves `0`. Used for `rpm`, where `0` is a
119
+ * meaningful value ("unlimited") distinct from "unset". Negatives/NaN drop to
120
+ * undefined so a typo can't accidentally exempt a function.
121
+ */
122
+ function asNonNegativeInt(v: unknown): number | undefined {
123
+ if (typeof v !== "number" || !Number.isFinite(v)) return undefined;
124
+ const n = Math.floor(v);
125
+ return n >= 0 ? n : undefined;
126
+ }
127
+
128
+ function asRateLimitBy(v: unknown): RateLimitBy | undefined {
129
+ return v === "identity" || v === "ip" || v === "global" ? v : undefined;
130
+ }
131
+
100
132
  function asRatio(v: unknown, fallback: number): number {
101
133
  if (typeof v !== "number" || !Number.isFinite(v)) return fallback;
102
134
  if (v < 0) return 0;
@@ -123,7 +155,8 @@ export function parseManifest(raw: unknown, fromFile = true): FunctionManifest {
123
155
  };
124
156
 
125
157
  base.timeoutMs = asPositiveInt(obj.timeoutMs);
126
- base.rpm = asPositiveInt(obj.rpm);
158
+ base.rpm = asNonNegativeInt(obj.rpm);
159
+ base.rateLimitBy = asRateLimitBy(obj.rateLimitBy);
127
160
  base.memoryMB = asPositiveInt(obj.memoryMB);
128
161
 
129
162
  if (typeof obj.warm === "boolean") base.warm = obj.warm;
@@ -142,6 +175,29 @@ export function parseManifest(raw: unknown, fromFile = true): FunctionManifest {
142
175
  return base;
143
176
  }
144
177
 
178
+ /**
179
+ * Fold a code-declared `serve({ rateLimit })` override (already normalized to
180
+ * `{ rpm?, rateLimitBy? }`) into a manifest. Used by the in-process registry
181
+ * and the Worker entries so the gateway's rate-limiter — which only ever reads
182
+ * the manifest via `manifestFor()` — picks up values declared in the function
183
+ * module. Serve-config values win over `1tube.json`; absent ones are left as-is.
184
+ * Returns the original manifest unchanged when there is nothing to apply, so
185
+ * callers can keep object identity in the common case.
186
+ */
187
+ export function mergeServeRateLimit(
188
+ manifest: FunctionManifest,
189
+ override: { rpm?: number; rateLimitBy?: RateLimitBy },
190
+ ): FunctionManifest {
191
+ if (override.rpm === undefined && override.rateLimitBy === undefined) {
192
+ return manifest;
193
+ }
194
+ return {
195
+ ...manifest,
196
+ rpm: override.rpm ?? manifest.rpm,
197
+ rateLimitBy: override.rateLimitBy ?? manifest.rateLimitBy,
198
+ };
199
+ }
200
+
145
201
  /**
146
202
  * Read and parse `<functionsDir>/<name>/1tube.json`. Missing file =
147
203
  * defaultManifest(). Malformed JSON = defaultManifest() with a stderr warning.
package/src/registry.ts CHANGED
@@ -7,7 +7,12 @@
7
7
  */
8
8
 
9
9
  import { AsyncLocalStorage } from "node:async_hooks";
10
- import { defaultManifest, type FunctionManifest } from "./manifest.ts";
10
+ import {
11
+ defaultManifest,
12
+ type FunctionManifest,
13
+ mergeServeRateLimit,
14
+ type RateLimitBy,
15
+ } from "./manifest.ts";
11
16
 
12
17
  export interface AuthContext {
13
18
  userId: string;
@@ -169,7 +174,14 @@ export class FunctionRegistry {
169
174
  */
170
175
  register(
171
176
  handler: AuthenticatedHandler | PublicHandler,
172
- opts: { public: boolean; timeoutMs?: number },
177
+ opts: {
178
+ public: boolean;
179
+ timeoutMs?: number;
180
+ /** `serve({ rateLimit })` override. `0` = unlimited. Wins over `1tube.json`. */
181
+ rpm?: number;
182
+ /** `serve({ rateLimit })` bucket key strategy. Wins over `1tube.json`. */
183
+ rateLimitBy?: RateLimitBy;
184
+ },
173
185
  ): void {
174
186
  const name = currentNameStorage.getStore();
175
187
  if (!name) {
@@ -178,7 +190,11 @@ export class FunctionRegistry {
178
190
  "This is a bug in the function loader — must be wrapped in runWithCurrentFunction().",
179
191
  );
180
192
  }
181
- const manifest = this.pendingManifests.get(name) ?? defaultManifest();
193
+ const base = this.pendingManifests.get(name) ?? defaultManifest();
194
+ // Fold any `serve({ rateLimit })` override into the manifest so the
195
+ // gateway's rate-limiter (which only ever reads `manifestFor()`) sees the
196
+ // code-declared value without a separate lookup path.
197
+ const manifest = mergeServeRateLimit(base, opts);
182
198
  this.handlers.set(name, {
183
199
  handler,
184
200
  isPublic: opts.public,