1tube 0.1.10 → 0.1.12

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
@@ -1,14 +1,23 @@
1
1
  ![NPM Version](https://img.shields.io/npm/v/1tube)
2
+ ![NuGet Version](https://img.shields.io/nuget/v/OneTube?v=2&icon=nuget)
2
3
 
3
4
  # 1tube
4
5
 
5
- Self-hosted Supabase Edge Functions gateway. Runs your Deno edge functions locally or behind a .NET host with zero-copy YARP proxying — no cold starts, no version lock, no Supabase compute dependency.
6
+ Self-hosted Supabase Edge Functions gateway. Runs your Deno edge functions
7
+ locally or behind a .NET host with zero-copy YARP proxying — no cold starts, no
8
+ version lock, no Supabase compute dependency.
6
9
 
7
10
  ## How it works
8
11
 
9
- 1tube discovers edge function modules from a `supabase/functions/` directory and hosts them in a single Deno HTTP server. Each function's `serve()` call registers a handler in a global registry instead of starting a separate `Deno.serve()`. The gateway then routes requests, handles JWT auth, CORS, rate limiting, and structured logging.
12
+ 1tube discovers edge function modules from a `supabase/functions/` directory and
13
+ hosts them in a single Deno HTTP server. Each function's `serve()` call
14
+ registers a handler in a global registry instead of starting a separate
15
+ `Deno.serve()`. The gateway then routes requests, handles JWT auth, CORS, rate
16
+ limiting, and structured logging.
10
17
 
11
- Individual edge functions require **zero changes** — only the shared `_shared/handler.ts` wrapper needs a small shim (4 lines) to detect the 1tube registry.
18
+ Individual edge functions require **zero changes** — only the shared
19
+ `_shared/handler.ts` wrapper needs a small shim (4 lines) to detect the 1tube
20
+ registry.
12
21
 
13
22
  ## Quick start (local dev)
14
23
 
@@ -23,43 +32,573 @@ cp .env.example .env
23
32
  bun run dev -- --functions ../sciobot-next/supabase/functions
24
33
  ```
25
34
 
26
- The gateway starts on `http://localhost:3100`. Functions are available at `http://localhost:3100/functions/v1/<name>`.
35
+ The gateway starts on `http://localhost:3100`. Functions are available at
36
+ `http://localhost:3100/functions/v1/<name>`.
27
37
 
28
- In dev mode, 1tube also watches the functions directory and reloads handlers in-process on filesystem changes (including newly created function folders).
38
+ In dev mode, 1tube also watches the functions directory and reloads handlers
39
+ in-process on filesystem changes (including newly created function folders).
40
+
41
+ ## npm CLI
42
+
43
+ The published npm package exposes a `1tube` binary. It is a tiny Node shim that
44
+ launches this repo's Deno CLI, so machines using it need Deno on `PATH`.
45
+
46
+ ```bash
47
+ # Build and sign a firmware payload in one command
48
+ npx 1tube package --functions supabase/functions --out fw.1tube --sign-key "$1TUBE_PACKAGE_SIGN_KEY"
49
+
50
+ # Keep/update the intermediate dist/ artifact as well
51
+ npx 1tube package --functions supabase/functions --in dist --out fw.1tube --sign-key "$1TUBE_PACKAGE_SIGN_KEY"
52
+ ```
53
+
54
+ This replaces local-source invocations such as
55
+ `deno run -A ../1tube/src/cli.ts ...` in downstream CI pipelines.
29
56
 
30
57
  ### Deno npm lifecycle scripts
31
58
 
32
- Deno does not run npm `postinstall` / build scripts unless you allow them. `deno.lock` may still list transitive packages such as `protobufjs`; the `check` script uses `--allow-scripts=npm:protobufjs` so `bun run check` / `npm publish` (via `prepublishOnly`) stays warning-free. That script is only needed for dependency install — **1tube’s runtime graph is just Hono + JSR std**.
59
+ Deno does not run npm `postinstall` / build scripts unless you allow them.
60
+ `deno.lock` may still list transitive packages such as `protobufjs`; the `check`
61
+ script uses `--allow-scripts=npm:protobufjs` so `bun run check` / `npm publish`
62
+ (via `prepublishOnly`) stays warning-free. That script is only needed for
63
+ dependency install — **1tube’s runtime graph is just Hono + JSR std**.
33
64
 
34
65
  ## Endpoints
35
66
 
36
- | Path | Description |
37
- |---|---|
38
- | `GET /` | Liveness probe (`{"status":"ok"}`) — intentionally minimal so unauthenticated callers don't see the function map. |
39
- | `POST /functions/v1/:name` | Invoke an edge function |
40
- | `GET /health` | Auth-gated health (`Authorization: Bearer $INTERNAL_KEY`); without auth returns the same minimal `{"status":"ok"}`. |
41
- | `GET /metrics` | Auth-gated Prometheus exposition (same scheme). |
67
+ | Path | Description |
68
+ | -------------------------- | ------------------------------------------------------------------------------------------------------------------- |
69
+ | `GET /` | Liveness probe (`{"status":"ok"}`) — intentionally minimal so unauthenticated callers don't see the function map. |
70
+ | `POST /functions/v1/:name` | Invoke an edge function |
71
+ | `GET /health` | Auth-gated health (`Authorization: Bearer $INTERNAL_KEY`); without auth returns the same minimal `{"status":"ok"}`. |
72
+ | `GET /metrics` | Auth-gated Prometheus exposition (same scheme). |
42
73
 
43
74
  ## Configuration
44
75
 
45
- All knobs default to safe-but-backwards-compatible values. The TS gateway (`src/server.ts`) reads:
76
+ All knobs default to safe-but-backwards-compatible values. The TS gateway
77
+ (`src/server.ts`) reads:
78
+
79
+ | Env / flag | Default | Notes |
80
+ | ----------------------------------------------------------- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
81
+ | `--port` / `PORT` | `3100` | Listen port |
82
+ | `--host` / `1TUBE_HOST` | `127.0.0.1` | Loopback by default — pass `--host 0.0.0.0` to expose. |
83
+ | `--functions` / `FUNCTIONS_PATH` | `./supabase/functions` | Functions root |
84
+ | `--timeout` / `FUNCTION_TIMEOUT_MS` | `150000` | Per-request wall-clock cap (also overridable per function) |
85
+ | `--dev` / `1TUBE_DEV` | off | Applies the well-known local Supabase JWT/secrets. **Refuses to start in prod when JWT_SECRET / SUPABASE_SERVICE_ROLE_KEY are missing or are the public dev defaults.** |
86
+ | `--hmr` / `1TUBE_HMR` | off | File-watch + per-function reload (dev only). |
87
+ | `1TUBE_BODY_LIMIT_MB` | `30` | Hono `bodyLimit`; matches Supabase. Returns 413 before the handler runs. |
88
+ | `1TUBE_BODY_READ_MS` | `30000` | Slow-loris guard. Max idle gap (ms) between body chunks before the request is aborted with **408**. NOT a total body-read deadline — large but fast uploads pass through. Set `0` to disable. |
89
+ | `1TUBE_CORS_ORIGIN` | `*` (dev only) | Comma-separated allowlist or `*`. In prod, leaving unset disables CORS. |
90
+ | `1TUBE_TRUSTED_PROXIES` | empty | Comma-separated list of remote IPs whose `X-Forwarded-For` is honored. Anything else uses the raw socket address — XFF spoofing no longer mints fresh rate-limit buckets. |
91
+ | `1TUBE_SHUTDOWN_GRACE_MS` | `10000` | SIGINT/SIGTERM drain budget. |
92
+ | `INTERNAL_KEY` | unset | Required to read detailed `/health` and `/metrics`. Header-only: `Authorization: Bearer $INTERNAL_KEY`. |
93
+ | `--backend` / `1TUBE_BACKEND` | `deno` | Function execution engine. `deno` (default) imports each function as a module in this process — same as 1tube has always done. `workerd` bundles each function and serves it from a Cloudflare-style workerd subprocess for hard isolation. See [Workerd backend](#workerd-backend) below. |
94
+ | `--workerd-bin` / `1TUBE_WORKERD_BIN` | `workerd` (PATH) | Path to the workerd binary. Only consulted with `--backend workerd`. |
95
+ | `--workerd-env` / `1TUBE_WORKERD_ENV` | unset (= forward all) | Comma-separated allowlist of env var names to forward to bundled functions under `--backend workerd`. Each name reaches the bundle via `Deno.env.get(name)` and the worker's `env` binding. Values stay in the gateway's process env — they are NEVER written into `config.capnp`. **When unset (or `*`), every env var the gateway can see is forwarded** — bundled functions inherit their parent's environment like a regular `deno run` child. Pass an explicit list to narrow the surface for shared/multi-tenant workerds. |
96
+ | `--kill-stale-workerd` / `1TUBE_KILL_STALE_WORKERD` | off | When the boot-time port preflight finds one of workerd's socket ports already busy, automatically run `taskkill /F /IM workerd.exe` (Windows) or `pkill -9 -x workerd` (Unix), wait briefly, and re-probe. Only ever targets processes named `workerd` — non-workerd processes holding the port still surface the normal preflight error. Off by default; recommended for dev where leftover workerds from prior runs are the common cause of flaky boots. |
97
+ | `--inspector` / `--inspector-addr=ADDR` / `1TUBE_INSPECTOR` | off | Launch workerd with the V8 inspector bound to `ADDR` (default `127.0.0.1:9229` when `--inspector` has no value). Lets Chrome DevTools / Node-style debuggers attach for breakpoints inside bundled functions. **Local dev only** — opens an unauthenticated debug port. See [V8 inspector / debugger access](#v8-inspector--debugger-access) below. |
98
+ | `1TUBE_SHUTDOWN_GRACE_MS` | `10000` | Total wall-clock budget (ms) for shutdown, split between (1) draining gateway in-flight requests and (2) tearing down the workerd subprocess. See [Graceful shutdown](#graceful-shutdown) below. |
99
+ | `1TUBE_DEFAULT_RPM` | `120` | Override the gateway-wide default rate limit (requests per minute, per IP, per function). Per-function `1tube.json#rpm` still wins. |
100
+ | `1TUBE_DISABLE_RATE_LIMIT` | unset | Set to `1` to bypass rate limiting entirely. **Load-test / dev only** — production deployments must keep the limiter on. The gateway prints a clear warning at boot when this is enabled. |
101
+
102
+ ````
103
+ ## Workerd backend
104
+
105
+ 1tube can optionally run each function inside a [workerd](https://github.com/cloudflare/workerd) subprocess instead of importing it directly into the gateway. This gives:
106
+
107
+ - **Hard isolation** between functions and the gateway — a runaway function cannot wedge the gateway, leak memory into peer functions, or read peer functions' globals.
108
+ - **Cloudflare-Workers-style runtime** — `nodejs_compat`, `process.env`, `node:fs`/`node:os`/`node:http`, `AsyncLocalStorage`, the modern `fetch`/`Streams`/`URL` spec fixes, etc.
109
+ - **Zero changes to function code** — the same `serve(handler, { public: ... })` shim works on both backends. `Deno.env.get(...)` still works (we shim it onto the `env` binding). The bundler resolves `npm:`, `jsr:`, `https:`, and `file:` specifiers via Deno's own loader.
110
+
111
+ Per-function manifests, the circuit breaker, and per-function timeouts apply on the workerd path too — see [Per-function manifests](#per-function-manifests-on-workerd) below. Live HMR (`--hmr`) also works: a save triggers a re-bundle of just the changed function, a *new* workerd is spawned on shifted ports, the gateway atomically swaps to it, and the old workerd is torn down — all without dropping in-flight requests. See [HMR on workerd](#hmr-on-workerd) below for details and limits.
112
+
113
+ ### Run it
114
+
115
+ ```bash
116
+ # Make sure workerd is on PATH, or set 1TUBE_WORKERD_BIN.
117
+ workerd --version
118
+
119
+ # Boot 1tube with the workerd backend pointed at the playground.
120
+ deno run --allow-all src/server.ts \
121
+ --backend workerd \
122
+ --functions ./playground \
123
+ --port 3100 \
124
+ --dev
125
+ ````
126
+
127
+ You should see something like:
128
+
129
+ ```
130
+ [1tube] mode=dev hmr=off lazy=off backend=workerd ...
131
+ [1tube] Bundling functions from: ./playground
132
+ [1tube] workerd backend ready (v1.20260415.0) · 3 function(s) in 740ms
133
+ [1tube] Functions: echo, hello, whoami
134
+ ```
135
+
136
+ Then verify end-to-end:
137
+
138
+ ```bash
139
+ # Public hello
140
+ curl http://127.0.0.1:3100/functions/v1/hello
141
+ # → {"message":"hello, hello",...}
142
+
143
+ # Public echo with body + query + custom header
144
+ curl -X POST 'http://127.0.0.1:3100/functions/v1/echo?x=1' \
145
+ -H 'content-type: application/json' \
146
+ -H 'x-test: abc' \
147
+ -d '{"hello":"world"}'
148
+
149
+ # Unknown function fast-fails before auth/rate-limit
150
+ curl -i http://127.0.0.1:3100/functions/v1/no-such-fn
151
+ # → HTTP/1.1 404 Not Found
152
+ ```
153
+
154
+ The full e2e test (`tests/workerd-e2e.test.ts`) automates exactly the same
155
+ checks against a real workerd subprocess; run
156
+ `deno test --allow-all tests/workerd-e2e.test.ts` for one-shot verification.
46
157
 
47
- | Env / flag | Default | Notes |
48
- |---|---|---|
49
- | `--port` / `PORT` | `3100` | Listen port |
50
- | `--host` / `1TUBE_HOST` | `127.0.0.1` | Loopback by default — pass `--host 0.0.0.0` to expose. |
51
- | `--functions` / `FUNCTIONS_PATH` | `./supabase/functions` | Functions root |
52
- | `--timeout` / `FUNCTION_TIMEOUT_MS` | `150000` | Per-request wall-clock cap (also overridable per function) |
53
- | `--dev` / `1TUBE_DEV` | off | Applies the well-known local Supabase JWT/secrets. **Refuses to start in prod when JWT_SECRET / SUPABASE_SERVICE_ROLE_KEY are missing or are the public dev defaults.** |
54
- | `--hmr` / `1TUBE_HMR` | off | File-watch + per-function reload (dev only). |
55
- | `1TUBE_BODY_LIMIT_MB` | `30` | Hono `bodyLimit`; matches Supabase. Returns 413 before the handler runs. |
56
- | `1TUBE_BODY_READ_MS` | `30000` | Slow-loris guard. Max idle gap (ms) between body chunks before the request is aborted with **408**. NOT a total body-read deadline — large but fast uploads pass through. Set `0` to disable. |
57
- | `1TUBE_CORS_ORIGIN` | `*` (dev only) | Comma-separated allowlist or `*`. In prod, leaving unset disables CORS. |
58
- | `1TUBE_TRUSTED_PROXIES` | empty | Comma-separated list of remote IPs whose `X-Forwarded-For` is honored. Anything else uses the raw socket address — XFF spoofing no longer mints fresh rate-limit buckets. |
59
- | `1TUBE_SHUTDOWN_GRACE_MS` | `10000` | SIGINT/SIGTERM drain budget. |
60
- | `INTERNAL_KEY` | unset | Required to read detailed `/health` and `/metrics`. Header-only: `Authorization: Bearer $INTERNAL_KEY`. |
158
+ ### Compatibility date
159
+
160
+ Workerd applies a `compatibilityDate` to enable behaviour-change opt-ins. 1tube
161
+ defaults to today's date, then **clamps it down** to whatever the installed
162
+ workerd binary actually accepts — derived from the binary's version string
163
+ (`1.YYYYMMDD.N`), since workerd refuses dates later than its build date. You
164
+ only see a clamp warning if you explicitly pinned a date the binary can't
165
+ honour.
166
+
167
+ ### Forwarding env vars (secrets, API keys)
168
+
169
+ **Default: every env var the gateway sees is forwarded.** Bundled functions
170
+ inherit their parent's environment the same way a
171
+ `deno run ./functions/foo/index.ts` child would — so
172
+ `Deno.env.get("OPENAI_API_KEY")` Just Works without any extra wiring. This is
173
+ the developer-friendly default; use it for solo projects and trusted code.
174
+
175
+ Pass an explicit allowlist when you care about isolation (shared multi-tenant
176
+ workerds, reproducible build artifacts, etc.):
177
+
178
+ ```bash
179
+ # CLI flag — narrows to exactly these names
180
+ deno run --allow-all src/server.ts \
181
+ --backend workerd --functions ./functions \
182
+ --workerd-env=OPENAI_API_KEY,POSTHOG_HOST,STRIPE_SECRET_KEY
183
+
184
+ # Env var (handy when 1tube is launched by a process manager)
185
+ 1TUBE_WORKERD_ENV=OPENAI_API_KEY,POSTHOG_HOST,STRIPE_SECRET_KEY \
186
+ deno run --allow-all src/server.ts --backend workerd --functions ./functions
187
+
188
+ # `*` is the explicit form of the default (forward everything)
189
+ --workerd-env=*
190
+ ```
191
+
192
+ Each listed name becomes a `(name = X, fromEnvironment = X)` binding in the
193
+ generated `config.capnp`. Workerd reads the **value** from its own process env
194
+ at boot; the on-disk capnp file contains nothing but the var names. This means:
195
+
196
+ - Operators can rotate secrets by restarting the gateway with new env values; no
197
+ cache rebuild needed.
198
+ - A leaked `config.capnp` cannot leak secrets — it never had them.
199
+ - Vars listed but absent from the gateway env get a single grouped warning at
200
+ boot, then are simply not exposed (workerd would otherwise refuse to start).
201
+
202
+ > **A note on the default.** Earlier versions of 1tube defaulted to _deny-all_
203
+ > and required explicit opt-in. That was friction for the 90%-case (single-app
204
+ > deploys where the gateway and the functions trust each other) and surprised
205
+ > people whose `.env` already worked everywhere else. The deny-all stance is
206
+ > still one keystroke away — `--workerd-env=A,B,C` — and the per-function
207
+ > `permissions.env` manifest knob still narrows further under
208
+ > `1TUBE_ENFORCE_MANIFEST=1`.
209
+
210
+ ### Bundle sizes at boot
211
+
212
+ Workerd boot prints a sorted table so unintended npm bloat is visible
213
+ immediately:
214
+
215
+ ```
216
+ [1tube] workerd bundle sizes (sorted, largest first):
217
+ zod-deps 443.4KB (612ms)
218
+ stream 12.0KB (89ms)
219
+ echo 9.9KB (74ms)
220
+ ...
221
+ total 492.6KB (1131ms across 7 fns)
222
+ ```
223
+
224
+ If a bundle is much bigger than expected (50MB+), look for an unintended deep
225
+ import — `nodejs_compat` shims most of `node:*` automatically and you rarely
226
+ need to import a "browser polyfill" version of an npm package.
227
+
228
+ ### Compatibility date
229
+
230
+ Workerd applies a `compatibilityDate` to enable behaviour-change opt-ins. 1tube
231
+ defaults to today's date, then **clamps it down** to whatever the installed
232
+ workerd binary actually accepts — derived from the binary's version string
233
+ (`1.YYYYMMDD.N`), since workerd refuses dates later than its build date. You
234
+ only see a clamp warning if you explicitly pinned a date the binary can't
235
+ honour.
236
+
237
+ ### Per-function manifests on workerd
238
+
239
+ Each `<function>/1tube.json` is loaded at workerd boot and applied the same way
240
+ the Deno backend uses it:
241
+
242
+ - **`timeoutMs`** — per-function dispatch timeout. The gateway aborts the
243
+ request and returns 504 if the bundled handler doesn't respond within this
244
+ window. Falls back to `--timeout` when unset.
245
+ - **`rpm`** — per-function rate limit. The gateway-wide rate limiter consults
246
+ the workerd manifest the same way it consults Deno-side ones; manifest values
247
+ override `1TUBE_DEFAULT_RPM`.
248
+ - **`recycle`** — circuit breaker. After `errorWindow` requests with at least
249
+ `errorRate` of them returning 5xx (or aborting), the breaker opens for
250
+ `cooldownMs`. While open, requests short-circuit with
251
+ `503 Retry-After: <seconds>` and a `{"reason": "circuit_breaker_open"}` body —
252
+ workerd is never touched. The breaker half-opens automatically once the
253
+ cool-down elapses.
254
+ - **`permissions.env`** — per-function env scoping. Default behaviour: every
255
+ function sees the full `--workerd-env` allowlist (manifest entries are
256
+ documentation only). Set `1TUBE_ENFORCE_MANIFEST=1` to switch to strict mode,
257
+ where each function only sees the _intersection_ of the gateway allowlist and
258
+ its declared `permissions.env`. Use `["*"]` to opt back into the full surface
259
+ under enforcement.
260
+
261
+ Functions without a `1tube.json` get the defaults from `defaultManifest()` —
262
+ wide-open env, gateway-default timeout/rpm, errorRate=0.5 over a 20-request
263
+ window with a 30s cool-down.
264
+
265
+ #### Example — tight breaker
266
+
267
+ ```json
268
+ // playground/boom/1tube.json
269
+ {
270
+ "permissions": { "net": [], "env": [], "read": [], "write": [] },
271
+ "timeoutMs": 5000,
272
+ "recycle": { "errorRate": 1.0, "errorWindow": 3, "cooldownMs": 5000 }
273
+ }
274
+ ```
275
+
276
+ Three consecutive 500s trip the breaker; the gateway logs:
277
+
278
+ ```
279
+ [1tube] Circuit breaker OPEN for "boom" (workerd) — refusing requests for 5000ms (errorRate >= 1).
280
+ ```
281
+
282
+ The 4th request returns 503 with `Retry-After: 5` without ever reaching the
283
+ workerd subprocess. Verified end-to-end in `tests/workerd-e2e.test.ts`.
284
+
285
+ ### HMR on workerd
286
+
287
+ `--hmr` (or `1TUBE_HMR=1`) enables live reload on the workerd backend. The
288
+ gateway watches the functions directory and on each save:
289
+
290
+ 1. Classifies the changed path. A change inside a function dir (e.g.
291
+ `playground/hello/index.ts` or `playground/hello/1tube.json`) only re-bundles
292
+ that one function; a change to anything starting with `_` (`_shared/`,
293
+ `_internal/`, etc.) triggers a full re-bundle since shared code may be
294
+ imported by any function.
295
+ 2. Coalesces a 200ms burst of save events (editor format-on-save typically
296
+ writes the file 2–3 times within ~50ms).
297
+ 3. Re-bundles the affected function(s) using the long-lived esbuild worker.
298
+ Bundles for unchanged functions stay on disk.
299
+ 4. Generates a fresh `config.gen-{0|1}.capnp` on shifted ports (alternating
300
+ between `basePort` and `basePort+500`) so the new workerd never collides with
301
+ the still-serving old one.
302
+ 5. Spawns the new workerd, waits for TCP readiness, then atomically swaps the
303
+ gateway's route table.
304
+ 6. Stops the old workerd. In-flight requests against the old process keep their
305
+ captured origin URL and complete naturally; only NEW requests go to the new
306
+ workerd. There is no request-drop window.
307
+
308
+ A typical incremental swap completes in **200–800ms** on a warm cache
309
+ (single-function rebundle + workerd cold start). Manifests (`1tube.json`) are
310
+ re-read on every reload and propagated into the registry / supervisor before the
311
+ new workerd starts serving, so an `rpm` or `timeoutMs` edit applies to the very
312
+ first post-reload request.
313
+
314
+ Failures are non-fatal: a syntax error in your source code aborts the swap, the
315
+ old workerd keeps serving, and the next save retries. The reloader logs:
316
+
317
+ ```
318
+ [1tube] HMR 1 function(s) changed: hello
319
+ [1tube] HMR reload ok in 412ms (gen=3; rebundled=hello)
320
+ ```
321
+
322
+ or, on failure:
323
+
324
+ ```
325
+ [1tube] HMR reload FAILED — keeping previous workerd alive. Error: …
61
326
  ```
62
327
 
328
+ End-to-end coverage in `tests/workerd-e2e.test.ts`
329
+ (`HMR rebundles + swaps without dropping requests`).
330
+
331
+ ### Memory enforcement & crash recovery
332
+
333
+ **The honest version up front**: workerd's open-source binary does **not**
334
+ support per-isolate memory limits in its capnp schema — that's a
335
+ Cloudflare-platform feature, not something the OSS runtime exposes. So 1tube
336
+ cannot kill an individual misbehaving function; it can only recycle the whole
337
+ workerd process. Two mechanisms keep things stable:
338
+
339
+ 1. **Memory watchdog** — periodically reads the workerd PID's resident set size
340
+ (RSS) and recycles the entire process when it stays over budget for `N`
341
+ consecutive samples.
342
+ 2. **Crash auto-recovery** — if workerd dies unexpectedly (OOM, SIGKILL,
343
+ segfault), the gateway re-bundles and respawns it automatically.
344
+
345
+ Both reuse the M4 dual-process swap, so recovery is non-disruptive: in-flight
346
+ requests against the old workerd complete on their existing socket, the gateway
347
+ atomically points new requests at the new process, and only then is the
348
+ dead/over-budget process torn down.
349
+
350
+ #### Watchdog
351
+
352
+ Opt-in via `1TUBE_WORKERD_MAX_RSS_MB` (an absolute cap in MB). When unset, the
353
+ gateway derives a recommended budget from `sum(manifest.memoryMB) × 1.5 + 64MB`
354
+ and uses that automatically. If no manifest declares `memoryMB` either, the
355
+ watchdog stays off — no surprises for existing deployments.
356
+
357
+ | Env var | Default | Notes |
358
+ | ------------------------------- | ------- | ------------------- |
359
+ | `1TUBE_WORKERD_MAX_RSS_MB` | unset | Hard RSS cap in MB. |
360
+ | `1TUBE_WORKERD_RSS_INTERVAL_MS` | `5000` | Poll interval. |
361
+
362
+ Hysteresis is fixed at **3 consecutive over-budget samples** before tripping (a
363
+ single GC pause spike won't recycle), and a **10-second cooldown** runs after
364
+ each recycle to let the new process settle. Watchdog stats (last RSS, configured
365
+ budget, total recycles, current generation) are surfaced on the authenticated
366
+ `/health` endpoint under a `workerd` block:
367
+
368
+ ```json
369
+ {
370
+ "status": "ok",
371
+ "workerd": {
372
+ "pid": 1234,
373
+ "generation": 2,
374
+ "recycles": 1,
375
+ "rss_bytes": 134217728,
376
+ "budget_bytes": 268435456
377
+ }
378
+ }
379
+ ```
380
+
381
+ RSS reading is cross-platform: Linux reads `/proc/{pid}/status`, macOS shells
382
+ out to `ps`, Windows to `tasklist`. The numbers approximate (working set vs
383
+ resident vs incl. shared pages differ by platform) — treat the budget as a rough
384
+ cap, not a precise quota.
385
+
386
+ #### Crash recovery
387
+
388
+ `WorkerdProcess.onExit` is wired to trigger an automatic recycle. A bounded
389
+ retry budget — **5 crashes in 30 seconds** — protects against hot-looping on a
390
+ fundamentally broken bundle:
391
+
392
+ ```
393
+ [1tube] workerd crashed (gen=2, code=137, crashes=1/5 in 30s) — auto-recycling...
394
+ [1tube] workerd crashed (gen=3, code=137, crashes=2/5 in 30s) — auto-recycling...
395
+ …
396
+ [1tube] workerd crashed (gen=6, code=137, crashes=6/5 in 30s) — GIVING UP, manual restart required.
397
+ ```
398
+
399
+ After the budget is exhausted the gateway returns `502 workerd backend error`
400
+ for every request until the operator restarts it (or, in dev, an HMR save which
401
+ clears the counter and resumes recovery). End-to-end coverage in
402
+ `tests/workerd-e2e.test.ts`
403
+ (`gateway auto-recovers when workerd is killed externally`).
404
+
405
+ ### Observability on workerd
406
+
407
+ Both `/health` (JSON) and `/metrics` (Prometheus exposition) report
408
+ workerd-specific state when the backend is active. Both endpoints require
409
+ `Authorization: Bearer $INTERNAL_KEY`.
410
+
411
+ `/health` adds a `workerd` block:
412
+
413
+ ```json
414
+ {
415
+ "workerd": {
416
+ "pid": 4242,
417
+ "generation": 3,
418
+ "recycles": 0,
419
+ "rss_bytes": 134217728,
420
+ "budget_bytes": 268435456,
421
+ "last_reload_duration_ms": 412.5,
422
+ "bundle_bytes": { "hello": 1024, "echo": 998 }
423
+ }
424
+ }
425
+ ```
426
+
427
+ `/metrics` exposes the same data as scrapeable gauges, plus per-function
428
+ circuit-breaker view that mirrors `supervisor.allStats()`:
429
+
430
+ | Gauge | Meaning |
431
+ | ------------------------------------------------------ | ----------------------------------------------------------------------------------------- |
432
+ | `onetube_workerd_up` | `1` if workerd is currently running, `0` between processes (mid-recycle, post-crash gap). |
433
+ | `onetube_workerd_pid` | PID of the live workerd, `0` when not running. |
434
+ | `onetube_workerd_generation` | Increments on every reload (HMR / watchdog / crash recovery). |
435
+ | `onetube_workerd_recycles_total` | Number of memory-watchdog-triggered recycles. |
436
+ | `onetube_workerd_rss_bytes` | Most recent RSS sample. |
437
+ | `onetube_workerd_budget_bytes` | RSS budget enforced by the watchdog (when set). |
438
+ | `onetube_workerd_last_reload_duration_ms` | Wall-clock duration of the most recent successful reload. |
439
+ | `onetube_workerd_bundle_bytes{function="..."}` | esbuild output size per function. |
440
+ | `onetube_function_breaker_open{function="..."}` | `1` while the supervisor's breaker is open for this function. |
441
+ | `onetube_function_error_rate{function="..."}` | Rolling error rate inside the supervisor's window (0..1). |
442
+ | `onetube_function_recycle_recommended{function="..."}` | `1` once `recycle.maxRequests` has been reached. |
443
+
444
+ The breaker gauges work on the Deno backend too — the supervisor is shared
445
+ between paths.
446
+
447
+ ### V8 inspector / debugger access
448
+
449
+ Pass `--inspector` (or `--inspector-addr=host:port`, `1TUBE_INSPECTOR=1`) to
450
+ launch workerd with `--inspector-addr=...`. Default address is `127.0.0.1:9229`:
451
+
452
+ ```bash
453
+ deno run --allow-all src/server.ts --backend workerd --inspector
454
+ # → [1tube] workerd V8 inspector enabled at 127.0.0.1:9229 (open chrome://inspect or attach via DevTools)
455
+ ```
456
+
457
+ Then open `chrome://inspect` in a Chromium browser; the workerd target appears
458
+ under "Remote Target" once a function is invoked. Set breakpoints, step through
459
+ bundled code, inspect locals — everything you'd expect from V8.
460
+
461
+ Caveats:
462
+
463
+ - **Unauthenticated debug port.** `--inspector` opens an unauthenticated V8
464
+ protocol port. The gateway warns loudly when the bind address is non-loopback.
465
+ Don't enable in production.
466
+ - **HMR port shift.** When `--hmr` is also enabled, every reload spawns a _new_
467
+ workerd on the next port slot. The inspector port shifts by one (`9229` →
468
+ `9230` → `9229` → …) to avoid colliding with the still-listening predecessor.
469
+ The boot log calls out the actual port for each generation.
470
+ - **Workerd-only.** The flag is a no-op on `--backend deno`; debug Deno-side
471
+ functions with the standard `--inspect` Deno flag instead.
472
+
473
+ ### Graceful shutdown
474
+
475
+ On `SIGINT` / `SIGTERM` the gateway runs a three-phase shutdown bounded by
476
+ `1TUBE_SHUTDOWN_GRACE_MS` (default `10000`):
477
+
478
+ 1. **Stop watchers** — memory watchdog, hot reloader. Prevents a poll racing the
479
+ shutdown from triggering a doomed recycle.
480
+ 2. **Drain the listener** — `Deno.serve`'s abort signal stops accepting new TCP
481
+ connections. Already-accepted requests keep running against the still-live
482
+ workerd until they complete or grace expires.
483
+ 3. **Stop workerd** — only after the drain has finished (or grace expired).
484
+ Workerd's own SIGTERM-then-SIGKILL ladder gets the larger of
485
+ `(remaining_grace, 1000ms)` so its log pumps can flush a final line.
486
+
487
+ Previous releases fired-and-forgot `workerd.stop()` in parallel with the drain,
488
+ which killed workerd while requests were still forwarding through it (502s on
489
+ the way out). The current ordering ensures in-flight requests keep their
490
+ original socket until they complete naturally — same property HMR's dual-process
491
+ swap relies on.
492
+
493
+ If grace is too tight (rare), bump `1TUBE_SHUTDOWN_GRACE_MS=30000`. The boot log
494
+ records the actual drain + workerd-stop durations so you can size it from real
495
+ numbers.
496
+
497
+ ### Prebuilt artifacts (`1tube build` + `--prebuilt`)
498
+
499
+ For prod deploys you usually don't want esbuild on the box. `1tube build`
500
+ produces a sealed artifact directory that `1tube serve --prebuilt` can boot with
501
+ **zero bundling on the critical path** — just load, sha-256-verify, hand to
502
+ workerd.
503
+
504
+ ```bash
505
+ # Build in CI (or locally)
506
+ deno task build --functions ./supabase/functions --out ./dist
507
+ # → dist/manifest.json + hello.js + echo.js + ... + .gitignore
508
+
509
+ # Ship `dist/` and the workerd binary to prod, then:
510
+ deno run --allow-all src/cli.ts --backend workerd --prebuilt ./dist
511
+ ```
512
+
513
+ Build flags worth knowing:
514
+
515
+ | Flag | Default | Purpose |
516
+ | -------------------------- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------- |
517
+ | `--functions <path>` | `./supabase/functions` | Source dir to bundle. |
518
+ | `--out <path>` | `./dist` | Output artifact directory. |
519
+ | `--only A,B,C` | all | Build a subset (smoke tests, partial deploys). |
520
+ | `--sourcemap MODE` | `linked` | `none` / `linked` / `inline`. |
521
+ | `--minify` | off | Minify output. |
522
+ | `--compat-date YYYY-MM-DD` | (workerd's default) | Bake a workerd compatibility date into the manifest. |
523
+ | `--compat-flag FLAG` | `nodejs_compat` | Repeatable; appends a workerd compatibility flag. |
524
+ | `--workerd-env A,B,C` | empty | Bake env-var allowlist into the manifest. Values are still read from process env at serve time — only names are persisted. |
525
+
526
+ `dist/manifest.json` carries:
527
+
528
+ - Schema version (`schema: 1`) — serve refuses newer schemas instead of
529
+ mis-parsing.
530
+ - 1tube version + ISO build timestamp (diagnostic).
531
+ - Per-function `bundleFile` + `bundleBytes` + `bundleSha256` (lower-case hex).
532
+ The serve loader recomputes the digest at boot and **fails fast** if a bundle
533
+ has been modified or corrupted.
534
+ - Per-function `1tube.json` parsed in-line, so a prebuilt deploy doesn't need
535
+ the source tree at all.
536
+
537
+ Prebuilt-mode behaviour at serve time:
538
+
539
+ - `--functions` is ignored (warned about). `1TUBE_PREBUILT=path` works as an env
540
+ var equivalent.
541
+ - `--prebuilt` implies `--backend workerd`; coerced with a warning if you
542
+ forget.
543
+ - `--hmr` is rejected with a one-line warning — sealed artifacts can't reload.
544
+ Re-run `1tube build` and restart.
545
+ - `backend.reload()` is rejected. Crash-recovery (re-spawning workerd against
546
+ the same on-disk bundles) still works — it doesn't need to re-bundle.
547
+ - The artifact directory must be **writable at boot** because workerd's
548
+ generated `config.gen-*.capnp` is written next to the bundles. If your prod
549
+ filesystem is read-only, `cp -r dist /tmp/dist-rw && --prebuilt /tmp/dist-rw`.
550
+ (Capnp generation is microseconds; we don't bake it at build time so `--port`,
551
+ `--host`, and `--workerd-env` overrides keep working at serve.)
552
+
553
+ What you gain by separating build and serve:
554
+
555
+ - Reproducible deploys — the artifact is byte-identical for fixed inputs
556
+ (esbuild output is deterministic; manifest sorts by function name).
557
+ - Tamper-evident — sha-256 verification at boot catches both corruption and
558
+ silent edits.
559
+ - No esbuild / `@deno/esbuild-plugin` / npm fetch on the prod box.
560
+ - Faster boot — esbuild (the slow part) runs once in CI, not once per restart.
561
+
562
+ ### Cache layout
563
+
564
+ Bundles, sourcemaps, and the generated `config.gen-{0,1}.capnp` files live in
565
+ `node_modules/.cache/1tube-workerd/` (or `.1tube-cache/workerd/` if there's no
566
+ `node_modules/`). The directory is auto-gitignored. Safe to delete; it gets
567
+ rebuilt on next boot.
568
+
569
+ ### Benchmarking
570
+
571
+ A self-contained bench script under `scripts/bench.ts` boots a gateway with each
572
+ backend, hammers `/functions/v1/hello` (GET) and `/functions/v1/echo` (POST with
573
+ a 256-byte body), and reports `RPS / p50 / p95 / p99` per (backend, route) pair.
574
+ It auto-disables rate limiting via `1TUBE_DISABLE_RATE_LIMIT=1` so per-function
575
+ manifest caps don't skew the numbers.
576
+
577
+ ```bash
578
+ # Default sweep (5000 reqs at 64 concurrency, both backends, ~30s).
579
+ deno run --allow-all scripts/bench.ts
580
+
581
+ # Single backend + heavier load.
582
+ deno run --allow-all scripts/bench.ts --backend workerd -n 20000 -c 128
583
+
584
+ # Quick smoke (won't catch tail latency but boots fast).
585
+ deno run --allow-all scripts/bench.ts -n 500 -c 16 --warmup 50
586
+ ```
587
+
588
+ Order-of-magnitude numbers on a recent laptop, loopback, both backends warm:
589
+
590
+ | Backend | Route | RPS | p50 | p95 | p99 |
591
+ | ------- | --------- | ---: | -----: | -----: | -----: |
592
+ | Deno | GET hello | 3180 | 8.8ms | 16.7ms | 26.0ms |
593
+ | Deno | POST echo | 1819 | 16.4ms | 27.2ms | 36.3ms |
594
+ | Workerd | GET hello | 1377 | 22.6ms | 32.9ms | 38.0ms |
595
+ | Workerd | POST echo | 1034 | 29.7ms | 47.2ms | 57.2ms |
596
+
597
+ Workerd costs roughly 2× the latency of in-process Deno calls because every
598
+ request takes one extra HTTP hop (gateway → workerd loopback socket). For
599
+ LLM/streaming workloads where the upstream call dwarfs proxy overhead, that
600
+ delta is invisible.
601
+
63
602
  ## .NET integration
64
603
 
65
604
  Add the `OneTube` NuGet package to your ASP.NET project:
@@ -67,8 +606,17 @@ Add the `OneTube` NuGet package to your ASP.NET project:
67
606
  ```csharp
68
607
  services.AddOneTube(options =>
69
608
  {
70
- options.ProjectPath = "/path/to/1tube";
71
- options.FunctionsPath = "/path/to/supabase/functions";
609
+ // Path to the edge functions directory. Absolute, or relative
610
+ // to AppContext.BaseDirectory (the host's bin/).
611
+ options.FunctionsPath = "supabase/functions";
612
+
613
+ // Binaries — absolute, relative to AppContext.BaseDirectory,
614
+ // or bare names (PATH lookup). On servers, drop deno/workerd
615
+ // next to the published host and use relative paths so the
616
+ // deploy is reproducible.
617
+ options.DenoBinary = "onetube-bin/deno.exe";
618
+ options.WorkerdBinary = "onetube-bin/workerd.exe";
619
+
72
620
  options.Port = 3100;
73
621
  options.EnvVars = new()
74
622
  {
@@ -81,4 +629,10 @@ services.AddOneTube(options =>
81
629
  app.MapOneTube(port: 3100);
82
630
  ```
83
631
 
84
- This spawns the Deno gateway as a managed child process with health monitoring and auto-restart, and forwards `/functions/v1/*` via YARP zero-copy proxying.
632
+ The OneTube package ships the gateway TypeScript sources alongside the DLL
633
+ (under `OneTubeGateway/` in the host's output directory), so you do **not** need
634
+ a 1tube checkout on the production host — only the `deno` and `workerd` binaries
635
+ you point at via `DenoBinary` / `WorkerdBinary`.
636
+
637
+ This spawns the Deno gateway as a managed child process with health monitoring
638
+ and auto-restart, and forwards `/functions/v1/*` via YARP zero-copy proxying.