|
1 | 1 | import { Hono } from "@hono/hono"; |
2 | 2 | import { broadcast } from "./sse/channel.ts"; |
| 3 | +import { SANDBOX_MODE } from "./daemon.ts"; |
| 4 | +import { delay } from "../utils/async.ts"; |
3 | 5 | import { DenoRun } from "./workers/denoRun.ts"; |
4 | 6 |
|
| 7 | +// How long a request waits for a cold dev server to come up before we stop |
| 8 | +// holding the connection open and reply 503 (SANDBOX_MODE only). Kept well |
| 9 | +// under the CDN origin timeout so the env ingress can bounce to the activator |
| 10 | +// and retry, instead of the request hanging until the CDN returns a 504. |
| 11 | +const SANDBOX_READY_GATE_MS = 5_000; |
| 12 | + |
| 13 | +// Header + per-process token used by the daemon's own internal warmup request |
| 14 | +// to bypass the fast-503 gate (it must block until the worker is ready so it |
| 15 | +// can actually render the entry route). The token is random per process so a |
| 16 | +// forged `x-deco-warmup` header on public traffic can't match and is treated |
| 17 | +// as a normal request. |
| 18 | +export const WARMUP_HEADER = "x-deco-warmup"; |
| 19 | +export const WARMUP_TOKEN: string = crypto.randomUUID(); |
| 20 | + |
5 | 21 | export interface WorkerOptions { |
6 | 22 | persist: () => void; |
7 | 23 | command: Deno.Command; |
@@ -97,7 +113,47 @@ export const createWorker = (optionsProvider: WorkerOptionsProvider) => { |
97 | 113 | // ensure isolate is up and running |
98 | 114 | app.use("/*", async (c, next) => { |
99 | 115 | try { |
100 | | - await worker(); |
| 116 | + // Warmup requests (and normal, non-sandbox mode) keep the original |
| 117 | + // blocking behavior: wait for the worker to be fully ready, then proxy. |
| 118 | + // This is what lets the warmup request actually JIT-compile and render |
| 119 | + // the entry route — a fast-503 gate would skip the render entirely. |
| 120 | + const isWarmup = c.req.header(WARMUP_HEADER) === WARMUP_TOKEN; |
| 121 | + if (SANDBOX_MODE && !isWarmup) { |
| 122 | + // worker() boots the dev server (idempotent) and resolves once it is |
| 123 | + // listening; it rejects if the boot fails (missing dev.ts, crash, ...). |
| 124 | + // On a cold sandbox the boot can take a while, so rather than hold the |
| 125 | + // request open the whole time — which lets the CDN time out as a 504 — |
| 126 | + // we race it against a short gate: |
| 127 | + // - still booting at the gate -> 503 (retryable): the env ingress |
| 128 | + // (error_page 502 503 -> @admin) bounces to the activator and the |
| 129 | + // boot keeps running in the background, so the retry lands warm. |
| 130 | + // - boot rejected -> 424 (non-retryable): a real failure |
| 131 | + // that won't fix itself, so we don't loop the activator forever. |
| 132 | + // Mapping the rejection inline (not throwing) keeps the loser of the |
| 133 | + // race from surfacing as an unhandled rejection once we've replied. |
| 134 | + const boot = worker().then( |
| 135 | + () => "ready" as const, |
| 136 | + (err: unknown) => ({ err }), |
| 137 | + ); |
| 138 | + const outcome = await Promise.race([ |
| 139 | + boot, |
| 140 | + delay(SANDBOX_READY_GATE_MS).then(() => "timeout" as const), |
| 141 | + ]); |
| 142 | + if (outcome === "timeout") { |
| 143 | + c.res = new Response("Sandbox environment is starting", { |
| 144 | + status: 503, |
| 145 | + headers: { "retry-after": "2" }, |
| 146 | + }); |
| 147 | + return; |
| 148 | + } |
| 149 | + if (typeof outcome === "object") { |
| 150 | + console.error(outcome.err); |
| 151 | + c.res = new Response(`Error while starting worker`, { status: 424 }); |
| 152 | + return; |
| 153 | + } |
| 154 | + } else { |
| 155 | + await worker(); |
| 156 | + } |
101 | 157 | await next(); |
102 | 158 | } catch (error) { |
103 | 159 | console.error(error); |
|
0 commit comments