Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ This is the source code for https://jsr.io, the new JavaScript registry.
Cloudflare Workers worker (the LB)
- Module, package metadata, and npm tarballs are served directly from R2
- /api requests are proxied to the management API
- All other requests are proxied to the frontend Worker via a service
binding (no public URL hop)
- All other requests are proxied to the frontend Worker via a service binding
(no public URL hop)
- Data is stored in PostgreSQL (using Google Cloud SQL)
- The database is highly available
- Not used for serving registry requests
Expand Down
22 changes: 11 additions & 11 deletions architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,15 +131,15 @@ JavaScript to the browser.

In production the frontend ships as its own Cloudflare Worker:

- `frontend/build.ts` runs the Fresh vite build, mirrors the merged static
tree (`frontend/static/`, `_fresh/{client,static}/`, and `frontend/docs/*.md`
under `_jsr_docs/`) into `_fresh/assets/`, then bundles
`frontend/server.entry.ts` into `_fresh/worker.js` via `deno bundle`.
- `frontend/shim/deno.ts` polyfills the subset of `Deno.*` (env, file
reads, inspect, stat/open) needed by the Fresh server at runtime, with
file reads forwarded to the Workers ASSETS binding.
- Static files are served by the Workers Assets binding (asset-first
routing). Dynamic routes fall through to the worker.
- `frontend/build.ts` runs the Fresh vite build, mirrors the merged static tree
(`frontend/static/`, `_fresh/{client,static}/`, and `frontend/docs/*.md` under
`_jsr_docs/`) into `_fresh/assets/`, then bundles `frontend/server.entry.ts`
into `_fresh/worker.js` via `deno bundle`.
- `frontend/shim/deno.ts` polyfills the subset of `Deno.*` (env, file reads,
inspect, stat/open) needed by the Fresh server at runtime, with file reads
forwarded to the Workers ASSETS binding.
- Static files are served by the Workers Assets binding (asset-first routing).
Dynamic routes fall through to the worker.

Environment variables (`API_ROOT`, `FRONTEND_ROOT`, the `ORAMA_*` keys, etc.)
are configured as `plain_text` bindings on the worker by Terraform; no
Expand Down Expand Up @@ -194,8 +194,8 @@ A Cloudflare Worker that acts as the edge router. See
Key files:

- `main.ts` — request routing logic
- `proxy.ts` — proxy to backends (Cloud Run for API, service binding for
the frontend Worker) and R2
- `proxy.ts` — proxy to backends (Cloud Run for API, service binding for the
frontend Worker) and R2
- `headers.ts` — security headers, CORS, CSP
- `bots.ts` — bot/crawler detection
- `analytics.ts` — download tracking
Expand Down
4 changes: 4 additions & 0 deletions lb/local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,10 @@ function handler(req: Request): Promise<Response> {
ROOT_DOMAIN,
API_DOMAIN,
NPM_DOMAIN,
}, {
waitUntil(_promise: Promise<unknown>) {
// do nothing
},
});
}

Expand Down
33 changes: 22 additions & 11 deletions lb/main.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright 2024 the JSR authors. All rights reserved. MIT license.

import type { WorkerEnv } from "./types.ts";
import { proxyToBackend, proxyToR2 } from "./proxy.ts";
import { type ExecutionCtx, proxyToBackend, proxyToR2 } from "./proxy.ts";
import {
handleCORSPreflight,
isCORSPreflight,
Expand All @@ -22,9 +22,10 @@ export default {
async fetch(
request: Request,
env: WorkerEnv,
ctx: ExecutionCtx,
): Promise<Response> {
try {
const response = await route(request, env);
const response = await route(request, env, ctx);
return response;
} catch (error) {
console.error("LB error:", error);
Expand All @@ -42,16 +43,17 @@ export default {
export async function route(
request: Request,
env: WorkerEnv,
ctx?: ExecutionCtx,
): Promise<Response> {
const url = new URL(request.url);
const hostname = url.hostname.toLowerCase();

if (hostname === env.API_DOMAIN) {
return await handleAPIRequest(request, env);
return await handleAPIRequest(request, env, true, ctx);
} else if (hostname === env.NPM_DOMAIN) {
return await handleNPMRequest(request, env);
return await handleNPMRequest(request, env, ctx);
} else if (hostname === env.ROOT_DOMAIN) {
return await handleRootRequest(request, env);
return await handleRootRequest(request, env, ctx);
} else {
return new Response(`Unknown hostname: ${hostname}`, {
status: 404,
Expand All @@ -66,6 +68,7 @@ export async function handleAPIRequest(
request: Request,
env: WorkerEnv,
rewritePath: boolean = true,
ctx?: ExecutionCtx,
): Promise<Response> {
if (isCORSPreflight(request)) {
return handleCORSPreflight(API);
Expand All @@ -75,6 +78,7 @@ export async function handleAPIRequest(
request,
env.REGISTRY_API_URL,
rewritePath ? (path) => `/api${path}` : undefined,
ctx,
);

setSecurityHeaders(response, API);
Expand All @@ -89,6 +93,7 @@ export async function handleAPIRequest(
export async function handleNPMRequest(
request: Request,
env: WorkerEnv,
ctx?: ExecutionCtx,
): Promise<Response> {
if (isCORSPreflight(request)) {
return handleCORSPreflight(NPM);
Expand All @@ -104,6 +109,7 @@ export async function handleNPMRequest(
}
return path;
},
ctx,
);

setSecurityHeaders(response, NPM);
Expand Down Expand Up @@ -155,22 +161,23 @@ export async function handleNPMRequest(
export async function handleRootRequest(
request: Request,
env: WorkerEnv,
ctx?: ExecutionCtx,
): Promise<Response> {
const url = new URL(request.url);
const path = url.pathname;

if (isAPIRoute(path)) {
return await handleAPIRequest(request, env, false);
return await handleAPIRequest(request, env, false, ctx);
} else if (isBot(request)) {
return await handleFrontendRoute(request, env, true);
return await handleFrontendRoute(request, env, true, ctx);
} else if (path.startsWith("/@")) {
if (canAccessModuleFile(request) && isModuleFilePath(path)) {
return await handleModuleFileRoute(request, env);
return await handleModuleFileRoute(request, env, ctx);
} else {
return await handleFrontendRoute(request, env, false);
return await handleFrontendRoute(request, env, false, ctx);
}
} else {
return await handleFrontendRoute(request, env, false);
return await handleFrontendRoute(request, env, false, ctx);
}
}

Expand Down Expand Up @@ -221,11 +228,12 @@ async function handleFrontendRoute(
request: Request,
env: WorkerEnv,
isBot: boolean,
ctx?: ExecutionCtx,
): Promise<Response> {
const limited = await rateLimitGuard(request, env);
if (limited) return limited;

const response = await proxyToBackend(request, env.FRONTEND);
const response = await proxyToBackend(request, env.FRONTEND, undefined, ctx);

setSecurityHeaders(response, FRONTEND);
setDebugHeaders(response, {
Expand Down Expand Up @@ -265,11 +273,14 @@ async function rateLimitGuard(
async function handleModuleFileRoute(
request: Request,
env: WorkerEnv,
ctx?: ExecutionCtx,
): Promise<Response> {
const url = new URL(request.url);
const response = await proxyToR2(
request,
env.MODULES_BUCKET,
undefined,
ctx,
);

setSecurityHeaders(response, MODULES);
Expand Down
86 changes: 80 additions & 6 deletions lb/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,66 @@

import type { PartialBucket } from "./types.ts";

// Minimal slice of Cloudflare's `ExecutionContext` we depend on. Cache writes
// (`Cache.put`) finish *after* the response is returned to the client; without
// registering them via `waitUntil`, the Workers runtime tears the invocation
// down first and silently drops the write — so nothing ever gets cached. See
// https://developers.cloudflare.com/workers/runtime-apis/cache/#put
export interface ExecutionCtx {
waitUntil(promise: Promise<unknown>): void;
}

// Persist a cache write so it survives past the response. With an execution
// context we hand it to `waitUntil`; without one (unit tests) we await it so
// the write still completes deterministically. A failed write (sync throw or
// async rejection) is logged and swallowed — caching is best-effort and must
// never break serving the response.
async function persistCacheWrite(
ctx: ExecutionCtx | undefined,
cache: Cache,
key: Request,
response: Response,
): Promise<void> {
let write: Promise<unknown>;
try {
write = cache.put(key, response);
} catch (error) {
console.error("cache write failed:", error);
return;
}
const guarded = write.catch((error) => {
console.error("cache write failed:", error);
});
if (ctx) {
ctx.waitUntil(guarded);
} else {
await guarded;
}
}

// Cache key for a bucket (R2) response. `caches.default` is shared across all
// backends, and a `/@scope/...` URL is served as EITHER a module file (bucket,
// JSON) or an HTML page (frontend) depending on request headers — keying both
// on the raw URL cross-serves HTML for module files (and vice versa). Bucket
// entries are namespaced under a synthetic, non-routable host (which no real
// request can ever target, so it can't be poisoned) keyed by the original host
// + path so module and npm buckets also stay distinct.
function bucketCacheKey(rawUrl: string): Request {
const u = new URL(rawUrl);
return new Request(
`https://bucket-cache.jsr.internal/${u.host}${u.pathname}${u.search}`,
{ method: "GET" },
);
}

// Proxies an inbound request to a backend. The backend can be either an
// HTTP URL (Cloud Run API) or a service-binding Fetcher (frontend Worker).
// In both cases the caller receives the same cache + header semantics.
export async function proxyToBackend(
request: Request,
backend: string | { fetch: (req: Request) => Promise<Response> },
pathRewrite?: (path: string) => string,
ctx?: ExecutionCtx,
): Promise<Response> {
const url = new URL(request.url);
let path = url.pathname;
Expand Down Expand Up @@ -62,6 +115,7 @@ export async function proxyToBackend(
body: request.body,
redirect: "manual",
},
ctx,
);

const res = new Response(response.body, {
Expand Down Expand Up @@ -92,6 +146,7 @@ export async function proxyToR2(
request: Request,
bucket: PartialBucket,
pathRewrite?: (path: string) => string,
ctx?: ExecutionCtx,
): Promise<Response> {
const url = new URL(request.url);
let path = url.pathname;
Expand All @@ -100,16 +155,28 @@ export async function proxyToR2(
}
const key = decodeURIComponent(path.slice(1));

const cacheKey = new Request(request.url, { method: "GET" });
const cached = await caches.default?.match(cacheKey);
const cacheKey = bucketCacheKey(request.url);
let cached: Response | undefined;
try {
cached = await caches.default?.match(cacheKey);
} catch (error) {
// A corrupt/unreadable cache entry must never break the request.
console.error("R2 cache match error:", error);
}
if (cached) {
if (request.method === "HEAD") {
return new Response(null, {
headers: cached.headers,
status: cached.status,
});
}
return cached;
// Re-wrap: responses from `caches.default.match` have immutable headers,
// and callers (e.g. setSecurityHeaders) mutate the returned response's
// headers — mutating the cached response directly throws.
return new Response(cached.body, {
headers: cached.headers,
status: cached.status,
});
}

try {
Expand Down Expand Up @@ -142,7 +209,10 @@ export async function proxyToR2(
}

const response = new Response(object.body, { headers });
caches.default?.put(cacheKey, response.clone());
const cache = caches.default;
if (cache) {
await persistCacheWrite(ctx, cache, cacheKey, response.clone());
}
return response;
}
} catch (error) {
Expand All @@ -159,6 +229,7 @@ async function cachedFetch(
fetcher: (req: Request) => Promise<Response>,
input: RequestInfo | URL,
init?: RequestInit,
ctx?: ExecutionCtx,
): Promise<Response> {
const req = new Request(input, init);

Expand All @@ -178,14 +249,17 @@ async function cachedFetch(

const res = await fetcher(req);

if (shouldCache && req.method === "GET" && res.ok) {
const cache = caches.default;
if (cache && shouldCache && req.method === "GET" && res.ok) {
const cacheControl = res.headers.get("Cache-Control") ?? "";
if (
!cacheControl.includes("private") &&
!cacheControl.includes("no-store")
) {
const cacheKey = new Request(req.url, { method: "GET" });
caches.default?.put(cacheKey, res.clone());
// `waitUntil` (or await in tests) so the write isn't dropped when the
// invocation ends — the cause of the lb caching nothing in production.
await persistCacheWrite(ctx, cache, cacheKey, res.clone());
}
}

Expand Down
Loading
Loading