Keep frontend assets available across deployments and gate cutovers
18 files+577−90/18 viewed
| 69 | 69 | ||
| 70 | 70 | ## The tool | |
| 71 | 71 | ||
| 72 | + | Web releases retain fingerprinted JavaScript, CSS and fonts in the existing | |
| 73 | + | `DOWNLOADS` R2 bucket under `web-assets/assets/`. Before switching the Worker, | |
| 74 | + | the deploy tool ensures the currently live site's asset graph and the new | |
| 75 | + | build's assets are archived. Release manifests under `web-assets/manifests/` | |
| 76 | + | let later deploys skip unchanged files without scanning deployment history or | |
| 77 | + | fetching the entire live build again. Provider rate limits retry with a bound; | |
| 78 | + | an archive failure stops the web cutover. A confirmed first deployment skips | |
| 79 | + | the live-site copy. The deploy credential needs object read and write access | |
| 80 | + | to this bucket. | |
| 81 | + | ||
| 82 | + | Every web build also runs the compiled Worker locally in workerd. The gate | |
| 83 | + | checks old CSS through R2, missing assets, and old-client navigation with an | |
| 84 | + | existing cached response before any backend loaders run. Upload receipts | |
| 85 | + | must match the local asset checksums before cutover is allowed. | |
| 86 | + | ||
| 87 | + | Current files still use Workers Static Assets. Requests for older filenames | |
| 88 | + | fall back to R2, so an open tab can finish loading after a release. Archived | |
| 89 | + | files are immutable and retained without automatic deletion; do not apply an | |
| 90 | + | expiration lifecycle rule to this prefix. Assets already removed before the | |
| 91 | + | first protected deployment cannot be recovered by this archive. | |
| 92 | + | ||
| 93 | + | The public HTML/data cache is separated by build. On navigation, a tab from | |
| 94 | + | an older build gets a document navigation to its destination before route | |
| 95 | + | loaders run. Background refreshes do not force a reload, and writes are not | |
| 96 | + | replayed. Service APIs must remain compatible with existing clients during | |
| 97 | + | rollout. Use the deploy tool or the web package's `npm run deploy`; invoking | |
| 98 | + | `wrangler deploy` directly bypasses the asset archive step. | |
| 99 | + | ||
| 100 | + | After cutover, the tool checks the live build identifier and the bytes of | |
| 101 | + | both old and new stylesheets, client entries and route manifests. A failed | |
| 102 | + | check marks the deploy failed; it does not automatically roll back service | |
| 103 | + | code or database migrations. The production workflow serializes deploys | |
| 104 | + | without cancelling an active release. Manual deploys must not race that | |
| 105 | + | workflow. Roll back only to web versions that include the archive fallback; | |
| 106 | + | older versions would remove that protection. Database changes must be | |
| 107 | + | additive during rollout, with removals deferred until old callers are gone. | |
| 108 | + | ||
| 72 | 109 | ```sh | |
| 73 | 110 | node scripts/deploy.mjs plan # what would deploy, and why (read-only) | |
| 74 | 111 | node scripts/deploy.mjs deploy # migrations, then every changed unit |
| 1 | − | import { useEffect, useState } from "react"; | |
| 2 | − | import { useNavigation } from "react-router"; | |
| 1 | + | import { useContext, useEffect, useState } from "react"; | |
| 2 | + | import { UNSAFE_DataRouterContext, useNavigation } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import { stuckTarget } from "../lib/stuck"; | |
| 5 | + | import { watchBuildNavigation } from "../lib/resilient-fetch"; | |
| 5 | 6 | import { Button } from "./ui/button"; | |
| 6 | 7 | ||
| 7 | 8 | /** A document load of the destination, while the router still shows the old page. */ | |
| 8 | 9 | export function NavigationRecovery() { | |
| 10 | + | const context = useContext(UNSAFE_DataRouterContext); | |
| 11 | + | useEffect(() => watchBuildNavigation(() => context?.router.state.navigation.location), [context?.router]); | |
| 9 | 12 | const navigation = useNavigation(); | |
| 10 | 13 | const target = stuckTarget(navigation); | |
| 11 | 14 | const key = navigation.location?.key; |
| 7 | 7 | import { installResilientFetch } from "./lib/resilient-fetch"; | |
| 8 | 8 | ||
| 9 | 9 | // The router's reads outlast a moment offline (lib/resilient-fetch.ts). | |
| 10 | − | installResilientFetch(); | |
| 10 | + | installResilientFetch(import.meta.env.G1T_BUILD_ID); | |
| 11 | 11 | ||
| 12 | 12 | startTransition(() => { | |
| 13 | 13 | hydrateRoot( |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | import { archivedAssetKey, serveArchivedAsset } from "./asset-archive.ts"; | |
| 4 | + | ||
| 5 | + | test("archive keys cannot read releases, source maps or private objects", () => { | |
| 6 | + | assert.equal(archivedAssetKey("/assets/app-old12345.css"), "web-assets/assets/app-old12345.css"); | |
| 7 | + | for (const path of ["/downloads/runner", "/assets/../secret.js", "/assets/a.js.map", "/assets/%2e%2e/secret.js", "/assets/nested/a.js"]) assert.equal(archivedAssetKey(path), null); | |
| 8 | + | }); | |
| 9 | + | ||
| 10 | + | test("a stylesheet removed from the new build stays available with its MIME type", async () => { | |
| 11 | + | const bucket = { get: async (key: string) => { | |
| 12 | + | assert.equal(key, "web-assets/assets/app-old12345.css"); | |
| 13 | + | return { body: "body{color:red}", size: 15, httpEtag: '"old"', writeHttpMetadata: (headers: Headers) => headers.set("content-type", "text/css") }; | |
| 14 | + | } } as unknown as R2Bucket; | |
| 15 | + | const url = "https://g1t.sh/assets/app-old12345.css"; | |
| 16 | + | const response = await serveArchivedAsset(new Request(url), bucket); | |
| 17 | + | assert.equal(response.status, 200); | |
| 18 | + | assert.equal(response.headers.get("content-type"), "text/css"); | |
| 19 | + | assert.match(response.headers.get("cache-control")!, /immutable/); | |
| 20 | + | assert.equal(await response.text(), "body{color:red}"); | |
| 21 | + | assert.equal(await (await serveArchivedAsset(new Request(url, { method: "HEAD" }), bucket)).text(), ""); | |
| 22 | + | assert.equal((await serveArchivedAsset(new Request(url, { headers: { "if-none-match": '"old"' } }), bucket)).status, 304); | |
| 23 | + | }); | |
| 24 | + | ||
| 25 | + | test("missing or unavailable assets never become HTML or cacheable errors", async () => { | |
| 26 | + | const url = "https://g1t.sh/assets/missing-12345678.js"; | |
| 27 | + | const missing = await serveArchivedAsset(new Request(url), { get: async () => null } as unknown as R2Bucket); | |
| 28 | + | assert.equal(missing.status, 404); | |
| 29 | + | assert.equal(missing.headers.get("cache-control"), "no-store"); | |
| 30 | + | const down = await serveArchivedAsset(new Request(url), undefined); | |
| 31 | + | assert.equal(down.status, 503); | |
| 32 | + | assert.equal(down.headers.get("cache-control"), "no-store"); | |
| 33 | + | }); |
| 1 | + | /** Only built public assets can be read from the release bucket. */ | |
| 2 | + | export function archivedAssetKey(pathname: string): string | null { | |
| 3 | + | if (!/^\/assets\/[a-zA-Z0-9_.~-]+\.(?:js|css|woff2?|ttf|otf|png|jpe?g|gif|svg|webp|ico|wasm)$/.test(pathname)) return null; | |
| 4 | + | return `web-assets${pathname}`; | |
| 5 | + | } | |
| 6 | + | ||
| 7 | + | /** Current assets are served by Cloudflare before the Worker; misses land here. */ | |
| 8 | + | export async function serveArchivedAsset(request: Request, bucket: Pick<R2Bucket, "get"> | undefined): Promise<Response> { | |
| 9 | + | const key = archivedAssetKey(new URL(request.url).pathname); | |
| 10 | + | if (!key || !["GET", "HEAD"].includes(request.method)) return new Response("Not found", { status: 404, headers: { "cache-control": "no-store" } }); | |
| 11 | + | try { | |
| 12 | + | if (!bucket) throw new Error("Asset archive is not configured"); | |
| 13 | + | const object = await bucket.get(key); | |
| 14 | + | if (!object) return new Response("Not found", { status: 404, headers: { "cache-control": "no-store" } }); | |
| 15 | + | const headers = new Headers(); | |
| 16 | + | object.writeHttpMetadata(headers); | |
| 17 | + | headers.set("etag", object.httpEtag); | |
| 18 | + | headers.set("cache-control", "public, max-age=31536000, immutable"); | |
| 19 | + | headers.set("x-content-type-options", "nosniff"); | |
| 20 | + | if (request.headers.get("if-none-match") === object.httpEtag) return new Response(null, { status: 304, headers }); | |
| 21 | + | headers.set("content-length", String(object.size)); | |
| 22 | + | return new Response(request.method === "HEAD" ? null : object.body, { headers }); | |
| 23 | + | } catch { | |
| 24 | + | return new Response("Asset temporarily unavailable", { status: 503, headers: { "cache-control": "no-store", "retry-after": "1" } }); | |
| 25 | + | } | |
| 26 | + | } |
| 1 | + | import { redirectDocument, type MiddlewareFunction } from "react-router"; | |
| 2 | + | import { needsBuildRefresh } from "./build-version"; | |
| 3 | + | import { pageOf } from "./confirm-gate"; | |
| 4 | + | ||
| 5 | + | export const buildVersionMiddleware: MiddlewareFunction<Response> = async ({ request }, next) => { | |
| 6 | + | if (needsBuildRefresh(request, import.meta.env.G1T_BUILD_ID)) { | |
| 7 | + | const url = new URL(request.url); | |
| 8 | + | url.searchParams.delete("_routes"); | |
| 9 | + | throw redirectDocument(pageOf(url.pathname) + url.search, { headers: { "cache-control": "no-store" } }); | |
| 10 | + | } | |
| 11 | + | return next(); | |
| 12 | + | }; |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | import { BUILD_HEADER, needsBuildRefresh } from "./build-version.ts"; | |
| 4 | + | ||
| 5 | + | test("old navigation reads switch builds before loaders; writes and ordinary documents do not", () => { | |
| 6 | + | const request = (path: string, method = "GET", build = "old") => new Request(`https://g1t.sh${path}`, { method, headers: { [BUILD_HEADER]: build } }); | |
| 7 | + | assert.equal(needsBuildRefresh(request("/flagon-io/-/home.data"), "new"), true); | |
| 8 | + | assert.equal(needsBuildRefresh(request("/flagon-io/-/home.data", "GET", "new"), "new"), false); | |
| 9 | + | assert.equal(needsBuildRefresh(request("/flagon-io/-/home.data", "POST"), "new"), false); | |
| 10 | + | assert.equal(needsBuildRefresh(request("/flagon-io/-/home"), "new"), false); | |
| 11 | + | assert.equal(needsBuildRefresh(new Request("https://g1t.sh/a.data"), "new"), false); | |
| 12 | + | }); |
| 1 | + | export const BUILD_HEADER = "x-g1t-build"; | |
| 2 | + | ||
| 3 | + | /** Only safe route reads can switch builds automatically; never replay writes. */ | |
| 4 | + | export function needsBuildRefresh(request: Request, build: string): boolean { | |
| 5 | + | const requested = request.headers.get(BUILD_HEADER); | |
| 6 | + | return request.method === "GET" && new URL(request.url).pathname.endsWith(".data") && requested !== null && requested !== build; | |
| 7 | + | } |
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | 2 | import test from "node:test"; | |
| 3 | 3 | ||
| 4 | − | import { PATIENCE_MS, REFRESH_PATIENCE_MS, isNetworkFailure, keepTrying, refreshesCurrent, retriable, retryDelay } from "./resilient-fetch.ts"; | |
| 4 | + | import { PATIENCE_MS, REFRESH_PATIENCE_MS, installResilientFetch, isNetworkFailure, keepTrying, refreshesCurrent, retriable, retryDelay, watchBuildNavigation } from "./resilient-fetch.ts"; | |
| 5 | 5 | import { reconnectDelay } from "./reconnect.ts"; | |
| 6 | 6 | ||
| 7 | 7 | const ORIGIN = "https://g1t.sh"; | |
| 8 | 8 | const url = (path: string) => new URL(path, ORIGIN); | |
| 9 | 9 | ||
| 10 | + | test("only an active navigation carries its build; fetchers, refreshes and writes never force a document switch", async () => { | |
| 11 | + | const calls: Headers[] = []; | |
| 12 | + | const previous = Object.getOwnPropertyDescriptor(globalThis, "window"); | |
| 13 | + | const browser = { location: new URL("https://g1t.sh/edit"), fetch: async (_input: unknown, init?: RequestInit) => { calls.push(new Headers(init?.headers)); return new Response(null); } }; | |
| 14 | + | Object.defineProperty(globalThis, "window", { value: browser, configurable: true }); | |
| 15 | + | let destination: { pathname: string; search: string } | undefined; | |
| 16 | + | const stop = watchBuildNavigation(() => destination); | |
| 17 | + | try { | |
| 18 | + | installResilientFetch("old-build"); | |
| 19 | + | await browser.fetch("/notifications.data"); | |
| 20 | + | destination = { pathname: "/next", search: "?tab=one" }; | |
| 21 | + | await browser.fetch("/next.data?tab=one&_routes=root"); | |
| 22 | + | await browser.fetch("/unrelated.data"); | |
| 23 | + | await browser.fetch("/next.data?tab=one", { method: "POST" }); | |
| 24 | + | await browser.fetch("/edit.data"); | |
| 25 | + | await browser.fetch("https://external.example/next.data?tab=one"); | |
| 26 | + | assert.deepEqual(calls.map((headers) => headers.get("x-g1t-build")), [null, "old-build", null, null, null, null]); | |
| 27 | + | } finally { | |
| 28 | + | stop(); | |
| 29 | + | if (previous) Object.defineProperty(globalThis, "window", previous); | |
| 30 | + | else Reflect.deleteProperty(globalThis, "window"); | |
| 31 | + | } | |
| 32 | + | }); | |
| 33 | + | ||
| 10 | 34 | test("a request that got no answer, in each browser's words, is a network failure", () => { | |
| 11 | 35 | assert.equal(isNetworkFailure(new TypeError("Failed to fetch")), true); | |
| 12 | 36 | assert.equal(isNetworkFailure(new TypeError("NetworkError when attempting to fetch resource.")), true); |
| 22 | 22 | * sending it twice could do it twice. | |
| 23 | 23 | */ | |
| 24 | 24 | import { useSyncExternalStore } from "react"; | |
| 25 | + | import { BUILD_HEADER } from "./build-version.ts"; | |
| 25 | 26 | ||
| 26 | 27 | /** How long a read keeps trying; under lib/stuck.ts's `STUCK_NAVIGATION_MS`, so a click still lands on an error page that recovers rather than a whole-page load that cannot. */ | |
| 27 | 28 | export const PATIENCE_MS = 45_000; | |
| ⋯ | |||
| 156 | 157 | } | |
| 157 | 158 | ||
| 158 | 159 | let installed = false; | |
| 160 | + | type NavigationLocation = { pathname: string; search: string }; | |
| 161 | + | let navigationLocation: () => NavigationLocation | undefined = () => undefined; | |
| 159 | 162 | ||
| 163 | + | /** Read router state synchronously: React effects run after a navigation's fetch starts. */ | |
| 164 | + | export function watchBuildNavigation(read: () => NavigationLocation | undefined) { | |
| 165 | + | navigationLocation = read; | |
| 166 | + | return () => { if (navigationLocation === read) navigationLocation = () => undefined; }; | |
| 167 | + | } | |
| 168 | + | ||
| 160 | 169 | /** Wraps the browser's `fetch` so the router's reads survive a moment offline. Browser only; once. */ | |
| 161 | − | export function installResilientFetch() { | |
| 170 | + | export function installResilientFetch(build?: string) { | |
| 162 | 171 | if (installed || typeof window === "undefined") return; | |
| 163 | 172 | installed = true; | |
| 164 | 173 | const original = window.fetch.bind(window); | |
| ⋯ | |||
| 172 | 181 | } | |
| 173 | 182 | const method = init?.method ?? request?.method ?? "GET"; | |
| 174 | 183 | if (!retriable(url, method, window.location.origin)) return original(input, init); | |
| 184 | + | const refresh = refreshesCurrent(url, window.location); | |
| 185 | + | // Switch builds only on a navigation, never from a background refresh | |
| 186 | + | // that could interrupt someone editing the current page. | |
| 187 | + | const destination = navigationLocation(); | |
| 188 | + | if (build && !refresh && destination && refreshesCurrent(url, destination)) { | |
| 189 | + | const headers = new Headers(init?.headers ?? request?.headers); | |
| 190 | + | headers.set(BUILD_HEADER, build); | |
| 191 | + | init = { ...init, headers }; | |
| 192 | + | } | |
| 175 | 193 | const signal = init?.signal ?? request?.signal; | |
| 176 | − | const refresh = refreshesCurrent(url, window.location); | |
| 177 | 194 | const started = Date.now(); | |
| 178 | 195 | let onlineMs = 0; | |
| 179 | 196 | let counted = false; | |
| 100 | 100 | import { NavigationRecovery } from "./components/navigation-recovery"; | |
| 101 | 101 | import { isNetworkFailure } from "./lib/resilient-fetch"; | |
| 102 | 102 | import { useReconnect } from "./lib/reconnect"; | |
| 103 | + | import { buildVersionMiddleware } from "./lib/build-version.server"; | |
| 103 | 104 | ||
| 104 | 105 | export const links: Route.LinksFunction = () => [ | |
| 105 | 106 | { rel: "icon", href: "/favicon.ico", sizes: "32x32" }, | |
| ⋯ | |||
| 130 | 131 | }, | |
| 131 | 132 | ]; | |
| 132 | 133 | ||
| 133 | − | export const middleware: Route.MiddlewareFunction[] = [viewerMiddleware]; | |
| 134 | + | export const middleware: Route.MiddlewareFunction[] = [buildVersionMiddleware, viewerMiddleware]; | |
| 134 | 135 | ||
| 135 | 136 | export async function loader({ context, params, request }: Route.LoaderArgs) { | |
| 136 | 137 | const user = getViewer(context); | |
| 3 | 3 | "private": true, | |
| 4 | 4 | "type": "module", | |
| 5 | 5 | "scripts": { | |
| 6 | − | "build": "react-router build && node scripts/load-check.mjs", | |
| 6 | + | "build": "react-router build && node scripts/load-check.mjs && node scripts/deploy-check.mjs", | |
| 7 | 7 | "test": "node --test app/**/*.test.ts", | |
| 8 | 8 | "dev": "react-router dev", | |
| 9 | 9 | "typecheck": "wrangler types --include-env=false && react-router typegen && tsc -b --force", | |
| 10 | − | "deploy": "npm run build && wrangler deploy", | |
| 10 | + | "deploy": "npm run build && node ../../scripts/deploy/web-assets.mjs --deploy", | |
| 11 | 11 | "preview": "npm run build && vite preview", | |
| 12 | 12 | "cf-typegen": "wrangler types --include-env=false", | |
| 13 | 13 | "postinstall": "wrangler types --include-env=false" |
| 1 | + | // Exercise the built Worker in workerd before it can be deployed. Storage is | |
| 2 | + | // temporary and local; no service bindings or Cloudflare credentials are used. | |
| 3 | + | import assert from "node:assert/strict"; | |
| 4 | + | import { readFileSync, readdirSync } from "node:fs"; | |
| 5 | + | import { join, resolve } from "node:path"; | |
| 6 | + | import { Miniflare, convertV4MiniflareOptions } from "miniflare"; | |
| 7 | + | ||
| 8 | + | const root = resolve("build/server"); | |
| 9 | + | const config = JSON.parse(readFileSync(join(root, "wrangler.json"), "utf8")); | |
| 10 | + | const source = readFileSync(join(root, "index.js"), "utf8"); | |
| 11 | + | const build = source.match(/searchParams\.set\("_g1t_build", "([^"]+)"\)/)?.[1]; | |
| 12 | + | assert.ok(build, "The page cache must be separated by build"); | |
| 13 | + | const css = "body{color:red}"; | |
| 14 | + | // Seed R2 and the page cache through a test-only entry, keeping all R2 I/O | |
| 15 | + | // inside workerd. The application entry itself is the unmodified build. | |
| 16 | + | const seed = `import app from './index.js'; | |
| 17 | + | export default { async fetch(request, env, ctx) { | |
| 18 | + | if (new URL(request.url).pathname === '/__seed') { | |
| 19 | + | await env.DOWNLOADS.put('web-assets/assets/app-old12345.css', ${JSON.stringify(css)}, {httpMetadata:{contentType:'text/css'}}); | |
| 20 | + | const cache = new URL('/pricing.data', request.url); | |
| 21 | + | cache.searchParams.set('_g1t_build', ${JSON.stringify(build)}); | |
| 22 | + | await caches.default.put(new Request(cache), new Response('wrong-build-cache', {headers:{'cache-control':'public, max-age=300','x-g1t-kept-at':String(Date.now())}})); | |
| 23 | + | return new Response('seeded'); | |
| 24 | + | } | |
| 25 | + | return app.fetch(request, env, ctx); | |
| 26 | + | }};`; | |
| 27 | + | const files = [join(root, "index.js"), ...readdirSync(join(root, "assets")).filter((name) => name.endsWith(".js")).map((name) => join(root, "assets", name))]; | |
| 28 | + | const runtime = new Miniflare(convertV4MiniflareOptions({ | |
| 29 | + | name: "web-deploy-check", cf: false, modulesRoot: root, | |
| 30 | + | modules: [{ type: "ESModule", path: join(root, "deploy-check.mjs"), contents: seed }, ...files.map((path) => ({ type: "ESModule", path }))], | |
| 31 | + | compatibilityDate: config.compatibility_date, | |
| 32 | + | compatibilityFlags: config.compatibility_flags, | |
| 33 | + | r2Buckets: ["DOWNLOADS"], | |
| 34 | + | })); | |
| 35 | + | try { | |
| 36 | + | const origin = await runtime.ready; | |
| 37 | + | const request = (path, init) => fetch(new URL(path, origin), { ...init, signal: AbortSignal.timeout(15_000) }); | |
| 38 | + | assert.equal((await request("/__seed")).status, 200); | |
| 39 | + | const old = await request("/assets/app-old12345.css"); | |
| 40 | + | assert.equal(old.status, 200); | |
| 41 | + | assert.equal(old.headers.get("content-type"), "text/css"); | |
| 42 | + | assert.equal(await old.text(), css); | |
| 43 | + | const missing = await request("/assets/app-missing0.css"); | |
| 44 | + | assert.equal(missing.status, 404); | |
| 45 | + | assert.equal(missing.headers.get("cache-control"), "no-store"); | |
| 46 | + | assert.doesNotMatch(missing.headers.get("content-type") ?? "", /html/); | |
| 47 | + | for (const path of ["/pricing.data", "/flagon-io/-/home.data?tab=one&_routes=root"]) { | |
| 48 | + | const answer = await request(path, { headers: { "x-g1t-build": "previous-build" } }); | |
| 49 | + | assert.equal(answer.status, 202); | |
| 50 | + | const body = await answer.text(); | |
| 51 | + | assert.match(body, /reload/); | |
| 52 | + | assert.ok(body.includes(path.split(".data")[0])); | |
| 53 | + | assert.doesNotMatch(body, /_routes|wrong-build-cache/); | |
| 54 | + | } | |
| 55 | + | console.log("Deployment safety: old CSS survives, missing assets stay isolated, old navigations bypass cache and loaders."); | |
| 56 | + | } finally { | |
| 57 | + | await runtime.dispose(); | |
| 58 | + | } |
| 2 | 2 | import { cloudflare } from "@cloudflare/vite-plugin"; | |
| 3 | 3 | import tailwindcss from "@tailwindcss/vite"; | |
| 4 | 4 | import { defineConfig, type Plugin } from "vite"; | |
| 5 | + | import { randomUUID } from "node:crypto"; | |
| 5 | 6 | ||
| 6 | 7 | /** | |
| 7 | 8 | * The doc editor's chunk and everything it imports, so a doc's page can | |
| ⋯ | |||
| 44 | 45 | } | |
| 45 | 46 | ||
| 46 | 47 | export default defineConfig({ | |
| 48 | + | define: { "import.meta.env.G1T_BUILD_ID": JSON.stringify(randomUUID()) }, | |
| 47 | 49 | plugins: [ | |
| 48 | 50 | cloudflare({ viteEnvironment: { name: "ssr" } }), | |
| 49 | 51 | tailwindcss(), | |
| 16 | 16 | import { usercontentPath } from "../app/lib/usercontent"; | |
| 17 | 17 | import { TOKEN_CHALLENGE } from "../app/lib/website-token"; | |
| 18 | 18 | import { serveUsercontent } from "./usercontent"; | |
| 19 | + | import { serveArchivedAsset } from "../app/lib/asset-archive"; | |
| 20 | + | import { needsBuildRefresh } from "../app/lib/build-version"; | |
| 19 | 21 | ||
| 20 | 22 | const loadBuild = () => import("virtual:react-router/server-build"); | |
| 21 | 23 | ||
| ⋯ | |||
| 77 | 79 | ||
| 78 | 80 | async function site(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> { | |
| 79 | 81 | const { pathname } = new URL(request.url); | |
| 82 | + | // An old tab's chunk or stylesheet must not turn into an HTML route/404. | |
| 83 | + | if (pathname.startsWith("/assets/")) return serveArchivedAsset(request, env.DOWNLOADS); | |
| 80 | 84 | // Git over HTTPS shares this hostname but belongs to the repos service. | |
| 81 | 85 | // Its answer goes back to the git client as it is: a repository under a | |
| 82 | 86 | // renamed workspace's old name answers with a 301, which git follows and | |
| ⋯ | |||
| 142 | 146 | ||
| 143 | 147 | function anonymousPage(request: Request, pathname: string): boolean { | |
| 144 | 148 | if (request.method !== "GET") return false; | |
| 149 | + | // A cached data response must not bypass the build-switch middleware. | |
| 150 | + | if (needsBuildRefresh(request, import.meta.env.G1T_BUILD_ID)) return false; | |
| 145 | 151 | if (/(?:^|;\s*)g1t_session=/.test(request.headers.get("cookie") ?? "")) return false; | |
| 146 | 152 | // A token signs the request in (app/lib/website-token.ts): never kept, never served a kept page. | |
| 147 | 153 | if (request.headers.has("authorization")) return false; | |
| ⋯ | |||
| 152 | 158 | const cache = (caches as unknown as { default: Cache }).default; | |
| 153 | 159 | // Visitors asked about analytics first get a page that says so, kept apart. | |
| 154 | 160 | const keyUrl = new URL(request.url); | |
| 161 | + | // Cached HTML and route data belong to the build that rendered them. | |
| 162 | + | keyUrl.searchParams.set("_g1t_build", import.meta.env.G1T_BUILD_ID); | |
| 155 | 163 | if (visitorAsksFirst(request)) keyUrl.searchParams.set("_g1t_consent", "1"); | |
| 156 | 164 | const key = new Request(keyUrl, { method: "GET" }); | |
| 157 | 165 | const repository = PUBLIC_PROJECT.test(new URL(request.url).pathname) ? repositoryOfPage(new URL(request.url).pathname) : null; | |
| 68 | 68 | import { reportDeployment } from "./deploy/report.mjs"; | |
| 69 | 69 | import { ROOT, byStage, codeStages, containersRollout, findWranglerConfigs, npmCiArgs, npmWorkspace, pick, problems, resolvedStack, waves } from "./deploy/stack.mjs"; | |
| 70 | 70 | import { withDeployWindow } from "./deploy/status-window.mjs"; | |
| 71 | + | import { publishWebAssets, verifyWebAssets } from "./deploy/web-assets.mjs"; | |
| 71 | 72 | ||
| 72 | 73 | const USAGE = "usage: node scripts/deploy.mjs plan|deploy|build|migrate|manifest|doctor|install|build-base|image [--all] [--only a,b] [--skip a,b] [--force] [--rollback] [--concurrency N] [--stage S] [--json]"; | |
| 73 | 74 | ||
| ⋯ | |||
| 350 | 351 | const built = await exec("npm run build", [], { cwd, onLine: out.line, shell: true, env: wranglerEnv() }); | |
| 351 | 352 | if (built.code !== 0) throw new Error(`npm run build failed:\n${lastLines(built.out)}`); | |
| 352 | 353 | } | |
| 354 | + | const webRelease = unit.id === "web" && !dryRun ? await publishWebAssets(cwd, { onLine: out.line, seedLive: !decision.missing }) : null; | |
| 353 | 355 | const args = ["deploy"]; | |
| 354 | 356 | if (dryRun) { | |
| 355 | 357 | args.push("--dry-run", "--outdir", join(tmpdir(), "g1t-deploy", "dist", unit.id)); | |
| ⋯ | |||
| 374 | 376 | deployed = await wrangler(args, { cwd, onLine: out.line }); | |
| 375 | 377 | } | |
| 376 | 378 | if (deployed.code !== 0) throw new Error(`wrangler deploy failed:\n${wranglerError(deployed.out)}`); | |
| 379 | + | if (webRelease) { | |
| 380 | + | await verifyWebAssets(webRelease); | |
| 381 | + | out.line("Web cutover verified: current and previous build assets respond"); | |
| 382 | + | } | |
| 377 | 383 | result.version = dryRun ? "(dry run)" : versionFrom(deployed.out); | |
| 378 | 384 | result.ok = true; | |
| 379 | 385 | if (unit.image && !dryRun) await reportContainers(unit, cwd, out); | |
| 1 | + | // Public, fingerprinted files outlive a Worker release. Upload before cutover; | |
| 2 | + | // never delete or overwrite a filename with different bytes. | |
| 3 | + | import { createHash, randomUUID } from "node:crypto"; | |
| 4 | + | import { readFile, readdir } from "node:fs/promises"; | |
| 5 | + | import { join, resolve } from "node:path"; | |
| 6 | + | import { pathToFileURL } from "node:url"; | |
| 7 | + | import { archivedAssetKey } from "../../apps/web/app/lib/asset-archive.ts"; | |
| 8 | + | import { cloudflareAuth, jsonFrom, wrangler, wranglerEnv } from "./cloudflare.mjs"; | |
| 9 | + | ||
| 10 | + | const TYPES = { js: "text/javascript", css: "text/css", woff: "font/woff", woff2: "font/woff2", ttf: "font/ttf", otf: "font/otf", svg: "image/svg+xml", png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", ico: "image/x-icon", wasm: "application/wasm" }; | |
| 11 | + | ||
| 12 | + | export function assetReferences(text) { | |
| 13 | + | return [...new Set([...text.matchAll(/(?:\/?assets\/|["'(]\.\/)([a-zA-Z0-9_.~-]+\.(?:js|css|woff2?|ttf|otf|png|jpe?g|gif|svg|webp|ico|wasm))\b/g)].map((match) => match[1]))] | |
| 14 | + | // Vite's emitted filenames have an eight-character content hash. | |
| 15 | + | // Libraries can also contain unused relative defaults like elk-worker.min.js. | |
| 16 | + | .filter((name) => /-[a-zA-Z0-9_-]{8}\.[a-z0-9]+$/.test(name)); | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | async function together(items, run) { | |
| 20 | + | let index = 0; | |
| 21 | + | const results = await Promise.allSettled(Array.from({ length: Math.min(6, items.length) }, async () => { | |
| 22 | + | for (let i = index++; i < items.length; i = index++) await run(items[i]); | |
| 23 | + | })); | |
| 24 | + | const failed = results.find((result) => result.status === "rejected"); | |
| 25 | + | if (failed) throw failed.reason; | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | /** Bounded provider retries: archive trouble delays cutover, never skips it. */ | |
| 29 | + | export function retryingFetch(fetchImpl = fetch, wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) { | |
| 30 | + | return async (url, options = {}) => { | |
| 31 | + | for (let attempt = 0; ; attempt++) { | |
| 32 | + | const response = await fetchImpl(url, { ...options, signal: AbortSignal.timeout(60_000) }); | |
| 33 | + | if (![429, 500, 502, 503, 504].includes(response.status) || attempt === 6) return response; | |
| 34 | + | const seconds = Number(response.headers.get("retry-after")); | |
| 35 | + | await response.body?.cancel(); | |
| 36 | + | await wait(response.status === 429 ? Math.min(60_000, Math.max(1000, seconds * 1000 || 60_000)) : Math.min(1000 * 2 ** attempt, 30_000)); | |
| 37 | + | } | |
| 38 | + | }; | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | const checksum = (bytes) => `"${createHash("md5").update(bytes).digest("hex")}"`; | |
| 42 | + | const objectUrl = (account, bucket, key) => `https://api.cloudflare.com/client/v4/accounts/${account}/r2/buckets/${bucket}/objects/${key}`; | |
| 43 | + | const manifestName = (names) => names.find((name) => /^manifest-[a-zA-Z0-9_-]{8}\.js$/.test(name)); | |
| 44 | + | ||
| 45 | + | async function confirmUpload(response, bytes, label) { | |
| 46 | + | if (!response.ok) { await response.body?.cancel(); throw new Error(`Asset archive upload failed (${response.status}): ${label}`); } | |
| 47 | + | let receipt; | |
| 48 | + | try { receipt = await response.json(); } catch { throw new Error(`Invalid asset upload receipt: ${label}`); } | |
| 49 | + | if (receipt.success !== true || `"${receipt.result?.etag?.replaceAll('"', "")}"` !== checksum(bytes)) throw new Error(`Asset upload checksum mismatch: ${label}`); | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | /** One manifest per release: work never grows with the number of old releases. */ | |
| 53 | + | export async function readArchiveManifest(url, headers, fetchImpl = fetch) { | |
| 54 | + | const response = await fetchImpl(url, { headers }); | |
| 55 | + | if (response.status === 404) { await response.body?.cancel(); return null; } | |
| 56 | + | if (!response.ok) throw new Error(`Cannot read archived build (${response.status})`); | |
| 57 | + | const data = await response.json(); | |
| 58 | + | if (data.version !== 1 || !Array.isArray(data.assets) || !data.assets.length || data.assets.some((row) => !Array.isArray(row) || row.length !== 2 || typeof row[0] !== "string" || archivedAssetKey(row[0].replace(/^web-assets/, "")) !== row[0] || !/^"[a-f0-9]{32}"$/.test(row[1]))) throw new Error("Invalid archived build manifest"); | |
| 59 | + | return new Map(data.assets); | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | export async function archiveAsset({ name, bytes, account, bucket, headers, index, fetchImpl = fetch }) { | |
| 63 | + | const key = archivedAssetKey(`/assets/${name}`); | |
| 64 | + | if (!key) throw new Error(`Not a public build asset: ${name}`); | |
| 65 | + | const url = objectUrl(account, bucket, key); | |
| 66 | + | const expected = checksum(bytes); | |
| 67 | + | if (index?.has(key)) { | |
| 68 | + | if (index.get(key) !== expected) throw new Error(`Asset filename collision: ${name}`); | |
| 69 | + | return; | |
| 70 | + | } | |
| 71 | + | // Cloudflare's R2 REST gateway does not implement HEAD. Range avoids | |
| 72 | + | // downloading unchanged files; cancel the body even if Range is ignored. | |
| 73 | + | const existing = await fetchImpl(url, { headers: { ...headers, range: "bytes=0-0" }, signal: AbortSignal.timeout(30_000) }); | |
| 74 | + | if (existing.ok) { | |
| 75 | + | await existing.body?.cancel(); | |
| 76 | + | if (existing.headers.get("etag") !== expected) throw new Error(`Asset filename collision: ${name}`); | |
| 77 | + | index?.set(key, expected); | |
| 78 | + | return; | |
| 79 | + | } | |
| 80 | + | await existing.body?.cancel(); | |
| 81 | + | if (existing.status !== 404) throw new Error(`Asset archive lookup failed (${existing.status}): ${name}`); | |
| 82 | + | const uploaded = await fetchImpl(url, { | |
| 83 | + | method: "PUT", headers: { ...headers, "content-type": TYPES[name.split(".").at(-1)], "cache-control": "public, max-age=31536000, immutable" }, | |
| 84 | + | body: bytes, signal: AbortSignal.timeout(60_000), | |
| 85 | + | }); | |
| 86 | + | await confirmUpload(uploaded, bytes, name); | |
| 87 | + | index?.set(key, expected); | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | /** Seed the currently live build too, so the first protected release saves existing tabs. */ | |
| 91 | + | async function liveReferences(origin, fetchImpl) { | |
| 92 | + | const pageUrl = new URL(origin); | |
| 93 | + | // The application's public HTML cache may still contain a previous release. | |
| 94 | + | pageUrl.searchParams.set("_g1t_archive", randomUUID()); | |
| 95 | + | const page = await fetchImpl(pageUrl, { signal: AbortSignal.timeout(30_000) }); | |
| 96 | + | if (!page.ok) throw new Error(`Cannot read the current site before cutover (${page.status})`); | |
| 97 | + | const pending = assetReferences(await page.text()); | |
| 98 | + | if (!pending.some((name) => name.endsWith(".js")) || !pending.some((name) => name.endsWith(".css"))) throw new Error("Current site did not expose its JS and CSS assets; refusing an unprotected cutover"); | |
| 99 | + | return pending; | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | export async function archiveLiveAssets(origin, save, fetchImpl = fetch, references) { | |
| 103 | + | let pending = references ?? await liveReferences(origin, fetchImpl); | |
| 104 | + | const seen = new Set(); | |
| 105 | + | while (pending.length) { | |
| 106 | + | const batch = [...new Set(pending)].filter((name) => !seen.has(name)); | |
| 107 | + | pending = []; | |
| 108 | + | for (const name of batch) seen.add(name); | |
| 109 | + | if (seen.size > 4000) throw new Error("Current site's asset graph exceeds the archive limit"); | |
| 110 | + | await together(batch, async (name) => { | |
| 111 | + | const response = await fetchImpl(new URL(`/assets/${name}`, origin), { signal: AbortSignal.timeout(30_000) }); | |
| 112 | + | if (!response.ok || /text\/html/i.test(response.headers.get("content-type") ?? "")) throw new Error(`Current asset is unavailable: ${name}`); | |
| 113 | + | const bytes = Buffer.from(await response.arrayBuffer()); | |
| 114 | + | await save(name, bytes); | |
| 115 | + | if (/\.(js|css)$/.test(name)) pending.push(...assetReferences(bytes.toString("utf8"))); | |
| 116 | + | }); | |
| 117 | + | } | |
| 118 | + | return seen.size; | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | export async function publishWebAssets(cwd, { onLine = console.log, fetchImpl = fetch, seedLive = true, auth = cloudflareAuth(wranglerEnv()) } = {}) { | |
| 122 | + | const config = JSON.parse(await readFile(join(cwd, "build/server/wrangler.json"), "utf8")); | |
| 123 | + | const bucket = config.r2_buckets.find((binding) => binding.binding === "DOWNLOADS")?.bucket_name; | |
| 124 | + | if (!bucket || !config.account_id) throw new Error("Web asset archive needs the DOWNLOADS bucket and Cloudflare account"); | |
| 125 | + | let headers = auth; | |
| 126 | + | if (!headers) { | |
| 127 | + | // Capture credentials only in memory; never pipe this output to deploy logs. | |
| 128 | + | const auth = await wrangler(["auth", "token", "--json"], { cwd }); | |
| 129 | + | if (auth.code !== 0) throw new Error("Cannot authenticate the asset archive; run wrangler login"); | |
| 130 | + | let credentials; | |
| 131 | + | try { credentials = jsonFrom(auth.out); } catch { throw new Error("Cannot read Wrangler credentials for asset archive"); } | |
| 132 | + | headers = credentials.type === "api_key" ? { "x-auth-key": credentials.key, "x-auth-email": credentials.email } : { authorization: `Bearer ${credentials.token}` }; | |
| 133 | + | } | |
| 134 | + | const providerFetch = retryingFetch(fetchImpl); | |
| 135 | + | const manifestUrl = (name) => objectUrl(config.account_id, bucket, `web-assets/manifests/${name}.json`); | |
| 136 | + | const saveManifest = async (name, assets) => { | |
| 137 | + | const body = JSON.stringify({ version: 1, assets: [...assets] }); | |
| 138 | + | const response = await providerFetch(manifestUrl(name), { method: "PUT", headers: { ...headers, "content-type": "application/json" }, body }); | |
| 139 | + | await confirmUpload(response, body, name); | |
| 140 | + | }; | |
| 141 | + | let index = new Map(); | |
| 142 | + | const domain = config.routes?.find((route) => route.custom_domain)?.pattern; | |
| 143 | + | if (domain && seedLive) { | |
| 144 | + | const origin = `https://${domain}`; | |
| 145 | + | const references = await liveReferences(origin, fetchImpl); | |
| 146 | + | const manifest = manifestName(references); | |
| 147 | + | if (!manifest) throw new Error("Current site has no versioned route manifest"); | |
| 148 | + | const archived = await readArchiveManifest(manifestUrl(manifest), headers, providerFetch); | |
| 149 | + | if (archived) { | |
| 150 | + | index = archived; | |
| 151 | + | onLine(`Current build is already archived (${index.size} assets)`); | |
| 152 | + | } else { | |
| 153 | + | const save = (name, bytes) => archiveAsset({ name, bytes, account: config.account_id, bucket, headers, index, fetchImpl: providerFetch }); | |
| 154 | + | onLine(`Archived ${await archiveLiveAssets(origin, save, fetchImpl, references)} current assets before cutover`); | |
| 155 | + | await saveManifest(manifest, index); | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | const directory = join(cwd, "build/client/assets"); | |
| 159 | + | const previous = new Map(index); | |
| 160 | + | const names = (await readdir(directory)).filter((name) => archivedAssetKey(`/assets/${name}`)); | |
| 161 | + | const manifest = manifestName(names); | |
| 162 | + | if (!manifest || !names.some((name) => name.endsWith(".css"))) throw new Error("Web build has no manifest/CSS assets"); | |
| 163 | + | const current = new Map(); | |
| 164 | + | await together(names, async (name) => { | |
| 165 | + | const bytes = await readFile(join(directory, name)); | |
| 166 | + | await archiveAsset({ name, bytes, account: config.account_id, bucket, headers, index, fetchImpl: providerFetch }); | |
| 167 | + | current.set(archivedAssetKey(`/assets/${name}`), checksum(bytes)); | |
| 168 | + | }); | |
| 169 | + | await saveManifest(manifest, current); | |
| 170 | + | onLine(`Verified ${names.length} assets for the new build; old filenames remain available`); | |
| 171 | + | const samples = new Map(); | |
| 172 | + | for (const assets of [previous, current]) { | |
| 173 | + | for (const pattern of [/\.css$/, /\/entry\.client-.*\.js$/, /\/manifest-.*\.js$/]) { | |
| 174 | + | const entry = [...assets].find(([key]) => pattern.test(key)); | |
| 175 | + | if (entry) samples.set(...entry); | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | return { origin: domain ? `https://${domain}` : null, manifest, samples: [...samples] }; | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | /** Check both sides of cutover, including filenames no longer in the new bundle. */ | |
| 182 | + | export async function verifyWebAssets(release, fetchImpl = fetch) { | |
| 183 | + | if (!release.origin) return; | |
| 184 | + | const references = await liveReferences(release.origin, fetchImpl); | |
| 185 | + | if (!references.includes(release.manifest)) throw new Error("Web cutover is not serving the expected build"); | |
| 186 | + | await together(release.samples, async ([key, etag]) => { | |
| 187 | + | const response = await fetchImpl(new URL(key.replace(/^web-assets/, ""), release.origin), { signal: AbortSignal.timeout(30_000) }); | |
| 188 | + | if (!response.ok || /text\/html/i.test(response.headers.get("content-type") ?? "") || checksum(Buffer.from(await response.arrayBuffer())) !== etag) throw new Error(`Web cutover asset check failed: ${key}`); | |
| 189 | + | }); | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { | |
| 193 | + | const release = await publishWebAssets(process.cwd()); | |
| 194 | + | if (process.argv.includes("--deploy")) { | |
| 195 | + | const deployed = await wrangler(["deploy"], { cwd: process.cwd(), onLine: console.log }); | |
| 196 | + | if (deployed.code !== 0) throw new Error("Web deploy failed"); | |
| 197 | + | await verifyWebAssets(release); | |
| 198 | + | console.log("Web cutover verified: current and previous build assets respond"); | |
| 199 | + | } | |
| 200 | + | } |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | import { createHash } from "node:crypto"; | |
| 4 | + | import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"; | |
| 5 | + | import { tmpdir } from "node:os"; | |
| 6 | + | import { dirname, join, resolve } from "node:path"; | |
| 7 | + | import { archiveAsset, archiveLiveAssets, assetReferences, publishWebAssets, readArchiveManifest, retryingFetch, verifyWebAssets } from "./web-assets.mjs"; | |
| 8 | + | ||
| 9 | + | test("discover actual hashed assets without archiving icons, source maps or library defaults", () => { | |
| 10 | + | assert.deepEqual(assetReferences('<link href="/assets/app-12345678.css"><img src="/apple-touch-icon.png"> import "./chunk-abcdefgh.js"; import "./elk-worker.min.js"; url(/assets/font-12345678.woff2)'), ["app-12345678.css", "chunk-abcdefgh.js", "font-12345678.woff2"]); | |
| 11 | + | }); | |
| 12 | + | ||
| 13 | + | test("archive the old release's transitive chunks before replacing it", async () => { | |
| 14 | + | const responses = { | |
| 15 | + | "/": '<link href="/assets/app-12345678.css"><script src="/assets/entry-12345678.js"></script>', | |
| 16 | + | "/assets/app-12345678.css": "body{color:red}", | |
| 17 | + | "/assets/entry-12345678.js": 'import "./lazy-12345678.js"; import "./lazy-12345678.js"', | |
| 18 | + | "/assets/lazy-12345678.js": 'import "./entry-12345678.js"', | |
| 19 | + | }; | |
| 20 | + | const saved = []; | |
| 21 | + | const total = await archiveLiveAssets("https://example.com", async (name) => saved.push(name), async (url) => new Response(responses[new URL(url).pathname])); | |
| 22 | + | assert.equal(total, 3); | |
| 23 | + | assert.deepEqual(saved.sort(), ["app-12345678.css", "entry-12345678.js", "lazy-12345678.js"]); | |
| 24 | + | }); | |
| 25 | + | ||
| 26 | + | test("archive failures block cutover and immutable filename collisions are refused", async () => { | |
| 27 | + | const args = { name: "app-12345678.css", bytes: Buffer.from("body{}"), account: "account", bucket: "bucket", headers: {} }; | |
| 28 | + | const calls = []; | |
| 29 | + | await assert.rejects(archiveAsset({ ...args, fetchImpl: async (_url, init) => { | |
| 30 | + | calls.push(init.method ?? "GET"); | |
| 31 | + | return new Response(null, { status: init.method === "PUT" ? 503 : 404 }); | |
| 32 | + | } }), /upload failed/); | |
| 33 | + | assert.deepEqual(calls, ["GET", "PUT"]); | |
| 34 | + | await assert.rejects(archiveAsset({ ...args, fetchImpl: async (_url, init) => init.method === "PUT" ? Response.json({ success: true, result: { etag: "incorrect" } }) : new Response(null, { status: 404 }) }), /checksum mismatch/); | |
| 35 | + | await assert.rejects(archiveAsset({ ...args, fetchImpl: async () => new Response(null, { headers: { etag: '"different"' } }) }), /collision/); | |
| 36 | + | const etag = `"${createHash("md5").update(args.bytes).digest("hex")}"`; | |
| 37 | + | await archiveAsset({ ...args, fetchImpl: async (_url, init) => { | |
| 38 | + | assert.notEqual(init.method, "PUT"); | |
| 39 | + | return new Response(null, { headers: { etag } }); | |
| 40 | + | } }); | |
| 41 | + | }); | |
| 42 | + | ||
| 43 | + | test("a missing live asset aborts the archive instead of saving an HTML error as CSS", async () => { | |
| 44 | + | await assert.rejects(archiveLiveAssets("https://example.com", async () => assert.fail(), async (url) => new URL(url).pathname === "/" | |
| 45 | + | ? new Response('/assets/app-12345678.css /assets/entry-12345678.js') | |
| 46 | + | : new Response("not found", { headers: { "content-type": "text/html" } })), /unavailable/); | |
| 47 | + | }); | |
| 48 | + | ||
| 49 | + | test("a release manifest skips unchanged uploads without scanning deployment history", async () => { | |
| 50 | + | const bytes = Buffer.from("body{}"); | |
| 51 | + | const etag = createHash("md5").update(bytes).digest("hex"); | |
| 52 | + | const index = await readArchiveManifest("https://provider/manifest", {}, async () => Response.json({ version: 1, assets: [["web-assets/assets/app-12345678.css", `"${etag}"`]] })); | |
| 53 | + | await archiveAsset({ name: "app-12345678.css", bytes, account: "a", bucket: "b", headers: {}, index, fetchImpl: async () => assert.fail("unchanged asset should not be uploaded") }); | |
| 54 | + | await assert.rejects(archiveAsset({ name: "app-12345678.css", bytes: Buffer.from("different"), account: "a", bucket: "b", headers: {}, index }), /collision/); | |
| 55 | + | }); | |
| 56 | + | ||
| 57 | + | test("provider rate limits wait and retry; persistent failure remains a failure", async () => { | |
| 58 | + | const waits = []; | |
| 59 | + | let calls = 0; | |
| 60 | + | const response = await retryingFetch(async () => new Response(null, { status: ++calls < 3 ? 429 : 200, headers: { "retry-after": "2" } }), async (ms) => waits.push(ms))("https://provider"); | |
| 61 | + | assert.equal(response.status, 200); | |
| 62 | + | assert.deepEqual(waits, [2000, 2000]); | |
| 63 | + | calls = 0; | |
| 64 | + | assert.equal((await retryingFetch(async () => { calls++; return new Response(null, { status: 503 }); }, async () => {})("https://provider")).status, 503); | |
| 65 | + | assert.equal(calls, 7); | |
| 66 | + | }); | |
| 67 | + | ||
| 68 | + | test("cutover verifies old and new assets and rejects HTML fallback or wrong build", async () => { | |
| 69 | + | const css = "body{}"; | |
| 70 | + | const release = { origin: "https://example.com", manifest: "manifest-12345678.js", samples: [["web-assets/assets/app-old12345.css", `"${createHash("md5").update(css).digest("hex")}"`]] }; | |
| 71 | + | const page = '<link href="/assets/app-12345678.css"><script src="/assets/manifest-12345678.js"></script>'; | |
| 72 | + | await verifyWebAssets(release, async (url) => new Response(new URL(url).pathname === "/" ? page : css)); | |
| 73 | + | await assert.rejects(verifyWebAssets(release, async (url) => new Response(new URL(url).pathname === "/" ? page : "error", { headers: { "content-type": "text/html" } })), /asset check failed/); | |
| 74 | + | await assert.rejects(verifyWebAssets(release, async () => new Response(page.replaceAll("12345678", "87654321"))), /expected build/); | |
| 75 | + | }); | |
| 76 | + | ||
| 77 | + | test("1000 successive releases keep old tabs' assets and perform bounded incremental archive work", async () => { | |
| 78 | + | const cwd = await mkdtemp(join(tmpdir(), "g1t-web-releases-")); | |
| 79 | + | const directory = join(cwd, "build/client/assets"); | |
| 80 | + | await mkdir(directory, { recursive: true }); | |
| 81 | + | await mkdir(join(cwd, "build/server"), { recursive: true }); | |
| 82 | + | await writeFile(join(cwd, "build/server/wrangler.json"), JSON.stringify({ account_id: "a", r2_buckets: [{ binding: "DOWNLOADS", bucket_name: "b" }], routes: [{ custom_domain: true, pattern: "example.com" }] })); | |
| 83 | + | const objects = new Map(); | |
| 84 | + | const shared = 'export const shared = true;'; | |
| 85 | + | let live = null; | |
| 86 | + | let apiCalls = 0; | |
| 87 | + | const fetchImpl = async (input, init = {}) => { | |
| 88 | + | const url = new URL(input); | |
| 89 | + | if (url.hostname === "example.com") { | |
| 90 | + | assert.equal(url.pathname, "/", "already protected releases must not crawl all live chunks again"); | |
| 91 | + | return new Response(`/assets/${live} /assets/app-12345678.css`); | |
| 92 | + | } | |
| 93 | + | apiCalls++; | |
| 94 | + | assert.ok(url.pathname.includes("/objects/"), "must not list all previous releases"); | |
| 95 | + | const key = url.pathname.split("/objects/")[1]; | |
| 96 | + | if (init.method === "PUT") { | |
| 97 | + | objects.set(key, Buffer.from(init.body)); | |
| 98 | + | return Response.json({ success: true, result: { etag: createHash("md5").update(init.body).digest("hex") } }); | |
| 99 | + | } | |
| 100 | + | const bytes = objects.get(key); | |
| 101 | + | return bytes ? new Response(bytes, { headers: { etag: `"${createHash("md5").update(bytes).digest("hex")}"` } }) : new Response(null, { status: 404 }); | |
| 102 | + | }; | |
| 103 | + | try { | |
| 104 | + | await writeFile(join(directory, "app-12345678.css"), "body{}"); | |
| 105 | + | await writeFile(join(directory, "shared-12345678.js"), shared); | |
| 106 | + | for (let i = 0; i < 1000; i++) { | |
| 107 | + | const manifest = `manifest-${i.toString(16).padStart(8, "0")}.js`; | |
| 108 | + | if (live) await rm(join(directory, live)); | |
| 109 | + | await writeFile(join(directory, manifest), `window.manifest=${i}`); | |
| 110 | + | apiCalls = 0; | |
| 111 | + | await publishWebAssets(cwd, { auth: { authorization: "test" }, fetchImpl, seedLive: i > 0, onLine: () => {} }); | |
| 112 | + | assert.equal(apiCalls, i === 0 ? 7 : 4, `release ${i} should upload only its changed file and manifest`); | |
| 113 | + | live = manifest; | |
| 114 | + | } | |
| 115 | + | assert.equal(objects.get("web-assets/assets/shared-12345678.js").toString(), shared); | |
| 116 | + | assert.equal(objects.get("web-assets/assets/manifest-00000000.js").toString(), "window.manifest=0"); | |
| 117 | + | assert.equal(objects.get("web-assets/assets/manifest-000003e7.js").toString(), "window.manifest=999"); | |
| 118 | + | } finally { | |
| 119 | + | assert.equal(dirname(resolve(cwd)), resolve(tmpdir())); | |
| 120 | + | await rm(cwd, { recursive: true, force: true }); | |
| 121 | + | } | |
| 122 | + | }); |
Comments
Sign in to comment.