1tube 0.1.50 → 0.1.52

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
@@ -3,832 +3,4 @@
3
3
 
4
4
  # 1tube
5
5
 
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.
9
-
10
- ## How it works
11
-
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.
17
-
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.
21
-
22
- ## Quick start (local dev)
23
-
24
- ```bash
25
- # Install dependencies
26
- bun install
27
-
28
- # Copy and fill in env vars
29
- cp .env.example .env
30
-
31
- # Start with auto-restart on file changes
32
- bun run dev -- --functions ../sciobot-next/supabase/functions
33
- ```
34
-
35
- The gateway starts on `http://localhost:3100`. Functions are available at
36
- `http://localhost:3100/functions/v1/<name>`.
37
-
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` (set
45
- `DENO_BIN` to point at a specific `deno` if it isn't). **Prefer this launcher
46
- over a hand-rolled `deno run … src/server.ts` line** — it applies 1tube's
47
- recommended defaults (see [minimum dependency age](#minimum-dependency-age)
48
- below) before Deno starts, which a flag added _inside_ the gateway can no longer
49
- do.
50
-
51
- ```bash
52
- # Show CLI help
53
- npx 1tube
54
-
55
- # Build and sign a firmware payload in one command
56
- npx 1tube package --functions supabase/functions --out fw.1tube --sign-key "$1TUBE_PACKAGE_SIGN_KEY"
57
-
58
- # Keep/update the intermediate dist/ artifact as well
59
- npx 1tube package --functions supabase/functions --in dist --out fw.1tube --sign-key "$1TUBE_PACKAGE_SIGN_KEY"
60
- ```
61
-
62
- This replaces local-source invocations such as
63
- `deno run -A ../1tube/src/cli.ts ...` in downstream CI pipelines.
64
-
65
- ### One launcher, one path
66
-
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:
76
-
77
- ```bash
78
- npx 1tube serve --functions ./supabase/functions --dev --hmr \
79
- --config ./supabase/functions/deno.json --env-file=.env --node-modules-dir=false
80
- ```
81
-
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.
91
- - Prefer keeping the command line clean? Set the same flags in
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`.
116
-
117
- ### Minimum dependency age
118
-
119
- Deno 2.9 turns on a **24-hour minimum dependency age** by default: a function
120
- that pins a just-published npm version fails to resolve locally with
121
- _"… is newer than the specified minimum dependency date"_. Because Deno
122
- snapshots npm config at process startup, this can only be changed by the
123
- launcher, not from inside the running gateway.
124
-
125
- The `1tube` launcher (and the .NET host) therefore export
126
- `NPM_CONFIG_MIN_RELEASE_AGE=0` for the Deno child **unless you've already set
127
- it**. That env var is the lowest explicit tier in Deno's precedence chain
128
- (`--minimum-dependency-age` > `deno.json` `minimumDependencyAge` > `.npmrc`
129
- `min-release-age` > this env var > the 24h built-in default), so it cancels
130
- _only_ the new built-in default — any age you configure through `.npmrc`,
131
- `deno.json`, or the CLI flag still wins. To re-enable a guard, set
132
- `NPM_CONFIG_MIN_RELEASE_AGE` (e.g. `24h`) yourself or use any higher-precedence
133
- mechanism.
134
-
135
- ### Deno npm lifecycle scripts
136
-
137
- Deno does not run npm `postinstall` / build scripts unless you allow them.
138
- `deno.lock` may still list transitive packages such as `protobufjs`; the `check`
139
- script uses `--allow-scripts=npm:protobufjs` so `bun run check` / `npm publish`
140
- (via `prepublishOnly`) stays warning-free. That script is only needed for
141
- dependency install — **1tube’s runtime graph is just Hono + JSR std**.
142
-
143
- ## Endpoints
144
-
145
- | Path | Description |
146
- | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
147
- | `GET /` | Liveness probe (`{"status":"ok"}`) — intentionally minimal so unauthenticated callers don't see the function map. |
148
- | `POST /functions/v1/:name` | Invoke an edge function |
149
- | `GET /health` | Auth-gated health (`Authorization: Bearer $INTERNAL_KEY`); without auth returns the same minimal `{"status":"ok"}`. |
150
- | `GET /metrics` | Auth-gated Prometheus exposition (same scheme). |
151
- | `GET /1tube/warmup` | Boot progress (deferred boot): `{ready, total, loaded, loading, queued, failed}`. CORS-enabled so frontends can poll it to drive a warm-up overlay. |
152
-
153
- ## Configuration
154
-
155
- All knobs default to safe-but-backwards-compatible values. The TS gateway
156
- (`src/server.ts`) reads:
157
-
158
- | Env / flag | Default | Notes |
159
- | ----------------------------------------------------------- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
160
- | `--port` / `PORT` | `3100` | Listen port |
161
- | `--host` / `1TUBE_HOST` | `127.0.0.1` | Loopback by default — pass `--host 0.0.0.0` to expose. |
162
- | `--functions` / `FUNCTIONS_PATH` | `./supabase/functions` | Functions root |
163
- | `--timeout` / `FUNCTION_TIMEOUT_MS` | `150000` | Per-request wall-clock cap (also overridable per function) |
164
- | `--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.** |
165
- | `--hmr` / `1TUBE_HMR` | off | File-watch + per-function reload (dev only). |
166
- | `--defer-boot` / `1TUBE_DEFER_BOOT` | on in dev/HMR | Deferred boot (Deno backend only): the gateway accepts requests immediately while function Workers spawn in the background. Requests for cold functions jump the boot queue and get `503` + `X-1tube-Warming: 1` until ready. Disable with `--no-defer-boot` / `1TUBE_DEFER_BOOT=0`. See [Deferred boot & warm-up overlay](#deferred-boot--warm-up-overlay). |
167
- | `--warmup-grace-ms` / `1TUBE_WARMUP_GRACE_MS` | `250` | How long the dispatcher waits for a warming function before answering with the warming 503. Keeps fast-loading functions from flashing the frontend overlay. |
168
- | `1TUBE_HMR_FRESH_WAIT_MS` | `2500` | How long a request waits for an in-flight HMR respawn of its function before falling back to the previous (stale) worker. Fresh code wins by default (Vite-style "request stalls until the rebuild lands"). Stale fallbacks carry `X-1tube-Stale: 1`. `0` = never wait. |
169
- | `1TUBE_BODY_LIMIT_MB` | `30` | Hono `bodyLimit`; matches Supabase. Returns 413 before the handler runs. |
170
- | `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. |
171
- | `1TUBE_CORS_ORIGIN` | `*` (dev only) | Comma-separated allowlist or `*`. In prod, leaving unset disables CORS. |
172
- | `1TUBE_CORS_ALLOW_HEADERS` | Supabase defaults | Comma-separated `Access-Control-Allow-Headers`. Replaces the built-in default list (`authorization, x-client-info, apikey, content-type`, …) when set. |
173
- | `1TUBE_CORS_ALLOW_METHODS` | Supabase defaults | Comma-separated `Access-Control-Allow-Methods` (default `GET, POST, PUT, PATCH, DELETE, OPTIONS`). |
174
- | `1TUBE_CORS_EXPOSE_HEADERS` | 1tube internals | Comma-separated `Access-Control-Expose-Headers`. **Merged with** (not replacing) the headers 1tube must always expose (e.g. `X-1tube-Warming`). |
175
- | `1TUBE_CORS_MAX_AGE` | unset | Seconds for `Access-Control-Max-Age` (preflight cache). Omitted when unset. |
176
- | `1TUBE_CORS_ALLOW_CREDENTIALS` | off | `1`/`true` emits `Access-Control-Allow-Credentials: true`. Per the Fetch spec this is incompatible with `Origin: *`, so when credentials are on, a literal `*` allowlist is rejected — set an explicit origin. |
177
- | `--route-prefix` / `1TUBE_ROUTE_PREFIX` | `/functions/v1` | URL prefix functions are mounted under. Change it to e.g. `/api` to serve at `/api/<name>`. Normalized to a leading-slash, no-trailing-slash form; logging and rate-limiting follow it automatically. |
178
- | `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. |
179
- | `1TUBE_SHUTDOWN_GRACE_MS` | `10000` | SIGINT/SIGTERM drain budget. |
180
- | `INTERNAL_KEY` | unset | Required to read detailed `/health` and `/metrics`. Header-only: `Authorization: Bearer $INTERNAL_KEY`. |
181
- | `--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. |
182
- | `--workerd-bin` / `1TUBE_WORKERD_BIN` | `workerd` (PATH) | Path to the workerd binary. Only consulted with `--backend workerd`. |
183
- | `--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. |
184
- | `--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. |
185
- | `--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. |
186
- | `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. |
187
- | `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. |
188
- | `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. |
189
- | `NPM_CONFIG_MIN_RELEASE_AGE` | `0` (set by launcher) | Deno 2.9's minimum dependency age. The `1tube` launcher and .NET host export `0` for the Deno child unless you set it, cancelling only Deno's new 24h default. See [Minimum dependency age](#minimum-dependency-age). Must be set _before_ Deno starts — a gateway flag can't change it. |
190
-
191
- ````
192
- ## Deferred boot & warm-up overlay
193
-
194
- On the Deno backend, eager boot pays the full cost of spawning one Worker per
195
- function (each transpiling + evaluating its whole import graph) **before** the
196
- gateway accepts a single request. With dozens of functions importing heavy
197
- deps, that's tens of seconds of staring at a boot log.
198
-
199
- Deferred boot (default whenever `--dev` or `--hmr` is on) flips the order:
200
-
201
- 1. Functions are **discovered instantly** and registered as known-but-queued —
202
- the gateway starts serving right away. Time-to-first-request drops from
203
- "sum of all worker spawns" to effectively zero.
204
- 2. Workers spawn in the background with the usual bounded concurrency
205
- (`--deno-worker-concurrency`, default 8), **most-recently-used first**:
206
- the queue is ordered by each function's last invocation time from the
207
- invocation log store, so the functions you were actually hitting before
208
- the restart are warm within the first few seconds and the long tail of
209
- rarely-called functions loads last. First run (empty store) falls back
210
- to name order.
211
- 3. A request for a function whose Worker isn't ready yet **bumps it to the
212
- front of the queue** and spawns it immediately, out-of-band. If it becomes
213
- ready within the grace window (`--warmup-grace-ms`, default 250 ms) the
214
- request is served normally and the client never notices. Otherwise the
215
- gateway answers with the **warming contract** below, and the rest of the
216
- queue keeps loading in the background.
217
-
218
- ### The warming contract
219
-
220
- ```http
221
- HTTP/1.1 503 Service Unavailable
222
- X-1tube-Warming: 1
223
- Retry-After: 1
224
-
225
- {"error":"Backend is warming up","reason":"function_warming","function":"hello",
226
- "state":"loading","warmup":{"total":53,"ready":12,"loading":8,"queued":33,"failed":0}}
227
- ```
228
-
229
- `X-1tube-Warming: 1` is the discriminator — a plain 503 (circuit breaker,
230
- overload) never carries it. It's in `Access-Control-Expose-Headers`, so browser
231
- JS can read it cross-origin. This is what lets a frontend distinguish "the
232
- backend is still warming up" from real latency or a real outage and show an
233
- honest overlay instead of a spinner.
234
-
235
- `GET /1tube/warmup` reports overall progress (also CORS-enabled), e.g. for a
236
- progress bar inside the overlay.
237
-
238
- ### Frontend helper
239
-
240
- The npm package ships a tiny client (`1tube/warmup-client`, no dependencies):
241
-
242
- ```ts
243
- import { createWarmupFetch } from "1tube/warmup-client";
244
-
245
- const warmFetch = createWarmupFetch({
246
- onWarmingChange: (warming) => setShowWarmupOverlay(warming), // drive the overlay
247
- retryDelayMs: 400,
248
- maxWaitMs: 120_000,
249
- });
250
-
251
- // Drop-in fetch replacement: retries through warm-up transparently.
252
- const resp = await warmFetch(`${GATEWAY}/functions/v1/hello`, { method: "POST" });
253
- ```
254
-
255
- `isWarmingResponse(resp)` and `fetchWarmupStatus(baseUrl)` are exported for
256
- hand-rolled integrations.
257
-
258
- ### Smart HMR reloads
259
-
260
- The same queue machinery powers HMR on the Deno backend, so a save costs as
261
- little as possible:
262
-
263
- - **No-op saves are dropped.** Changed files are content-hashed; a re-save
264
- with identical bytes (format-on-save no-ops, `touch`, agent tooling
265
- re-writing the same file) never respawns a Worker.
266
- - **Leading-edge debounce.** The first fs event of a quiet period flushes
267
- after ~40 ms instead of the full 200 ms trailing window — a single editor
268
- save starts reloading almost immediately. Stragglers still coalesce.
269
- - **MRU-first respawns.** When one edit affects more functions than there are
270
- spawn lanes (shared-module edits), the functions you've hit most recently
271
- respawn first; idle ones go last.
272
- - **Request-driven priority + freshness.** A request for a function whose
273
- respawn is still queued/in-flight bumps it to the front (out-of-band, above
274
- the concurrency bound) and waits up to `1TUBE_HMR_FRESH_WAIT_MS` (default
275
- 2500 ms) so save → refresh returns the **new** code, like Vite. If the
276
- respawn takes longer, the previous worker answers and the response is marked
277
- `X-1tube-Stale: 1` (CORS-exposed) so tooling can tell the difference.
278
- - **Targeted re-discovery.** Reloading one function stats only that function's
279
- dir instead of re-scanning the whole functions root.
280
- - **Local-only dep graphs.** The module graph that maps file changes to
281
- affected functions stops at the local-file boundary: `npm:`/`jsr:`/`https:`
282
- subtrees are marked external instead of being fetched and parsed. Only
283
- `file://` modules can fire watcher events, so nothing is lost — and graph
284
- builds for npm-heavy functions drop from seconds to milliseconds.
285
- - **One batch graph per deferred boot.** With shared modules active, deferred
286
- boot builds a single module graph for all functions in the background
287
- instead of letting every spawn crawl its own (which re-parsed the same
288
- `_shared` files once per function).
289
-
290
- Notes:
291
-
292
- - Deferred boot is **off by default in prod** (eager boot keeps the "everything
293
- is ready before traffic" contract); opt in with `--defer-boot`.
294
- - Functions that fail to boot answer `503 {"reason":"function_boot_failed"}`
295
- (not a warming response — retrying won't help).
296
- - Workerd backend is unaffected: it boots all functions as a single bundle
297
- generation.
298
-
299
- ## Workerd backend
300
-
301
- 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:
302
-
303
- - **Hard isolation** between functions and the gateway — a runaway function cannot wedge the gateway, leak memory into peer functions, or read peer functions' globals.
304
- - **Cloudflare-Workers-style runtime** — `nodejs_compat`, `process.env`, `node:fs`/`node:os`/`node:http`, `AsyncLocalStorage`, the modern `fetch`/`Streams`/`URL` spec fixes, etc.
305
- - **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.
306
-
307
- 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.
308
-
309
- ### Run it
310
-
311
- ```bash
312
- # Make sure workerd is on PATH, or set 1TUBE_WORKERD_BIN.
313
- workerd --version
314
-
315
- # Boot 1tube with the workerd backend pointed at the playground.
316
- deno run --allow-all src/server.ts \
317
- --backend workerd \
318
- --functions ./playground \
319
- --port 3100 \
320
- --dev
321
- ````
322
-
323
- You should see something like:
324
-
325
- ```
326
- [1tube] mode=dev hmr=off lazy=off backend=workerd ...
327
- [1tube] Bundling functions from: ./playground
328
- [1tube] workerd backend ready (v1.20260415.0) · 3 function(s) in 740ms
329
- [1tube] Functions: echo, hello, whoami
330
- ```
331
-
332
- Then verify end-to-end:
333
-
334
- ```bash
335
- # Public hello
336
- curl http://127.0.0.1:3100/functions/v1/hello
337
- # → {"message":"hello, hello",...}
338
-
339
- # Public echo with body + query + custom header
340
- curl -X POST 'http://127.0.0.1:3100/functions/v1/echo?x=1' \
341
- -H 'content-type: application/json' \
342
- -H 'x-test: abc' \
343
- -d '{"hello":"world"}'
344
-
345
- # Unknown function fast-fails before auth/rate-limit
346
- curl -i http://127.0.0.1:3100/functions/v1/no-such-fn
347
- # → HTTP/1.1 404 Not Found
348
- ```
349
-
350
- The full e2e test (`tests/workerd-e2e.test.ts`) automates exactly the same
351
- checks against a real workerd subprocess; run
352
- `deno test --allow-all tests/workerd-e2e.test.ts` for one-shot verification.
353
-
354
- ### Compatibility date
355
-
356
- Workerd applies a `compatibilityDate` to enable behaviour-change opt-ins. 1tube
357
- defaults to today's date, then **clamps it down** to whatever the installed
358
- workerd binary actually accepts — derived from the binary's version string
359
- (`1.YYYYMMDD.N`), since workerd refuses dates later than its build date. You
360
- only see a clamp warning if you explicitly pinned a date the binary can't
361
- honour.
362
-
363
- ### Forwarding env vars (secrets, API keys)
364
-
365
- **Default: every env var the gateway sees is forwarded.** Bundled functions
366
- inherit their parent's environment the same way a
367
- `deno run ./functions/foo/index.ts` child would — so
368
- `Deno.env.get("OPENAI_API_KEY")` Just Works without any extra wiring. This is
369
- the developer-friendly default; use it for solo projects and trusted code.
370
-
371
- Pass an explicit allowlist when you care about isolation (shared multi-tenant
372
- workerds, reproducible build artifacts, etc.):
373
-
374
- ```bash
375
- # CLI flag — narrows to exactly these names
376
- deno run --allow-all src/server.ts \
377
- --backend workerd --functions ./functions \
378
- --workerd-env=OPENAI_API_KEY,POSTHOG_HOST,STRIPE_SECRET_KEY
379
-
380
- # Env var (handy when 1tube is launched by a process manager)
381
- 1TUBE_WORKERD_ENV=OPENAI_API_KEY,POSTHOG_HOST,STRIPE_SECRET_KEY \
382
- deno run --allow-all src/server.ts --backend workerd --functions ./functions
383
-
384
- # `*` is the explicit form of the default (forward everything)
385
- --workerd-env=*
386
- ```
387
-
388
- Each listed name becomes a `(name = X, fromEnvironment = X)` binding in the
389
- generated `config.capnp`. Workerd reads the **value** from its own process env
390
- at boot; the on-disk capnp file contains nothing but the var names. This means:
391
-
392
- - Operators can rotate secrets by restarting the gateway with new env values; no
393
- cache rebuild needed.
394
- - A leaked `config.capnp` cannot leak secrets — it never had them.
395
- - Vars listed but absent from the gateway env get a single grouped warning at
396
- boot, then are simply not exposed (workerd would otherwise refuse to start).
397
-
398
- > **A note on the default.** Earlier versions of 1tube defaulted to _deny-all_
399
- > and required explicit opt-in. That was friction for the 90%-case (single-app
400
- > deploys where the gateway and the functions trust each other) and surprised
401
- > people whose `.env` already worked everywhere else. The deny-all stance is
402
- > still one keystroke away — `--workerd-env=A,B,C` — and the per-function
403
- > `permissions.env` manifest knob still narrows further under
404
- > `1TUBE_ENFORCE_MANIFEST=1`.
405
-
406
- ### Bundle sizes at boot
407
-
408
- Workerd boot prints a sorted table so unintended npm bloat is visible
409
- immediately:
410
-
411
- ```
412
- [1tube] workerd bundle sizes (sorted, largest first):
413
- zod-deps 443.4KB (612ms)
414
- stream 12.0KB (89ms)
415
- echo 9.9KB (74ms)
416
- ...
417
- total 492.6KB (1131ms across 7 fns)
418
- ```
419
-
420
- If a bundle is much bigger than expected (50MB+), look for an unintended deep
421
- import — `nodejs_compat` shims most of `node:*` automatically and you rarely
422
- need to import a "browser polyfill" version of an npm package.
423
-
424
- ### Compatibility date
425
-
426
- Workerd applies a `compatibilityDate` to enable behaviour-change opt-ins. 1tube
427
- defaults to today's date, then **clamps it down** to whatever the installed
428
- workerd binary actually accepts — derived from the binary's version string
429
- (`1.YYYYMMDD.N`), since workerd refuses dates later than its build date. You
430
- only see a clamp warning if you explicitly pinned a date the binary can't
431
- honour.
432
-
433
- ### Per-function manifests on workerd
434
-
435
- Each `<function>/1tube.json` is loaded at workerd boot and applied the same way
436
- the Deno backend uses it:
437
-
438
- - **`timeoutMs`** — per-function dispatch timeout. The gateway aborts the
439
- request and returns 504 if the bundled handler doesn't respond within this
440
- window. Falls back to `--timeout` when unset.
441
- - **`rpm`** — per-function rate limit. The gateway-wide rate limiter consults
442
- the workerd manifest the same way it consults Deno-side ones; manifest values
443
- override `1TUBE_DEFAULT_RPM`.
444
- - **`recycle`** — circuit breaker. After `errorWindow` requests with at least
445
- `errorRate` of them returning 5xx (or aborting), the breaker opens for
446
- `cooldownMs`. While open, requests short-circuit with
447
- `503 Retry-After: <seconds>` and a `{"reason": "circuit_breaker_open"}` body —
448
- workerd is never touched. The breaker half-opens automatically once the
449
- cool-down elapses.
450
- - **`permissions.env`** — per-function env scoping. Default behaviour: every
451
- function sees the full `--workerd-env` allowlist (manifest entries are
452
- documentation only). Set `1TUBE_ENFORCE_MANIFEST=1` to switch to strict mode,
453
- where each function only sees the _intersection_ of the gateway allowlist and
454
- its declared `permissions.env`. Use `["*"]` to opt back into the full surface
455
- under enforcement.
456
-
457
- Functions without a `1tube.json` get the defaults from `defaultManifest()` —
458
- wide-open env, gateway-default timeout/rpm, errorRate=0.5 over a 20-request
459
- window with a 30s cool-down.
460
-
461
- #### Example — tight breaker
462
-
463
- ```json
464
- // playground/boom/1tube.json
465
- {
466
- "permissions": { "net": [], "env": [], "read": [], "write": [] },
467
- "timeoutMs": 5000,
468
- "recycle": { "errorRate": 1.0, "errorWindow": 3, "cooldownMs": 5000 }
469
- }
470
- ```
471
-
472
- Three consecutive 500s trip the breaker; the gateway logs:
473
-
474
- ```
475
- [1tube] Circuit breaker OPEN for "boom" (workerd) — refusing requests for 5000ms (errorRate >= 1).
476
- ```
477
-
478
- The 4th request returns 503 with `Retry-After: 5` without ever reaching the
479
- workerd subprocess. Verified end-to-end in `tests/workerd-e2e.test.ts`.
480
-
481
- ### HMR on workerd
482
-
483
- `--hmr` (or `1TUBE_HMR=1`) enables live reload on the workerd backend. The
484
- gateway watches the functions directory and on each save:
485
-
486
- 1. Classifies the changed path. A change inside a function dir (e.g.
487
- `playground/hello/index.ts` or `playground/hello/1tube.json`) only re-bundles
488
- that one function; a change to anything starting with `_` (`_shared/`,
489
- `_internal/`, etc.) triggers a full re-bundle since shared code may be
490
- imported by any function.
491
- 2. Coalesces a 200ms burst of save events (editor format-on-save typically
492
- writes the file 2–3 times within ~50ms).
493
- 3. Re-bundles the affected function(s) using the long-lived esbuild worker.
494
- Bundles for unchanged functions stay on disk.
495
- 4. Generates a fresh `config.gen-{0|1}.capnp` on shifted ports (alternating
496
- between `basePort` and `basePort+500`) so the new workerd never collides with
497
- the still-serving old one.
498
- 5. Spawns the new workerd, waits for TCP readiness, then atomically swaps the
499
- gateway's route table.
500
- 6. Stops the old workerd. In-flight requests against the old process keep their
501
- captured origin URL and complete naturally; only NEW requests go to the new
502
- workerd. There is no request-drop window.
503
-
504
- A typical incremental swap completes in **200–800ms** on a warm cache
505
- (single-function rebundle + workerd cold start). Manifests (`1tube.json`) are
506
- re-read on every reload and propagated into the registry / supervisor before the
507
- new workerd starts serving, so an `rpm` or `timeoutMs` edit applies to the very
508
- first post-reload request.
509
-
510
- Failures are non-fatal: a syntax error in your source code aborts the swap, the
511
- old workerd keeps serving, and the next save retries. The reloader logs:
512
-
513
- ```
514
- [1tube] HMR 1 function(s) changed: hello
515
- [1tube] HMR reload ok in 412ms (gen=3; rebundled=hello)
516
- ```
517
-
518
- or, on failure:
519
-
520
- ```
521
- [1tube] HMR reload FAILED — keeping previous workerd alive. Error: …
522
- ```
523
-
524
- End-to-end coverage in `tests/workerd-e2e.test.ts`
525
- (`HMR rebundles + swaps without dropping requests`).
526
-
527
- ### Memory enforcement & crash recovery
528
-
529
- **The honest version up front**: workerd's open-source binary does **not**
530
- support per-isolate memory limits in its capnp schema — that's a
531
- Cloudflare-platform feature, not something the OSS runtime exposes. So 1tube
532
- cannot kill an individual misbehaving function; it can only recycle the whole
533
- workerd process. Two mechanisms keep things stable:
534
-
535
- 1. **Memory watchdog** — periodically reads the workerd PID's resident set size
536
- (RSS) and recycles the entire process when it stays over budget for `N`
537
- consecutive samples.
538
- 2. **Crash auto-recovery** — if workerd dies unexpectedly (OOM, SIGKILL,
539
- segfault), the gateway re-bundles and respawns it automatically.
540
-
541
- Both reuse the M4 dual-process swap, so recovery is non-disruptive: in-flight
542
- requests against the old workerd complete on their existing socket, the gateway
543
- atomically points new requests at the new process, and only then is the
544
- dead/over-budget process torn down.
545
-
546
- #### Watchdog
547
-
548
- Opt-in via `1TUBE_WORKERD_MAX_RSS_MB` (an absolute cap in MB). When unset, the
549
- gateway derives a recommended budget from `sum(manifest.memoryMB) × 1.5 + 64MB`
550
- and uses that automatically. If no manifest declares `memoryMB` either, the
551
- watchdog stays off — no surprises for existing deployments.
552
-
553
- | Env var | Default | Notes |
554
- | ------------------------------- | ------- | ------------------- |
555
- | `1TUBE_WORKERD_MAX_RSS_MB` | unset | Hard RSS cap in MB. |
556
- | `1TUBE_WORKERD_RSS_INTERVAL_MS` | `5000` | Poll interval. |
557
-
558
- Hysteresis is fixed at **3 consecutive over-budget samples** before tripping (a
559
- single GC pause spike won't recycle), and a **10-second cooldown** runs after
560
- each recycle to let the new process settle. Watchdog stats (last RSS, configured
561
- budget, total recycles, current generation) are surfaced on the authenticated
562
- `/health` endpoint under a `workerd` block:
563
-
564
- ```json
565
- {
566
- "status": "ok",
567
- "workerd": {
568
- "pid": 1234,
569
- "generation": 2,
570
- "recycles": 1,
571
- "rss_bytes": 134217728,
572
- "budget_bytes": 268435456
573
- }
574
- }
575
- ```
576
-
577
- RSS reading is cross-platform: Linux reads `/proc/{pid}/status`, macOS shells
578
- out to `ps`, Windows to `tasklist`. The numbers approximate (working set vs
579
- resident vs incl. shared pages differ by platform) — treat the budget as a rough
580
- cap, not a precise quota.
581
-
582
- #### Crash recovery
583
-
584
- `WorkerdProcess.onExit` is wired to trigger an automatic recycle. A bounded
585
- retry budget — **5 crashes in 30 seconds** — protects against hot-looping on a
586
- fundamentally broken bundle:
587
-
588
- ```
589
- [1tube] workerd crashed (gen=2, code=137, crashes=1/5 in 30s) — auto-recycling...
590
- [1tube] workerd crashed (gen=3, code=137, crashes=2/5 in 30s) — auto-recycling...
591
- …
592
- [1tube] workerd crashed (gen=6, code=137, crashes=6/5 in 30s) — GIVING UP, manual restart required.
593
- ```
594
-
595
- After the budget is exhausted the gateway returns `502 workerd backend error`
596
- for every request until the operator restarts it (or, in dev, an HMR save which
597
- clears the counter and resumes recovery). End-to-end coverage in
598
- `tests/workerd-e2e.test.ts`
599
- (`gateway auto-recovers when workerd is killed externally`).
600
-
601
- ### Observability on workerd
602
-
603
- Both `/health` (JSON) and `/metrics` (Prometheus exposition) report
604
- workerd-specific state when the backend is active. Both endpoints require
605
- `Authorization: Bearer $INTERNAL_KEY`.
606
-
607
- `/health` adds a `workerd` block:
608
-
609
- ```json
610
- {
611
- "workerd": {
612
- "pid": 4242,
613
- "generation": 3,
614
- "recycles": 0,
615
- "rss_bytes": 134217728,
616
- "budget_bytes": 268435456,
617
- "last_reload_duration_ms": 412.5,
618
- "bundle_bytes": { "hello": 1024, "echo": 998 }
619
- }
620
- }
621
- ```
622
-
623
- `/metrics` exposes the same data as scrapeable gauges, plus per-function
624
- circuit-breaker view that mirrors `supervisor.allStats()`:
625
-
626
- | Gauge | Meaning |
627
- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------- |
628
- | `onetube_workerd_up` | `1` if workerd is currently running, `0` between processes (mid-recycle, post-crash gap). |
629
- | `onetube_workerd_pid` | PID of the live workerd, `0` when not running. |
630
- | `onetube_workerd_generation` | Increments on every reload (HMR / watchdog / crash recovery). |
631
- | `onetube_workerd_recycles_total` | Number of memory-watchdog-triggered recycles. |
632
- | `onetube_workerd_rss_bytes` | Most recent RSS sample. |
633
- | `onetube_workerd_budget_bytes` | RSS budget enforced by the watchdog (when set). |
634
- | `onetube_workerd_last_reload_duration_ms` | Wall-clock duration of the most recent successful reload. |
635
- | `onetube_workerd_bundle_bytes{function="..."}` | esbuild output size per function. |
636
- | `onetube_function_breaker_open{function="..."}` | `1` while the supervisor's breaker is open for this function. |
637
- | `onetube_function_error_rate{function="..."}` | Rolling error rate inside the supervisor's window (0..1). |
638
- | `onetube_function_recycle_recommended{function="..."}` | `1` once `recycle.maxRequests` has been reached. |
639
-
640
- The breaker gauges work on the Deno backend too — the supervisor is shared
641
- between paths.
642
-
643
- ### V8 inspector / debugger access
644
-
645
- Pass `--inspector` (or `--inspector-addr=host:port`, `1TUBE_INSPECTOR=1`) to
646
- launch workerd with `--inspector-addr=...`. Default address is `127.0.0.1:9229`:
647
-
648
- ```bash
649
- deno run --allow-all src/server.ts --backend workerd --inspector
650
- # → [1tube] workerd V8 inspector enabled at 127.0.0.1:9229 (open chrome://inspect or attach via DevTools)
651
- ```
652
-
653
- Then open `chrome://inspect` in a Chromium browser; the workerd target appears
654
- under "Remote Target" once a function is invoked. Set breakpoints, step through
655
- bundled code, inspect locals — everything you'd expect from V8.
656
-
657
- Caveats:
658
-
659
- - **Unauthenticated debug port.** `--inspector` opens an unauthenticated V8
660
- protocol port. The gateway warns loudly when the bind address is non-loopback.
661
- Don't enable in production.
662
- - **HMR port shift.** When `--hmr` is also enabled, every reload spawns a _new_
663
- workerd on the next port slot. The inspector port shifts by one (`9229` →
664
- `9230` → `9229` → …) to avoid colliding with the still-listening predecessor.
665
- The boot log calls out the actual port for each generation.
666
- - **Workerd-only.** The flag is a no-op on `--backend deno`; debug Deno-side
667
- functions with the standard `--inspect` Deno flag instead.
668
-
669
- ### Graceful shutdown
670
-
671
- On `SIGINT` / `SIGTERM` the gateway runs a three-phase shutdown bounded by
672
- `1TUBE_SHUTDOWN_GRACE_MS` (default `10000`):
673
-
674
- 1. **Stop watchers** — memory watchdog, hot reloader. Prevents a poll racing the
675
- shutdown from triggering a doomed recycle.
676
- 2. **Drain the listener** — `Deno.serve`'s abort signal stops accepting new TCP
677
- connections. Already-accepted requests keep running against the still-live
678
- workerd until they complete or grace expires.
679
- 3. **Stop workerd** — only after the drain has finished (or grace expired).
680
- Workerd's own SIGTERM-then-SIGKILL ladder gets the larger of
681
- `(remaining_grace, 1000ms)` so its log pumps can flush a final line.
682
-
683
- Previous releases fired-and-forgot `workerd.stop()` in parallel with the drain,
684
- which killed workerd while requests were still forwarding through it (502s on
685
- the way out). The current ordering ensures in-flight requests keep their
686
- original socket until they complete naturally — same property HMR's dual-process
687
- swap relies on.
688
-
689
- If grace is too tight (rare), bump `1TUBE_SHUTDOWN_GRACE_MS=30000`. The boot log
690
- records the actual drain + workerd-stop durations so you can size it from real
691
- numbers.
692
-
693
- ### Prebuilt artifacts (`1tube build` + `--prebuilt`)
694
-
695
- For prod deploys you usually don't want esbuild on the box. `1tube build`
696
- produces a sealed artifact directory that `1tube serve --prebuilt` can boot with
697
- **zero bundling on the critical path** — just load, sha-256-verify, hand to
698
- workerd.
699
-
700
- ```bash
701
- # Build in CI (or locally)
702
- deno task build --functions ./supabase/functions --out ./dist
703
- # → dist/manifest.json + hello.js + echo.js + ... + .gitignore
704
-
705
- # Ship `dist/` and the workerd binary to prod, then:
706
- deno run --allow-all src/cli.ts --backend workerd --prebuilt ./dist
707
- ```
708
-
709
- Build flags worth knowing:
710
-
711
- | Flag | Default | Purpose |
712
- | -------------------------- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------- |
713
- | `--functions <path>` | `./supabase/functions` | Source dir to bundle. |
714
- | `--out <path>` | `./dist` | Output artifact directory. |
715
- | `--only A,B,C` | all | Build a subset (smoke tests, partial deploys). |
716
- | `--sourcemap MODE` | `linked` | `none` / `linked` / `inline`. |
717
- | `--minify` | off | Minify output. |
718
- | `--compat-date YYYY-MM-DD` | (workerd's default) | Bake a workerd compatibility date into the manifest. |
719
- | `--compat-flag FLAG` | `nodejs_compat` | Repeatable; appends a workerd compatibility flag. |
720
- | `--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. |
721
-
722
- `dist/manifest.json` carries:
723
-
724
- - Schema version (`schema: 1`) — serve refuses newer schemas instead of
725
- mis-parsing.
726
- - 1tube version + ISO build timestamp (diagnostic).
727
- - Per-function `bundleFile` + `bundleBytes` + `bundleSha256` (lower-case hex).
728
- The serve loader recomputes the digest at boot and **fails fast** if a bundle
729
- has been modified or corrupted.
730
- - Per-function `1tube.json` parsed in-line, so a prebuilt deploy doesn't need
731
- the source tree at all.
732
-
733
- Prebuilt-mode behaviour at serve time:
734
-
735
- - `--functions` is ignored (warned about). `1TUBE_PREBUILT=path` works as an env
736
- var equivalent.
737
- - `--prebuilt` implies `--backend workerd`; coerced with a warning if you
738
- forget.
739
- - `--hmr` is rejected with a one-line warning — sealed artifacts can't reload.
740
- Re-run `1tube build` and restart.
741
- - `backend.reload()` is rejected. Crash-recovery (re-spawning workerd against
742
- the same on-disk bundles) still works — it doesn't need to re-bundle.
743
- - The artifact directory must be **writable at boot** because workerd's
744
- generated `config.gen-*.capnp` is written next to the bundles. If your prod
745
- filesystem is read-only, `cp -r dist /tmp/dist-rw && --prebuilt /tmp/dist-rw`.
746
- (Capnp generation is microseconds; we don't bake it at build time so `--port`,
747
- `--host`, and `--workerd-env` overrides keep working at serve.)
748
-
749
- What you gain by separating build and serve:
750
-
751
- - Reproducible deploys — the artifact is byte-identical for fixed inputs
752
- (esbuild output is deterministic; manifest sorts by function name).
753
- - Tamper-evident — sha-256 verification at boot catches both corruption and
754
- silent edits.
755
- - No esbuild / `@deno/esbuild-plugin` / npm fetch on the prod box.
756
- - Faster boot — esbuild (the slow part) runs once in CI, not once per restart.
757
-
758
- ### Cache layout
759
-
760
- Bundles, sourcemaps, and the generated `config.gen-{0,1}.capnp` files live in
761
- `node_modules/.cache/1tube-workerd/` (or `.1tube-cache/workerd/` if there's no
762
- `node_modules/`). The directory is auto-gitignored. Safe to delete; it gets
763
- rebuilt on next boot.
764
-
765
- ### Benchmarking
766
-
767
- A self-contained bench script under `scripts/bench.ts` boots a gateway with each
768
- backend, hammers `/functions/v1/hello` (GET) and `/functions/v1/echo` (POST with
769
- a 256-byte body), and reports `RPS / p50 / p95 / p99` per (backend, route) pair.
770
- It auto-disables rate limiting via `1TUBE_DISABLE_RATE_LIMIT=1` so per-function
771
- manifest caps don't skew the numbers.
772
-
773
- ```bash
774
- # Default sweep (5000 reqs at 64 concurrency, both backends, ~30s).
775
- deno run --allow-all scripts/bench.ts
776
-
777
- # Single backend + heavier load.
778
- deno run --allow-all scripts/bench.ts --backend workerd -n 20000 -c 128
779
-
780
- # Quick smoke (won't catch tail latency but boots fast).
781
- deno run --allow-all scripts/bench.ts -n 500 -c 16 --warmup 50
782
- ```
783
-
784
- Order-of-magnitude numbers on a recent laptop, loopback, both backends warm:
785
-
786
- | Backend | Route | RPS | p50 | p95 | p99 |
787
- | ------- | --------- | ---: | -----: | -----: | -----: |
788
- | Deno | GET hello | 3180 | 8.8ms | 16.7ms | 26.0ms |
789
- | Deno | POST echo | 1819 | 16.4ms | 27.2ms | 36.3ms |
790
- | Workerd | GET hello | 1377 | 22.6ms | 32.9ms | 38.0ms |
791
- | Workerd | POST echo | 1034 | 29.7ms | 47.2ms | 57.2ms |
792
-
793
- Workerd costs roughly 2× the latency of in-process Deno calls because every
794
- request takes one extra HTTP hop (gateway → workerd loopback socket). For
795
- LLM/streaming workloads where the upstream call dwarfs proxy overhead, that
796
- delta is invisible.
797
-
798
- ## .NET integration
799
-
800
- Add the `OneTube` NuGet package to your ASP.NET project:
801
-
802
- ```csharp
803
- services.AddOneTube(options =>
804
- {
805
- // Path to the edge functions directory. Absolute, or relative
806
- // to AppContext.BaseDirectory (the host's bin/).
807
- options.FunctionsPath = "supabase/functions";
808
-
809
- // Binaries — absolute, relative to AppContext.BaseDirectory,
810
- // or bare names (PATH lookup). On servers, drop deno/workerd
811
- // next to the published host and use relative paths so the
812
- // deploy is reproducible.
813
- options.DenoBinary = "onetube-bin/deno.exe";
814
- options.WorkerdBinary = "onetube-bin/workerd.exe";
815
-
816
- options.Port = 3100;
817
- options.EnvVars = new()
818
- {
819
- ["SUPABASE_URL"] = "...",
820
- ["JWT_SECRET"] = "...",
821
- };
822
- });
823
-
824
- // In the pipeline, after UseRouting:
825
- app.MapOneTube(port: 3100);
826
- ```
827
-
828
- The OneTube package ships the gateway TypeScript sources alongside the DLL
829
- (under `OneTubeGateway/` in the host's output directory), so you do **not** need
830
- a 1tube checkout on the production host — only the `deno` and `workerd` binaries
831
- you point at via `DenoBinary` / `WorkerdBinary`.
832
-
833
- This spawns the Deno gateway as a managed child process with health monitoring
834
- and auto-restart, and forwards `/functions/v1/*` via YARP zero-copy proxying.
6
+ 🕯☽ Black magic. ☾🕯
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "1tube",
3
- "version": "0.1.50",
3
+ "version": "0.1.52",
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",
@@ -53,7 +53,7 @@ function getSecret(): string {
53
53
  verifyCache.clear();
54
54
  if (!current && !_warnedNoSecret) {
55
55
  console.warn(
56
- "[1tube] JWT_SECRET not set — authenticated endpoints will reject all requests",
56
+ "[1tube] JWT_SECRET not set — authenticated endpoints return 503, not 401",
57
57
  );
58
58
  _warnedNoSecret = true;
59
59
  }
@@ -165,6 +165,64 @@ async function verifyToken(token: string): Promise<JWTPayload | null> {
165
165
  }
166
166
  }
167
167
 
168
+ export type RequestAuthProbe =
169
+ | { status: "ok"; auth: AuthContext }
170
+ | { status: "missing" }
171
+ | { status: "invalid" }
172
+ | { status: "unavailable" };
173
+
174
+ function serviceRoleAuth(token: string): AuthContext {
175
+ const nowSec = Math.floor(Date.now() / 1000);
176
+ return {
177
+ userId: "",
178
+ email: "",
179
+ payload: {
180
+ sub: "",
181
+ email: "",
182
+ role: "service_role",
183
+ iss: "supabase",
184
+ aud: "service_role",
185
+ iat: nowSec,
186
+ // Synthetic exp matching JWTPayload shape; the token has no real
187
+ // expiry of its own — rotation is governed by env reload.
188
+ exp: nowSec + 60 * 60,
189
+ },
190
+ rawToken: token,
191
+ };
192
+ }
193
+
194
+ /**
195
+ * Probe the Authorization header without collapsing every miss into `null`.
196
+ *
197
+ * A missing `JWT_SECRET` is `unavailable` — the gateway cannot tell a live
198
+ * session from a bad one, so the dispatcher must return 503 rather than 401.
199
+ * Treating that as Unauthorized signs SPAs out during deploys.
200
+ */
201
+ export async function probeRequest(req: Request): Promise<RequestAuthProbe> {
202
+ const token = parseBearer(req.headers.get("Authorization"));
203
+ if (!token) return { status: "missing" };
204
+
205
+ const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") || "";
206
+ if (serviceKey && token === serviceKey) {
207
+ return { status: "ok", auth: serviceRoleAuth(token) };
208
+ }
209
+
210
+ if (!getSecret()) return { status: "unavailable" };
211
+
212
+ const payload = await verifyToken(token);
213
+ if (!payload) return { status: "invalid" };
214
+
215
+ return {
216
+ status: "ok",
217
+ auth: {
218
+ userId: payload.sub,
219
+ email: payload.email,
220
+ payload,
221
+ rawToken: token,
222
+ },
223
+ };
224
+ }
225
+
168
226
  /**
169
227
  * Validate the Authorization header and return an AuthContext, or null if invalid.
170
228
  *
@@ -187,39 +245,8 @@ async function verifyToken(token: string): Promise<JWTPayload | null> {
187
245
  export async function validateRequest(
188
246
  req: Request,
189
247
  ): Promise<AuthContext | null> {
190
- const token = parseBearer(req.headers.get("Authorization"));
191
- if (!token) return null;
192
-
193
- const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") || "";
194
- if (serviceKey && token === serviceKey) {
195
- const nowSec = Math.floor(Date.now() / 1000);
196
- return {
197
- userId: "",
198
- email: "",
199
- payload: {
200
- sub: "",
201
- email: "",
202
- role: "service_role",
203
- iss: "supabase",
204
- aud: "service_role",
205
- iat: nowSec,
206
- // Synthetic exp matching JWTPayload shape; the token has no real
207
- // expiry of its own — rotation is governed by env reload.
208
- exp: nowSec + 60 * 60,
209
- },
210
- rawToken: token,
211
- };
212
- }
213
-
214
- const payload = await verifyToken(token);
215
- if (!payload) return null;
216
-
217
- return {
218
- userId: payload.sub,
219
- email: payload.email,
220
- payload,
221
- rawToken: token,
222
- };
248
+ const probed = await probeRequest(req);
249
+ return probed.status === "ok" ? probed.auth : null;
223
250
  }
224
251
 
225
252
  // ---------------------------------------------------------------------------
@@ -36,7 +36,8 @@ const DEFAULT_ALLOWED_METHODS = "POST, GET, OPTIONS, PUT, PATCH, DELETE";
36
36
  // its HMR respawn is still in flight; `retry-after` lets clients
37
37
  // honour the gateway's suggested backoff. These are always exposed —
38
38
  // operator config only ADDS to them.
39
- const INTERNAL_EXPOSED_HEADERS = "x-1tube-warming, x-1tube-stale, retry-after";
39
+ const INTERNAL_EXPOSED_HEADERS =
40
+ "x-1tube-warming, x-1tube-stale, x-1tube-auth-unavailable, retry-after";
40
41
 
41
42
  interface CorsConfig {
42
43
  /** "*" means allow any. Empty array means CORS disabled. */
package/src/server.ts CHANGED
@@ -19,7 +19,7 @@
19
19
  import { type Context, Hono } from "npm:hono@4";
20
20
  import { bodyLimit } from "npm:hono@4/body-limit";
21
21
  import { currentRequestStorage, FunctionRegistry } from "./registry.ts";
22
- import { validateRequest } from "./gateway/auth.ts";
22
+ import { probeRequest } from "./gateway/auth.ts";
23
23
  import { watchdogBody } from "./gateway/body-watchdog.ts";
24
24
  import { corsMiddleware } from "./gateway/cors.ts";
25
25
  import {
@@ -1842,13 +1842,15 @@ app.use(fnRouteWildcard, async (c, next) => {
1842
1842
  // unauthenticated requests here. Public functions need to pass through and
1843
1843
  // rate-limit by IP; protected functions are gated below in the dispatcher.
1844
1844
  try {
1845
- const auth = await validateRequest(c.req.raw);
1846
- if (auth) {
1847
- c.set("userId", auth.userId);
1848
- (c as any).set("__authContext", auth);
1845
+ const probed = await probeRequest(c.req.raw);
1846
+ (c as any).set("__authProbe", probed.status);
1847
+ if (probed.status === "ok") {
1848
+ c.set("userId", probed.auth.userId);
1849
+ (c as any).set("__authContext", probed.auth);
1849
1850
  }
1850
1851
  } catch {
1851
- // Ignore validation errors at probe time — dispatcher will return 401.
1852
+ // Ignore validation errors at probe time — dispatcher will 401/503.
1853
+ (c as any).set("__authProbe", "invalid");
1852
1854
  }
1853
1855
  await next();
1854
1856
  });
@@ -1857,6 +1859,100 @@ app.use(fnRouteWildcard, rateLimiter);
1857
1859
  // Function dispatch. The pattern is built from the configurable prefix, so
1858
1860
  // Hono can't statically infer the `:name` param — hence the `?? ""` guard
1859
1861
  // (the `{.+}` matcher guarantees a non-empty value at runtime anyway).
1862
+
1863
+ /**
1864
+ * Dev/localhost error detail body.
1865
+ *
1866
+ * In production the gateway deliberately collapses unhandled function
1867
+ * failures to a generic `{"error":"Internal server error"}` so stack
1868
+ * traces and internal messages never leak to clients. That makes local
1869
+ * debugging painful, so when iterating (--dev / 1TUBE_DEV=1) and bound to
1870
+ * loopback we instead return the real message + stack. Gated on BOTH dev
1871
+ * mode AND loopback binding so `1TUBE_DEV=1 --host 0.0.0.0` still
1872
+ * produces sanitized bodies.
1873
+ */
1874
+ /**
1875
+ * Dev/localhost error detail body.
1876
+ *
1877
+ * In production the gateway deliberately collapses unhandled function
1878
+ * failures to a generic `{"error":"Internal server error"}` so stack
1879
+ * traces and internal messages never leak to clients. That makes local
1880
+ * debugging painful, so when iterating (--dev / 1TUBE_DEV=1) on a local
1881
+ * address we instead return the real message + stack.
1882
+ *
1883
+ * "Local" is decided per-request from the request's `Host` header (NOT
1884
+ * the bind address), because developers frequently map loopback to a
1885
+ * custom name in their hosts file (e.g. `app.local`, `sciobot.test`) —
1886
+ * the server still binds 127.0.0.1 but the request arrives under the
1887
+ * alias. Matching by `Host` covers all of those.
1888
+ *
1889
+ * Configuration:
1890
+ * 1TUBE_EXPOSE_ERRORS=0|1 force off / force on (overrides dev+host check)
1891
+ * 1TUBE_EXPOSE_ERROR_HOSTS="a.test,b.local" extra host suffixes to treat as local
1892
+ *
1893
+ * Defaults (local): loopback IPs, `localhost`, any `*.localhost`, `*.local`,
1894
+ * `*.test`, `*.internal` host, plus the operator's extras.
1895
+ */
1896
+
1897
+ /** Loopback IPs (exact match) and well-known local TLD suffixes. */
1898
+ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1", "[::1]", "localhost"]);
1899
+ const LOCAL_HOST_SUFFIXES = [".localhost", ".local", ".test", ".internal"];
1900
+
1901
+ function parseExtraExposeHosts(): string[] {
1902
+ const raw = Deno.env.get("1TUBE_EXPOSE_ERROR_HOSTS") ?? "";
1903
+ return raw.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
1904
+ }
1905
+ const extraExposeHosts = parseExtraExposeHosts();
1906
+
1907
+ function isLocalHostname(hostname: string): boolean {
1908
+ const h = hostname.toLowerCase();
1909
+ if (LOOPBACK_HOSTS.has(h)) return true;
1910
+ if (h.startsWith("127.")) return true; // entire 127.0.0.0/8 loopback block
1911
+ for (const suffix of LOCAL_HOST_SUFFIXES) {
1912
+ if (h.endsWith(suffix)) return true;
1913
+ }
1914
+ for (const extra of extraExposeHosts) {
1915
+ if (h === extra || h.endsWith("." + extra)) return true;
1916
+ }
1917
+ return false;
1918
+ }
1919
+
1920
+ /** Strip the port from a Host header value. */
1921
+ function hostHeaderHostname(c: { req: { header: (n: string) => string | undefined } }): string {
1922
+ const host = c.req.header("host") ?? "";
1923
+ if (host.startsWith("[")) {
1924
+ const end = host.indexOf("]");
1925
+ return end === -1 ? host : host.slice(0, end + 1);
1926
+ }
1927
+ const i = host.indexOf(":");
1928
+ return i === -1 ? host : host.slice(0, i);
1929
+ }
1930
+
1931
+ function shouldExposeErrorDetails(
1932
+ c: { req: { header: (n: string) => string | undefined } },
1933
+ ): boolean {
1934
+ // Operator override wins both ways.
1935
+ const override = Deno.env.get("1TUBE_EXPOSE_ERRORS");
1936
+ if (override === "1" || override === "true") return true;
1937
+ if (override === "0" || override === "false") return false;
1938
+ if (!opts.dev) return false;
1939
+ return isLocalHostname(hostHeaderHostname(c));
1940
+ }
1941
+
1942
+ function errorDetailsBody(
1943
+ c: { req: { header: (n: string) => string | undefined } },
1944
+ err: unknown,
1945
+ ): Record<string, unknown> {
1946
+ if (!shouldExposeErrorDetails(c)) return { error: "Internal server error" };
1947
+ const e = err instanceof Error ? err : new Error(String(err));
1948
+ return {
1949
+ error: "Internal server error",
1950
+ message: e.message,
1951
+ stack: e.stack,
1952
+ name: e.name,
1953
+ };
1954
+ }
1955
+
1860
1956
  app.all(routeDispatchPattern(), async (c) => {
1861
1957
  // c.req.param("name") greedily captures the trailing path with the {.+}
1862
1958
  // matcher; restrict it to the first segment.
@@ -1930,6 +2026,15 @@ app.all(routeDispatchPattern(), async (c) => {
1930
2026
  duplex: "half",
1931
2027
  });
1932
2028
  const auth = (c as any).get("__authContext") ?? null;
2029
+ const knownPublic = registry.get(name)?.isPublic ??
2030
+ registry.workerHandle(name)?.isPublic;
2031
+ if (knownPublic === false && !auth && (c as any).get("__authProbe") === "unavailable") {
2032
+ return c.json(
2033
+ { error: "Auth temporarily unavailable", reason: "jwt_secret_unavailable" },
2034
+ 503,
2035
+ { "X-1tube-Auth-Unavailable": "1" },
2036
+ );
2037
+ }
1933
2038
 
1934
2039
  // Per-function timeout from the manifest, falling back to the
1935
2040
  // gateway-wide default. Same precedence the Deno path uses.
@@ -1982,7 +2087,7 @@ app.all(routeDispatchPattern(), async (c) => {
1982
2087
  message: err instanceof Error ? err.message : String(err),
1983
2088
  stack: err instanceof Error ? err.stack : undefined,
1984
2089
  });
1985
- response = c.json({ error: "Internal server error" }, 500);
2090
+ response = c.json(errorDetailsBody(c, err), 500);
1986
2091
  }
1987
2092
  } finally {
1988
2093
  if (timer !== null) clearTimeout(timer);
@@ -2154,6 +2259,13 @@ app.all(routeDispatchPattern(), async (c) => {
2154
2259
 
2155
2260
  const auth = (c as any).get("__authContext") ?? null;
2156
2261
  if (!handle.isPublic && !auth) {
2262
+ if ((c as any).get("__authProbe") === "unavailable") {
2263
+ return c.json(
2264
+ { error: "Auth temporarily unavailable", reason: "jwt_secret_unavailable" },
2265
+ 503,
2266
+ { "X-1tube-Auth-Unavailable": "1" },
2267
+ );
2268
+ }
2157
2269
  return c.json({ error: "Unauthorized" }, 401);
2158
2270
  }
2159
2271
 
@@ -2207,7 +2319,7 @@ app.all(routeDispatchPattern(), async (c) => {
2207
2319
  message: err instanceof Error ? err.message : String(err),
2208
2320
  stack: err instanceof Error ? err.stack : undefined,
2209
2321
  });
2210
- response = c.json({ error: "Internal server error" }, 500);
2322
+ response = c.json(errorDetailsBody(c, err), 500);
2211
2323
  }
2212
2324
  } finally {
2213
2325
  if (timer !== null) clearTimeout(timer);
@@ -2460,7 +2572,19 @@ const server = Deno.serve(
2460
2572
  },
2461
2573
  onError: (err) => {
2462
2574
  console.error(`[1tube] Request error: ${err}`);
2463
- return new Response("Internal error", { status: 500 });
2575
+ // No request context here, so fall back to bind-address + extras.
2576
+ const override = Deno.env.get("1TUBE_EXPOSE_ERRORS");
2577
+ const expose = override === "1" || override === "true"
2578
+ ? true
2579
+ : override === "0" || override === "false"
2580
+ ? false
2581
+ : opts.dev && isLocalHostname(opts.host);
2582
+ if (!expose) return new Response("Internal error", { status: 500 });
2583
+ const e = err instanceof Error ? err : new Error(String(err));
2584
+ return Response.json(
2585
+ { error: "Internal error", message: e.message, stack: e.stack, name: e.name },
2586
+ { status: 500 },
2587
+ );
2464
2588
  },
2465
2589
  },
2466
2590
  app.fetch,