1tube 0.1.45 → 0.1.47

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/README.md CHANGED
@@ -62,25 +62,57 @@ npx 1tube package --functions supabase/functions --in dist --out fw.1tube --sign
62
62
  This replaces local-source invocations such as
63
63
  `deno run -A ../1tube/src/cli.ts ...` in downstream CI pipelines.
64
64
 
65
- ### Passing deno flags through the launcher
65
+ ### One launcher, one path
66
66
 
67
- Arguments before a standalone `--` are 1tube CLI args; everything **after** `--`
68
- is forwarded verbatim to `deno run` (inserted before the entrypoint). This lets
69
- the launcher fully replace a project's bespoke `deno run … src/server.ts`
70
- command — including project-level deno flags like `--config`, `--env-file`,
71
- `--node-modules-dir` and `--no-lock`:
67
+ There is a single launcher (`src/launch.ts`): the npm bin, the deno tasks, and
68
+ host projects all funnel through it, and `serve` **always** spawns the gateway
69
+ as a child `deno run`. That child process is what lets the launcher reconcile
70
+ the lockfile with your installed dependencies _before_ Deno resolves any npm
71
+ specifier — and fully reload it when a dependency changes (see below).
72
+
73
+ The launcher recognizes its own flags and forwards everything else to the
74
+ gateway, so it fully replaces a bespoke `deno run … src/server.ts` line — no
75
+ `--` split needed:
72
76
 
73
77
  ```bash
74
78
  npx 1tube serve --functions ./supabase/functions --dev --hmr \
75
- -- --no-lock --env-file=.env --config ./supabase/functions/deno.json --node-modules-dir=false
79
+ --config ./supabase/functions/deno.json --env-file=.env --node-modules-dir=false
76
80
  ```
77
81
 
78
- - If your passthrough includes its own `--config`, the launcher does **not** add
79
- 1tube's bundled `deno.json` — your import map wins (the gateway's own sources
80
- use fully-qualified `npm:`/`jsr:` specifiers, so they resolve regardless).
82
+ Launcher flags: `--lock <path>` (managed lockfile, default
83
+ `<cwd>/.1tube/deno.lock`), `--no-lock`, `--refresh-lock`, `--config <path>`,
84
+ `--env-file[=<path>]` (repeatable), `--minimum-dependency-age <n>`,
85
+ `--node-modules-dir <v>`, `--no-pin`, `--no-dep-watch`. Anything after a `--`
86
+ is forwarded verbatim to the gateway.
87
+
88
+ - If you don't pass `--config`, the launcher falls back to 1tube's bundled
89
+ `deno.json`; the gateway's own sources use fully-qualified `npm:`/`jsr:`
90
+ specifiers, so they resolve either way.
81
91
  - Prefer keeping the command line clean? Set the same flags in
82
- `1TUBE_DENO_ARGS` (space-separated) instead of after `--`. CLI passthrough
83
- takes precedence on any duplicate flag.
92
+ `1TUBE_DENO_ARGS` (space-separated); they're appended to the launcher args.
93
+
94
+ ### Dependency pinning & live reload
95
+
96
+ The edge runtime runs with `--node-modules-dir=false`, so Deno resolves npm
97
+ specifiers from its own cache + lockfile, not from `node_modules`. An import map
98
+ that maps a dependency with an **unconstrained** specifier (`npm:1tube/edge`,
99
+ i.e. `@*`) is satisfied by _any_ locked version — so bumping that dependency in
100
+ `package.json` would silently never reach the edge runtime, and the gateway
101
+ could end up a different version than the edge surface its functions import.
102
+
103
+ The launcher closes this gap: before spawning the gateway it rewrites every
104
+ unconstrained `npm:` specifier in your `--config` import map to the **exact
105
+ version installed in `node_modules`** (the source of truth your package manager
106
+ controls), writing the result to `<.1tube>/deno.gen.json`. Now the specifier's
107
+ version tracks the installed dependency, so Deno's native lockfile auto-update
108
+ recomputes just that entry — every already-pinned dependency keeps its locked
109
+ version (and the warm-boot speed a lockfile buys).
110
+
111
+ While serving, the launcher watches your deno config and each pinned
112
+ dependency's `package.json`. When an install or a config edit changes the
113
+ effective version set, it prints `dependency change detected — reloading
114
+ gateway` and fully restarts the gateway so Deno re-resolves. Disable with
115
+ `--no-pin` / `--no-dep-watch`.
84
116
 
85
117
  ### Minimum dependency age
86
118
 
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.45",
3
+ "version": "0.1.47",
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",
@@ -55,7 +55,7 @@
55
55
  "serve": "deno task serve",
56
56
  "build:edge": "deno run -A npm:typescript@latest/tsc --target ES2022 --module ESNext --moduleResolution Bundler --declaration --outDir dist src/edge.ts src/edge-caller.ts src/warmup-client.ts",
57
57
  "prepack": "bun run build:edge",
58
- "typecheck": "deno check src/server.ts src/cli.ts src/edge.ts src/edge-caller.ts src/warmup-client.ts eslint-plugin.js",
58
+ "typecheck": "deno check src/server.ts src/cli.ts src/launch.ts src/edge.ts src/edge-caller.ts src/warmup-client.ts eslint-plugin.js",
59
59
  "check": "deno cache --allow-scripts=npm:protobufjs src/server.ts",
60
60
  "smoke:bin": "node ./scripts/npm-cli.js --version",
61
61
  "prepublishOnly": "bun run build:edge && bun run typecheck && bun run check && bun run smoke:bin"
@@ -1,113 +1,49 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ /**
4
+ * `1tube` npm bin — a tiny Node shim that locates Deno and runs the single
5
+ * launcher (`src/launch.ts`). All argument handling, the managed lockfile,
6
+ * dependency pinning, minimum-dependency-age, and live reload live in the
7
+ * launcher itself, so this shim stays trivial: find deno, forward argv, wire
8
+ * up signals + exit code.
9
+ *
10
+ * Machines using this need Deno on PATH (or set `DENO_BIN`). Arguments are
11
+ * forwarded verbatim — the launcher recognizes its own flags (`--lock`,
12
+ * `--config`, `--env-file`, `--node-modules-dir`, `--minimum-dependency-age`,
13
+ * `--no-lock`, `--no-pin`, `--no-dep-watch`, …) and forwards everything else
14
+ * to the gateway. `1TUBE_DENO_ARGS` (space-separated) is appended for projects
15
+ * that prefer to keep flags out of the command line.
16
+ */
17
+
3
18
  import { spawn } from "node:child_process";
4
- import { existsSync, mkdirSync, rmSync } from "node:fs";
19
+ import { existsSync } from "node:fs";
5
20
  import { delimiter, dirname, isAbsolute, join, resolve } from "node:path";
6
21
  import process from "node:process";
7
22
  import { fileURLToPath } from "node:url";
8
23
 
9
24
  const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
10
- const denoConfig = resolve(packageRoot, "deno.json");
11
- const cliEntrypoint = resolve(packageRoot, "src", "cli.ts");
25
+ const launcherEntrypoint = resolve(packageRoot, "src", "launch.ts");
12
26
  const denoBin = process.env.DENO_BIN || "deno";
13
27
 
14
- // Arg model
15
- // ---------
16
- // Everything BEFORE a standalone `--` is a 1tube CLI argument (forwarded
17
- // after the entrypoint). Everything AFTER `--` is a raw `deno run` flag,
18
- // inserted BEFORE the entrypoint — so this launcher can fully replace a
19
- // hand-rolled `deno run … src/server.ts` line:
20
- //
21
- // 1tube serve --functions ./supabase/functions --dev --hmr \
22
- // -- --no-lock --env-file=.env --config ./supabase/functions/deno.json --node-modules-dir=false
23
- //
24
- // Projects that would rather not thread flags through the command line
25
- // can set `1TUBE_DENO_ARGS` instead (same flags, space-separated).
26
- const rawArgs = process.argv.slice(2);
27
- const sepIndex = rawArgs.indexOf("--");
28
- const tubeArgs = sepIndex === -1 ? rawArgs : rawArgs.slice(0, sepIndex);
29
- const cliDenoArgs = sepIndex === -1 ? [] : rawArgs.slice(sepIndex + 1);
30
-
31
- // CLI passthrough is appended last so it wins on any duplicate flag
32
- // (Deno applies last-one-wins for repeated flags).
33
- const denoPassthrough = [
28
+ // The launcher needs no import map or lockfile of its own (it imports only
29
+ // node: builtins + relative source), and it owns the gateway child's lock,
30
+ // config and npm-config — so the outer `deno run` stays minimal and fast.
31
+ const denoArgs = [
32
+ "run",
33
+ "--quiet",
34
+ "-A",
35
+ "--node-modules-dir=false",
36
+ launcherEntrypoint,
34
37
  ...tokenizeArgs(
35
38
  process.env.ONETUBE_DENO_ARGS ?? process.env["1TUBE_DENO_ARGS"] ?? "",
36
39
  ),
37
- ...cliDenoArgs,
40
+ ...process.argv.slice(2),
38
41
  ];
39
42
 
40
- // Default to --help only when the user gave us nothing at all; if they
41
- // supplied deno flags but no 1tube subcommand, let cli.ts fall through to
42
- // its default `serve` path.
43
- const forwardedTubeArgs = tubeArgs.length === 0 && denoPassthrough.length === 0
44
- ? ["--help"]
45
- : tubeArgs;
46
-
47
- // 1tube ships its own deno.json (import map for the gateway's jsr:/npm:
48
- // dependencies). Only inject it when the project didn't supply its own
49
- // --config — a Supabase project points --config at
50
- // supabase/functions/deno.json so its function workers resolve the
51
- // project's bare specifiers. The gateway's own sources use fully
52
- // qualified npm:/jsr: specifiers, so they resolve without our config.
53
- const userSuppliedConfig = denoPassthrough.some((a) =>
54
- a === "--config" || a === "-c" ||
55
- a.startsWith("--config=") || a.startsWith("-c=")
56
- );
57
-
58
- // Managed lockfile
59
- // ----------------
60
- // A lockfile is what makes a big functions project boot fast: without one
61
- // (`--no-lock`) Deno re-solves the entire npm/jsr version graph from
62
- // scratch on every start, and a 100-function project pays that as a
63
- // multi-second floor every boot. People reach for `--no-lock` to dodge
64
- // "the lock went stale when I bumped a dep" pain — but in dev Deno's
65
- // lockfile is ADDITIVE and non-frozen: a changed dependency is appended
66
- // without error. So the right default is a lockfile we own, not none.
67
- //
68
- // We point `--lock` at `.1tube/deno.lock` — a gateway-owned scratch path
69
- // (already gitignored alongside `.1tube/logs.db`) so it never adds git
70
- // churn and never collides with a project's own committed `deno.lock`.
71
- // We never pass `--frozen`, so dep changes self-heal. `1TUBE_REFRESH_LOCK=1`
72
- // drops the managed lock for a clean rebuild. Any explicit `--lock`,
73
- // `--no-lock`, or `--frozen` the user passes wins — we don't second-guess.
74
- const managedLock = resolveManagedLock(denoPassthrough);
75
-
76
- const denoArgs = ["run", "--quiet", "-A"];
77
- if (!userSuppliedConfig) denoArgs.push("--config", denoConfig);
78
- if (managedLock) denoArgs.push("--lock", managedLock);
79
- denoArgs.push(...denoPassthrough, cliEntrypoint, ...forwardedTubeArgs);
80
-
81
- // Deno 2.9 enables a 24h "minimum dependency age" by default, which makes
82
- // a function that pins a freshly published npm version fail to resolve in
83
- // local dev ("…newer than the specified minimum dependency date"). This
84
- // setting cannot be changed from inside the process (Deno snapshots npm
85
- // config at startup), so the launcher is the place to neutralize it.
86
- //
87
- // NPM_CONFIG_MIN_RELEASE_AGE is the LOWEST explicit tier in Deno's
88
- // precedence chain (CLI flag > deno.json > .npmrc > this env var > the
89
- // 24h built-in default). Setting it to 0 cancels ONLY the built-in
90
- // default — any .npmrc `min-release-age`, deno.json `minimumDependencyAge`,
91
- // or `--minimum-dependency-age` the project sets still wins. We never
92
- // override a value the user already exported.
93
- const childEnv = { ...process.env };
94
- if (
95
- childEnv.NPM_CONFIG_MIN_RELEASE_AGE === undefined &&
96
- childEnv.npm_config_min_release_age === undefined
97
- ) {
98
- childEnv.NPM_CONFIG_MIN_RELEASE_AGE = "0";
99
- }
100
- // Let the gateway print which lockfile is in effect (it can't read its own
101
- // process's `--lock` flag back out of Deno).
102
- if (managedLock) childEnv.ONETUBE_LOCK = managedLock;
103
-
104
43
  // Resolve the real deno binary up front. Node ≥18.20 refuses to spawn a
105
- // `.cmd`/`.bat` shim without a shell, but spawning a real `.exe` (or a
106
- // bare binary on POSIX) with `shell:false` lets Node escape arguments
107
- // properly — important now that we forward arbitrary deno flags, some of
108
- // which carry paths with spaces (`--config "C:\My Project\deno.json"`).
109
- // We only fall back to a shell when we must (a `.cmd`/`.bat` shim, or we
110
- // couldn't locate the binary on a Windows PATH at all).
44
+ // `.cmd`/`.bat` shim without a shell, but spawning a real `.exe` (or a bare
45
+ // binary on POSIX) with `shell:false` lets Node escape arguments properly —
46
+ // important since we forward arbitrary flags, some carrying paths with spaces.
111
47
  const resolvedDeno = resolveExecutable(denoBin);
112
48
  const needsShell = resolvedDeno
113
49
  ? /\.(cmd|bat)$/i.test(resolvedDeno)
@@ -116,26 +52,17 @@ const spawnBin = resolvedDeno ?? denoBin;
116
52
 
117
53
  let child;
118
54
  if (needsShell) {
119
- // A `.cmd`/`.bat` shim (the npm-installed deno) — or an unresolved name
120
- // on Windows — must go through a shell. We pass a single, manually
121
- // quoted command string instead of an args array so that (a) paths
122
- // with spaces survive and (b) we sidestep Node's DEP0190 "args are not
123
- // escaped" deprecation, which fires only for the shell-plus-array form.
124
55
  const command = [spawnBin, ...denoArgs].map(quoteForCmd).join(" ");
125
56
  child = spawn(command, {
126
57
  stdio: "inherit",
127
58
  shell: true,
128
59
  windowsHide: false,
129
- env: childEnv,
130
60
  });
131
61
  } else {
132
- // Real executable (deno.exe / a POSIX binary): no shell, so Node
133
- // escapes each argument for us.
134
62
  child = spawn(spawnBin, denoArgs, {
135
63
  stdio: "inherit",
136
64
  shell: false,
137
65
  windowsHide: false,
138
- env: childEnv,
139
66
  });
140
67
  }
141
68
 
@@ -164,64 +91,10 @@ child.on("exit", (code, signal) => {
164
91
  process.exit(code ?? 1);
165
92
  });
166
93
 
167
- /**
168
- * Decide whether to inject a 1tube-managed lockfile, and where.
169
- *
170
- * Returns the absolute lock path to pass to `--lock`, or null when the
171
- * caller already made an explicit locking choice (`--lock`, `--no-lock`,
172
- * or `--frozen`) — in which case we stay out of the way entirely.
173
- *
174
- * The managed lock lives at `<cwd>/.1tube/deno.lock`: a scratch path that
175
- * 1tube already owns and gitignores, so enabling it adds no git churn and
176
- * never clashes with a project's own committed `deno.lock`.
177
- *
178
- * @param {string[]} passthrough Raw deno flags being forwarded to `deno run`.
179
- * @returns {string | null}
180
- */
181
- function resolveManagedLock(passthrough) {
182
- const explicit = passthrough.some((a) =>
183
- a === "--lock" || a.startsWith("--lock=") ||
184
- a === "--no-lock" || a.startsWith("--no-lock=") ||
185
- a === "--frozen" || a.startsWith("--frozen=")
186
- );
187
- if (explicit) return null;
188
-
189
- const dir = join(process.cwd(), ".1tube");
190
- const lockPath = join(dir, "deno.lock");
191
- try {
192
- mkdirSync(dir, { recursive: true });
193
- } catch {
194
- // If we can't create the scratch dir, fall back to no managed lock
195
- // rather than crash the launcher.
196
- return null;
197
- }
198
-
199
- // `1TUBE_REFRESH_LOCK=1` forces a clean rebuild by dropping the managed
200
- // lock; Deno then re-derives it from the current deps on this boot.
201
- if (truthy(process.env.ONETUBE_REFRESH_LOCK ?? process.env["1TUBE_REFRESH_LOCK"])) {
202
- try {
203
- rmSync(lockPath, { force: true });
204
- } catch {
205
- // Best-effort.
206
- }
207
- }
208
- return lockPath;
209
- }
210
-
211
- /**
212
- * Loose truthiness for env-var flags: "1", "true", "yes", "on" (any case).
213
- *
214
- * @param {string | undefined} v
215
- * @returns {boolean}
216
- */
217
- function truthy(v) {
218
- return v !== undefined && /^(1|true|yes|on)$/i.test(v.trim());
219
- }
220
-
221
94
  /**
222
95
  * Split a flag string into argv tokens, honoring single/double quotes so
223
- * values with spaces (`--env-file="my env.env"`) survive. Good enough for
224
- * a dev-time convenience var — not a full shell parser.
96
+ * values with spaces (`--env-file="my env.env"`) survive. Good enough for a
97
+ * dev-time convenience var — not a full shell parser.
225
98
  *
226
99
  * @param {string} s
227
100
  * @returns {string[]}
@@ -238,14 +111,13 @@ function tokenizeArgs(s) {
238
111
 
239
112
  /**
240
113
  * Locate an executable on PATH (honoring PATHEXT on Windows). Returns an
241
- * absolute path, or null when the name can't be resolved — in which case
242
- * the caller hands the bare name to spawn and lets the OS/shell try.
114
+ * absolute path, or null when the name can't be resolved — in which case the
115
+ * caller hands the bare name to spawn and lets the OS/shell try.
243
116
  *
244
117
  * @param {string} bin
245
118
  * @returns {string | null}
246
119
  */
247
120
  function resolveExecutable(bin) {
248
- // An explicit path (absolute or containing a separator) is used as-is.
249
121
  if (isAbsolute(bin) || bin.includes("/") || bin.includes("\\")) {
250
122
  return existsSync(bin) ? bin : null;
251
123
  }
@@ -264,9 +136,9 @@ function resolveExecutable(bin) {
264
136
  }
265
137
 
266
138
  /**
267
- * Quote a single argument for cmd.exe. Leaves simple tokens untouched to
268
- * avoid over-quoting; wraps anything with whitespace or shell
269
- * metacharacters in double quotes, doubling any embedded quotes.
139
+ * Quote a single argument for cmd.exe. Leaves simple tokens untouched to avoid
140
+ * over-quoting; wraps anything with whitespace or shell metacharacters in
141
+ * double quotes, doubling any embedded quotes.
270
142
  *
271
143
  * @param {string} arg
272
144
  * @returns {string}
@@ -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;