diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index be239ff80e5..19f0d44d09c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -244,6 +244,12 @@ jobs: CI: true timeout-minutes: 30 + - name: Verify service worker precaching and upgrades + if: env.E2E_ENABLED == 'true' + working-directory: packages/app + run: bunx playwright test --config e2e/service-worker/playwright.config.ts + timeout-minutes: 5 + - name: Upload Playwright artifacts if: always() && env.E2E_ENABLED == 'true' uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 diff --git a/packages/app/e2e/service-worker/cache.spec.ts b/packages/app/e2e/service-worker/cache.spec.ts index 23cd6a6f770..fc1d5a28ac1 100644 --- a/packages/app/e2e/service-worker/cache.spec.ts +++ b/packages/app/e2e/service-worker/cache.spec.ts @@ -1,60 +1,124 @@ -import { expect, test } from "@playwright/test" -import { readFile } from "node:fs/promises" -import { createServer } from "node:http" +import { expect, test, type Page } from "@playwright/test" +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises" +import { createServer, type ServerResponse } from "node:http" import { once } from "node:events" +import { createHash } from "node:crypto" +import { join, extname, relative, sep } from "node:path" +import { tmpdir } from "node:os" +import { fileURLToPath } from "node:url" +import { build } from "vite" +import { serviceWorker } from "../../vite.pwa" -const legacy = ` -self.addEventListener("install", event => event.waitUntil( - caches.open("workbox-precache-v2-" + self.registration.scope).then(cache => - cache.addAll(["/index.html", "/assets/app-old.js", "/assets/lazy-old.js"]) - ) -)) -self.addEventListener("fetch", event => { - if (event.request.mode === "navigate") { - event.respondWith(caches.match("/index.html")) - return - } - event.respondWith(caches.match(event.request).then(response => response || fetch(event.request))) -}) -` +type Site = { + url: string + deploy: (fault?: "failed" | "html" | "corrupt" | "mixed-html" | "blocked") => void + legacy: () => void + requests: string[] + release: () => void +} -const fixture = test.extend<{ site: { url: string; upgrade: () => void; repair: () => void } }>({ - site: async ({}, use) => { - const worker = await readFile(new URL("../../dist/sw.js", import.meta.url), "utf8") - const state = { version: "old", repaired: false } +const fixture = test.extend<{ site: Site }, { builds: Record> }>({ + builds: [ + async ({}, use) => { + const directory = await mkdtemp(join(tmpdir(), "opencode-precache-")) + const builds: Record> = {} + try { + for (const version of ["old", "new"]) { + const root = join(directory, version) + const outDir = join(root, "dist") + await mkdir(join(root, "public", "nested"), { recursive: true }) + await Promise.all( + Object.entries({ + "index.html": `

Loading

`, + "main.js": `document.querySelector("h1").textContent = "${version}"; + document.querySelector("button").onclick = async () => { + document.querySelector("output").textContent = await (await import("./lazy.js")).load() + };`, + "lazy.js": `export async function load() { return (await import("./nested.js")).value }`, + "nested.js": `export const value = "${version} nested lazy loaded"`, + "public/nested/data.json": JSON.stringify({ version }), + "public/nested/font.woff2": `font-${version}`, + "public/nested/module.wasm": Buffer.from([0, 97, 115, 109, 1, 0, 0, 0]), + "public/large.bin": Buffer.alloc(2 * 1024 * 1024 + 1, version === "old" ? 1 : 2), + "public/_headers": "/*\n Cache-Control: no-cache", + "public/_redirects": "/* /index.html 200", + }).map(([path, contents]) => writeFile(join(root, path), contents)), + ) + await build({ + configFile: false, + root, + logLevel: "silent", + build: { outDir, assetsDir: "_assets", sourcemap: true }, + plugins: serviceWorker(outDir), + }) + builds[version] = Object.fromEntries( + await Promise.all( + (await readdir(outDir, { recursive: true, withFileTypes: true })) + .filter((entry) => entry.isFile()) + .map(async (entry) => { + const path = join(entry.parentPath, entry.name) + return ["/" + relative(outDir, path).split(sep).join("/"), await readFile(path)] + }), + ), + ) + } + await use(builds) + } finally { + await rm(directory, { recursive: true, force: true }) + } + }, + { scope: "worker" }, + ], + site: async ({ builds }, use) => { + const state = { version: "old", fault: "", legacy: false } + const requests: string[] = [] + const blocked: ServerResponse[] = [] + const release = () => blocked.splice(0).forEach((response) => response.end(builds.new["/large.bin"])) const server = createServer((request, response) => { - const pathname = new URL(request.url ?? "/", "http://localhost").pathname - const prefix = state.version === "old" ? "/assets" : "/_assets" + const path = new URL(request.url ?? "/", "http://localhost").pathname + requests.push(path) response.setHeader("cache-control", "no-store") - if (pathname === "/sw.js") { + if (path === "/observer.html") + return void response.writeHead(200, { "content-type": "text/html" }).end("Worker observer") + if (path === "/api/health") + return void response.writeHead(200, { "content-type": "application/json" }).end('{"healthy":true}') + if (path === "/sw.js" && state.legacy && state.version === "old") { + // Model the shipped worker's shared precache name and cache-first navigation behavior. + const urls = Object.keys(builds.old).filter( + (path) => path === "/index.html" || (path.startsWith("/_assets/") && path.endsWith(".js")), + ) response.setHeader("content-type", "text/javascript") - response.end(state.version === "old" ? legacy : worker) - return - } - if (pathname === `${prefix}/app-${state.version}.js`) { - response.setHeader("content-type", "text/javascript") - response.end(`import "${prefix}/startup-${state.version}.js"`) - return - } - if (pathname === `${prefix}/startup-${state.version}.js`) { - response.setHeader("content-type", "text/javascript") - response.end(` - document.getElementById("root").innerHTML = '

${state.version}

' - document.querySelector("button").onclick = () => import("/assets/lazy-old.js") + return void response.end(` + self.addEventListener("install", event => event.waitUntil( + caches.open("workbox-precache-v2-" + self.registration.scope).then(cache => cache.addAll(${JSON.stringify(urls)})) + )); + self.addEventListener("fetch", event => event.respondWith( + caches.match(event.request.mode === "navigate" ? "/index.html" : event.request) + .then(response => response || fetch(event.request)) + )); `) - return } - if ( - (pathname === "/assets/lazy-old.js" && state.version === "old") || - (pathname === "/_assets/retry.js" && state.repaired) - ) { - response.setHeader("content-type", "text/javascript") - response.end('document.querySelector("output").textContent = "Older chunk loaded"') - return + if (path === "/index.html" && state.fault === "mixed-html") + return void response.writeHead(200, { "content-type": "text/html" }).end(builds.old["/index.html"]) + if (path === "/large.bin" && state.fault && state.fault !== "mixed-html") { + if (state.fault === "blocked") return void blocked.push(response) + if (state.fault === "failed") return void response.writeHead(503).end("Unavailable") + if (state.fault === "html") + return void response.writeHead(200, { "content-type": "text/html" }).end("Wrong fallback") + return void response.end("Incorrect bytes with a successful status") } - // Deliberately retain the old server's fallback so the worker must reject HTML asset responses itself. + const file = builds[state.version][path] + const types: Record = { + ".js": "text/javascript", + ".html": "text/html", + ".json": "application/json", + ".wasm": "application/wasm", + } + response.setHeader("content-type", types[extname(path)] ?? "application/octet-stream") + if (file) return void response.end(file) + if (extname(path)) return void response.writeHead(404).end("Not found") response.setHeader("content-type", "text/html") - response.end(`
`) + response.end(builds[state.version]["/index.html"]) }) server.listen(0, "127.0.0.1") await once(server, "listening") @@ -63,75 +127,265 @@ const fixture = test.extend<{ site: { url: string; upgrade: () => void; repair: try { await use({ url: `http://127.0.0.1:${address.port}`, - upgrade: () => (state.version = "new"), - repair: () => (state.repaired = true), + deploy: (fault = undefined) => { + state.version = "new" + state.fault = fault ?? "" + }, + legacy: () => { + state.legacy = true + }, + requests, + release, }) } finally { + release() server.closeAllConnections() await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))) } }, }) -fixture("updates a legacy worker without reloading drafts or deleting old chunks", async ({ page, site }) => { - await page.goto(site.url) +async function install(page: Page, url: string) { + await page.goto(url) await expect(page.getByRole("heading")).toHaveText("old") await page.evaluate(async () => { await navigator.serviceWorker.register("/sw.js") await navigator.serviceWorker.ready }) - await page.goto(site.url) + await page.reload() + await expect.poll(() => page.evaluate(() => navigator.serviceWorker.controller?.state)).toBe("activated") await expect(page.getByRole("heading")).toHaveText("old") - await page.getByLabel("Draft").fill("Keep this unsent prompt") +} - site.upgrade() - await page.evaluate(async () => { - const cache = await caches.open("opencode-assets") - await cache.put( - "/_assets/startup-new.js", - new Response("stale fallback", { - headers: { "content-type": "text/html" }, - }), - ) - const changed = new Promise((resolve) => - navigator.serviceWorker.addEventListener("controllerchange", () => resolve(), { once: true }), - ) +async function update(page: Page) { + return page.evaluateHandle(async () => { const registration = await navigator.serviceWorker.getRegistration() - if (!registration) throw new Error("Missing legacy worker") - await registration.update() - await changed - }) - - await expect(page.getByLabel("Draft")).toHaveValue("Keep this unsent prompt") - await page.getByRole("button", { name: "Load older chunk" }).click() - await expect(page.getByRole("status")).toHaveText("Older chunk loaded") - - await page.goto(`${site.url}/workspace/example`) - await expect(page.getByRole("heading")).toHaveText("new") - await expect - .poll(() => - page.evaluate(async () => - (await (await caches.open("opencode-assets")).match("/_assets/startup-new.js"))?.headers.get("content-type"), + if (!registration) throw new Error("Missing installed worker") + const found = new Promise((resolve) => + registration.addEventListener( + "updatefound", + () => { + if (!registration.installing) throw new Error("Missing installing worker") + resolve(registration.installing) + }, + { once: true }, ), ) - .toBe("text/javascript") + await registration.update() + return found + }) +} + +async function waiting(page: Page) { + await expect + .poll(() => page.evaluate(async () => (await navigator.serviceWorker.getRegistration())?.waiting?.state)) + .toBe("installed") +} + +fixture( + "opens an uncached route offline and executes never-used nested lazy chunks", + async ({ page, context, site }) => { + await install(page, site.url) + await expect(page.getByRole("status")).toBeEmpty() + await context.setOffline(true) + await page.goto(`${site.url}/workspace/never-visited`) + await expect(page.getByRole("heading")).toHaveText("old") + await page.getByRole("button", { name: "Load lazy" }).click() + await expect(page.getByRole("status")).toHaveText("old nested lazy loaded") + }, +) + +fixture( + "precaches public files of every type and size, excluding deployment metadata and source maps", + async ({ page, site, builds, context }) => { + await install(page, site.url) + const files = ["/nested/data.json", "/nested/font.woff2", "/nested/module.wasm", "/large.bin"] + await context.setOffline(true) + for (const path of files) { + const digest = await page.evaluate( + async (path) => + Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", await (await fetch(path)).arrayBuffer()))), + path, + ) + expect(Buffer.from(digest)).toEqual(createHash("sha256").update(builds.old[path]).digest()) + } + expect(site.requests).not.toContain("/_headers") + expect(site.requests).not.toContain("/_redirects") + expect(site.requests.filter((path) => path.endsWith(".map"))).toEqual([]) + }, +) + +fixture( + "keeps drafts and removed old lazy chunks until every controlled tab closes", + async ({ page, context, site, builds }) => { + await install(page, site.url) + const second = await context.newPage() + await second.goto(site.url) + await expect(second.getByRole("heading")).toHaveText("old") + await second.getByLabel("Draft").fill("Keep this unsent prompt") + + site.deploy() + const created = context.waitForEvent("serviceworker") + const worker = await update(page) + const replacement = await created + await waiting(page) + expect(await worker.evaluate((worker) => worker.state)).toBe("installed") + await expect(second.getByLabel("Draft")).toHaveValue("Keep this unsent prompt") + await page.close() + await waiting(second) + await expect(second.getByRole("heading")).toHaveText("old") + await expect(second.getByLabel("Draft")).toHaveValue("Keep this unsent prompt") + + const removed = Object.keys(builds.old).find((path) => path.includes("/nested-") && path.endsWith(".js")) + expect(removed).toBeDefined() + expect((await second.request.get(`${site.url}${removed}`)).status()).toBe(404) + await second.getByRole("button", { name: "Load lazy" }).click() + await expect(second.getByRole("status")).toHaveText("old nested lazy loaded") + await expect(second.getByLabel("Draft")).toHaveValue("Keep this unsent prompt") + await second.close() + + await expect + .poll(() => + replacement.evaluate(() => { + const registration = (self as unknown as { registration: ServiceWorkerRegistration }).registration + return { waiting: !!registration.waiting, active: registration.active?.state } + }), + ) + .toEqual({ waiting: false, active: "activated" }) + await context.setOffline(true) + const observer = await context.newPage() + await observer.goto(`${site.url}/workspace/reopened`) + await expect(observer.getByRole("heading")).toHaveText("new") + await observer.getByRole("button", { name: "Load lazy" }).click() + await expect(observer.getByRole("status")).toHaveText("new nested lazy loaded") + }, +) + +for (const fault of ["failed", "html", "corrupt", "mixed-html"] as const) { + fixture(`retains the old complete build when a precache download is ${fault}`, async ({ page, context, site }) => { + await install(page, site.url) + await page.getByLabel("Draft").fill("Still editing") + site.deploy(fault) + const worker = await update(page) + await expect.poll(() => worker.evaluate((worker) => worker.state)).toBe("redundant") + expect(await page.evaluate(async () => (await navigator.serviceWorker.getRegistration())?.waiting)).toBeNull() + await expect(page.getByLabel("Draft")).toHaveValue("Still editing") + await context.setOffline(true) + await page.goto(`${site.url}/workspace/after-failure`) + await expect(page.getByRole("heading")).toHaveText("old") + await page.getByRole("button", { name: "Load lazy" }).click() + await expect(page.getByRole("status")).toHaveText("old nested lazy loaded") + }) +} + +fixture("does not expose new HTML while a precache download is blocked", async ({ page, context, site }) => { + await install(page, site.url) + site.requests.length = 0 + site.deploy("blocked") + const worker = await update(page) + await expect.poll(() => site.requests.includes("/large.bin")).toBe(true) + expect(await worker.evaluate((worker) => worker.state)).toBe("installing") + const second = await context.newPage() + await second.goto(`${site.url}/workspace/during-install`) + await expect(second.getByRole("heading")).toHaveText("old") + site.release() + await waiting(page) + await second.reload() + await expect(second.getByRole("heading")).toHaveText("old") }) -fixture("does not cache HTML responses under asset URLs", async ({ page, site }) => { - site.upgrade() - await page.goto(site.url) - await expect(page.getByRole("heading")).toHaveText("new") - await page.evaluate(async () => { - await navigator.serviceWorker.register("/sw.js") - await navigator.serviceWorker.ready - }) - await page.goto(site.url) - await expect(page.getByRole("heading")).toHaveText("new") - expect(await page.evaluate(async () => (await fetch("/_assets/retry.js")).headers.get("content-type"))).toBe( - "text/html", - ) - site.repair() - expect(await page.evaluate(async () => (await fetch("/_assets/retry.js")).headers.get("content-type"))).toBe( - "text/javascript", - ) +fixture("upgrades the legacy shared precache only after old tabs close", async ({ page, context, site, builds }) => { + site.legacy() + const observer = await context.newPage() + await observer.goto(`${site.url}/observer.html`) + await install(page, site.url) + await page.getByLabel("Draft").fill("Legacy unsent prompt") + // A stale runtime-cache HTML response must not contaminate the new generated precache. + const entry = Object.keys(builds.new).find((path) => path.includes("/index-") && path.endsWith(".js")) + expect(entry).toBeDefined() + await page.evaluate(async (entry) => { + await ( + await caches.open("opencode-assets") + ).put(entry!, new Response("stale fallback", { headers: { "content-type": "text/html" } })) + }, entry) + site.deploy() + await update(page) + await waiting(page) + await expect(page.getByLabel("Draft")).toHaveValue("Legacy unsent prompt") + await page.getByRole("button", { name: "Load lazy" }).click() + await expect(page.getByRole("status")).toHaveText("old nested lazy loaded") + await page.close() + await expect + .poll(() => observer.evaluate(async () => !!(await navigator.serviceWorker.getRegistration())?.waiting)) + .toBe(false) + await context.setOffline(true) + await observer.goto(`${site.url}/workspace/legacy-upgraded`) + await expect(observer.getByRole("heading")).toHaveText("new") + await observer.getByRole("button", { name: "Load lazy" }).click() + await expect(observer.getByRole("status")).toHaveText("new nested lazy loaded") +}) + +fixture("does not substitute cached HTML for API or missing asset navigations", async ({ page, site }) => { + await install(page, site.url) + const api = await page.goto(`${site.url}/api/health`) + expect(await api?.json()).toEqual({ healthy: true }) + expect(api?.fromServiceWorker()).toBe(false) + const asset = await page.goto(`${site.url}/_assets/missing.js`) + expect(asset?.status()).toBe(404) + expect(await asset?.text()).toBe("Not found") +}) + +test("the production build precaches every deployable file", async ({ page, context }) => { + const directory = new URL("../../dist/", import.meta.url) + const files = (await readdir(directory, { recursive: true, withFileTypes: true })) + .filter((entry) => entry.isFile()) + .map((entry) => "/" + relative(fileURLToPath(directory), join(entry.parentPath, entry.name)).split(sep).join("/")) + .filter((path) => !path.endsWith(".map") && !["/_headers", "/_redirects", "/sw.js"].includes(path)) + expect(files.length).toBeGreaterThan(1) + const server = createServer(async (request, response) => { + const path = new URL(request.url ?? "/", "http://localhost").pathname + response.setHeader("cache-control", "no-store") + if (path === "/probe.html") + return void response.writeHead(200, { "content-type": "text/html" }).end("Precache probe") + const bytes = await readFile(new URL(`.${path}`, directory)).catch(() => undefined) + if (!bytes) return void response.writeHead(404).end("Not found") + if (path.endsWith(".js")) response.setHeader("content-type", "text/javascript") + if (path.endsWith(".html")) { + response.setHeader("content-type", "text/html") + // Inspect the real cached HTML without executing the app or contacting a backend. + response.setHeader("content-security-policy", "default-src 'none'") + } + response.end(bytes) + }) + server.listen(0, "127.0.0.1") + await once(server, "listening") + const address = server.address() + if (!address || typeof address === "string") throw new Error("Expected a TCP address") + const url = `http://127.0.0.1:${address.port}` + try { + await page.goto(`${url}/probe.html`) + await page.evaluate(async () => { + await navigator.serviceWorker.register("/sw.js") + await navigator.serviceWorker.ready + }) + const cached = await page.evaluate(async () => + ( + await Promise.all( + (await caches.keys()).map(async (name) => + (await (await caches.open(name)).keys()).map((request) => new URL(request.url).pathname), + ), + ) + ) + .flat() + .sort(), + ) + expect(cached).toEqual(files.sort()) + await context.setOffline(true) + const response = await page.goto(`${url}/workspace/offline-probe`) + expect(response?.fromServiceWorker()).toBe(true) + expect(await response?.text()).toBe(await readFile(new URL("index.html", directory), "utf8")) + } finally { + server.closeAllConnections() + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))) + } }) diff --git a/packages/app/e2e/service-worker/playwright.config.ts b/packages/app/e2e/service-worker/playwright.config.ts index 96c2539cdf8..29e5148c673 100644 --- a/packages/app/e2e/service-worker/playwright.config.ts +++ b/packages/app/e2e/service-worker/playwright.config.ts @@ -1,9 +1,14 @@ import { defineConfig } from "@playwright/test" +// Tiny fixture builds do not need a Rolldown thread for every host CPU. +process.env.RAYON_NUM_THREADS ??= "2" + export default defineConfig({ testDir: ".", testMatch: "*.spec.ts", outputDir: "../test-results/service-worker", - timeout: 30_000, + timeout: 60_000, + workers: 1, + expect: { timeout: 15_000 }, use: { browserName: "chromium" }, }) diff --git a/packages/app/vite.config.ts b/packages/app/vite.config.ts index 0a7738a661e..a06a8fa4077 100644 --- a/packages/app/vite.config.ts +++ b/packages/app/vite.config.ts @@ -1,7 +1,8 @@ import { sentryVitePlugin } from "@sentry/vite-plugin" +import { fileURLToPath } from "node:url" import { defineConfig } from "vite" -import { VitePWA } from "vite-plugin-pwa" import desktopPlugin from "./vite.js" +import { serviceWorker } from "./vite.pwa" const sentry = process.env.SENTRY_AUTH_TOKEN && process.env.SENTRY_ORG && process.env.SENTRY_PROJECT @@ -21,62 +22,7 @@ const sentry = : false export default defineConfig({ - plugins: [ - desktopPlugin, - VitePWA({ - strategies: "generateSW", - registerType: "prompt", - injectRegister: false, - manifest: false, - workbox: { - clientsClaim: false, - skipWaiting: true, - inlineWorkboxRuntime: true, - // Always fetch the current HTML. Precaching a partial build can strand it without its chunks after an upgrade. - navigateFallback: null, - globPatterns: [], - runtimeCaching: [ - { - urlPattern: ({ url }) => - url.origin === self.location.origin && - (url.pathname.startsWith("/_assets/") || url.pathname.startsWith("/assets/")), - handler: "CacheFirst", - options: { - cacheName: "opencode-assets", - plugins: [ - { - cachedResponseWillBeUsed: async ({ request, cachedResponse }) => { - if ( - cachedResponse?.status === 200 && - !/^(text\/html|application\/xhtml\+xml)\b/i.test(cachedResponse.headers.get("content-type") ?? "") - ) - return cachedResponse - // Keep old tabs' precached chunks usable without retaining their stale HTML navigation handler. - const response = await caches.match(request, { - cacheName: `workbox-precache-v2-${self.location.origin}/`, - }) - return response?.status === 200 && - !/^(text\/html|application\/xhtml\+xml)\b/i.test(response.headers.get("content-type") ?? "") - ? response - : null - }, - cacheWillUpdate: async ({ response }) => - response.status === 200 && - !/^(text\/html|application\/xhtml\+xml)\b/i.test(response.headers.get("content-type") ?? "") - ? response - : null, - }, - ], - expiration: { - maxEntries: 1000, - }, - }, - }, - ], - }, - }), - sentry, - ] as any, + plugins: [desktopPlugin, serviceWorker(fileURLToPath(new URL("./dist", import.meta.url))), sentry] as any, server: { host: "0.0.0.0", allowedHosts: true, diff --git a/packages/app/vite.pwa.ts b/packages/app/vite.pwa.ts new file mode 100644 index 00000000000..d1c52a55980 --- /dev/null +++ b/packages/app/vite.pwa.ts @@ -0,0 +1,41 @@ +import { createHash } from "node:crypto" +import { readFile } from "node:fs/promises" +import { resolve } from "node:path" +import { VitePWA } from "vite-plugin-pwa" + +export function serviceWorker(directory: string) { + return VitePWA({ + strategies: "generateSW", + registerType: "prompt", + injectRegister: false, + manifest: false, + workbox: { + globDirectory: directory, + clientsClaim: false, + // Keep each open tab on its complete build until all old clients close. + skipWaiting: false, + inlineWorkboxRuntime: true, + navigateFallback: "/index.html", + navigateFallbackDenylist: [/^\/api(?:\/|$)/, /^\/(?:_assets|assets)(?:\/|$)/], + // Include lazy chunks and non-JS dependencies, not just the startup bundle. + globPatterns: ["**/*"], + globIgnores: ["**/*.map", "_headers", "_redirects"], + maximumFileSizeToCacheInBytes: Number.MAX_SAFE_INTEGER, + manifestTransforms: [ + async (entries) => ({ + manifest: await Promise.all( + entries.map(async (entry) => ({ + ...entry, + // A revision labels a cache entry; integrity rejects mixed deployments + // and HTML fallback responses instead of installing a broken build. + integrity: `sha256-${createHash("sha256") + .update(await readFile(resolve(directory, entry.url))) + .digest("base64")}`, + })), + ), + warnings: [], + }), + ], + }, + }) +}