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 +53 -0
- package/dist/edge.js +14 -0
- package/package.json +1 -1
- package/src/backends/deno/hot-reloader.ts +4 -1
- package/src/backends/deno/worker-entry-colocated.ts +30 -10
- package/src/backends/deno/worker-entry.ts +26 -3
- package/src/backends/deno/worker-host.ts +24 -2
- package/src/edge.ts +65 -0
- package/src/gateway/rate-limit.ts +36 -9
- package/src/manifest.ts +59 -3
- package/src/registry.ts +19 -3
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
|
@@ -95,7 +95,10 @@ export interface DenoReloadDebouncerOptions {
|
|
|
95
95
|
* `debounceMs`. Unset = classic trailing-only behavior.
|
|
96
96
|
*/
|
|
97
97
|
leadingMs?: number;
|
|
98
|
-
|
|
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`,
|
|
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
|
|
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,
|
|
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
|
|
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: {
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
|
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: {
|
|
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)
|
|
1394
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
86
|
-
|
|
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
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const rpm =
|
|
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
|
-
|
|
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
|
-
/**
|
|
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 =
|
|
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 {
|
|
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: {
|
|
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
|
|
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,
|