diff --git a/apps/app/src/lib/plugin-frontend-reload.test.ts b/apps/app/src/lib/plugin-frontend-reload.test.ts index 9604256f84a..ed1b4c6b9d8 100644 --- a/apps/app/src/lib/plugin-frontend-reload.test.ts +++ b/apps/app/src/lib/plugin-frontend-reload.test.ts @@ -116,8 +116,8 @@ function makeDeps(initial: PluginFrontendCandidate[] = []): TestReconcileDeps { fetchCandidates: vi.fn( async (): Promise => initial, ), - importModule: vi.fn( - async (_url: string): Promise => pluginModule("hello"), + importModule: vi.fn(async (_url: string): Promise => + pluginModule("hello"), ), applyCss: vi.fn(), retainCss: vi.fn(() => vi.fn()), @@ -546,6 +546,27 @@ describe("reconcilePluginFrontends", () => { expect(state.appliedHashes.has("hello")).toBe(false); }); + it("uses a fresh module URL when retrying a failed unchanged bundle", async () => { + const state = createPluginFrontendReconcileState(); + const deps = makeDeps([candidate("hello", "v1")]); + deps.importModule.mockRejectedValueOnce(new Error("blocked by client")); + + await reconcilePluginFrontends(state, deps); + await reconcilePluginFrontends(state, deps); + await reconcilePluginFrontends(state, deps); + + expect(deps.importModule).toHaveBeenCalledTimes(2); + expect(deps.importModule).toHaveBeenNthCalledWith( + 1, + "/api/v1/plugins/hello/assets/app.js?h=v1", + ); + expect(deps.importModule).toHaveBeenNthCalledWith( + 2, + "/api/v1/plugins/hello/assets/app.js?h=v1&bb_retry=1", + ); + expect(state.records.get("hello")?.status).toBe("loaded"); + }); + it("mounts once, skips repeated reconciliation, and disposes exactly once on reload and removal", async () => { const state = createPluginFrontendReconcileState(); const deps = makeDeps([candidate("hello", "v1")]); diff --git a/apps/app/src/lib/plugin-frontend.ts b/apps/app/src/lib/plugin-frontend.ts index 4b21c040cf2..cb0721d7cb8 100644 --- a/apps/app/src/lib/plugin-frontend.ts +++ b/apps/app/src/lib/plugin-frontend.ts @@ -322,9 +322,14 @@ async function runWithConcurrencyLimit( await Promise.all(lanes); } +function appendPluginImportRetry(url: string, retryCount: number): string { + return `${url}${url.includes("?") ? "&" : "?"}bb_retry=${retryCount}`; +} + interface PluginFrontendReconcileState { records: Map; appliedHashes: Map; + failedImportAttempts: Map; activeGenerations: Map; generationByPluginId: Map; pendingControllers: Map; @@ -337,6 +342,7 @@ export function createPluginFrontendReconcileState(): PluginFrontendReconcileSta return { records: new Map(), appliedHashes: new Map(), + failedImportAttempts: new Map(), activeGenerations: new Map(), generationByPluginId: new Map(), pendingControllers: new Map(), @@ -657,14 +663,36 @@ async function reconcileCandidates( return; } deps.resetCrashedSlots(pluginId); - const loaded = await loadPluginFrontends([candidate], { - importModule: deps.importModule, - injectCss: () => {}, - warn: deps.warn, - }); + const previousAttempt = state.failedImportAttempts.get(pluginId); + const retryCount = + previous?.status === "failed" && + previousAttempt?.hash === candidate.bundle.hash + ? previousAttempt.count + 1 + : 0; + const importUrl = + retryCount === 0 + ? candidate.bundle.jsUrl + : appendPluginImportRetry(candidate.bundle.jsUrl, retryCount); + const loaded = await loadPluginFrontends( + [ + { + ...candidate, + bundle: { ...candidate.bundle, jsUrl: importUrl }, + }, + ], + { + importModule: deps.importModule, + injectCss: () => {}, + warn: deps.warn, + }, + ); const record = loaded.get(pluginId); if (record === undefined) return; if (record.status === "failed") { + state.failedImportAttempts.set(pluginId, { + hash: candidate.bundle.hash, + count: retryCount, + }); await deactivateCommittedGeneration(pluginId, state, deps); state.records.set(pluginId, record); publishDiagnostic(state, deps, { @@ -679,6 +707,7 @@ async function reconcileCandidates( }); return; } + state.failedImportAttempts.delete(pluginId); if (record.status === "needs-update") { await deactivateCommittedGeneration(pluginId, state, deps); state.records.set(pluginId, record); @@ -833,6 +862,7 @@ export async function disposePluginFrontends( } state.records.clear(); state.appliedHashes.clear(); + state.failedImportAttempts.clear(); state.activeGenerations.clear(); state.diagnostics.clear(); deps.diagnosticsChanged?.(); diff --git a/apps/server/src/routes/plugins.ts b/apps/server/src/routes/plugins.ts index 871254bf675..a1ccee79a19 100644 --- a/apps/server/src/routes/plugins.ts +++ b/apps/server/src/routes/plugins.ts @@ -471,6 +471,41 @@ export function registerPluginRoutes( "app.css": { kind: "css", contentType: "text/css; charset=utf-8" }, } as const; + app.get("/plugin-app-assets/:hash/:file", async (context) => { + const file = context.req.param("file"); + const spec = + file === "app.js" || file === "app.css" + ? APP_ASSET_CONTENT_TYPES[file] + : undefined; + if (!spec) { + return context.json({ ok: false, error: "unknown plugin asset" }, 404); + } + const hash = context.req.param("hash"); + if (!/^[a-f0-9]{16}$/u.test(hash)) { + return context.json({ ok: false, error: "unknown plugin asset" }, 404); + } + const asset = plugins.getAppAssetByHash(hash, spec.kind); + if (!asset) { + return context.json( + { ok: false, error: "plugin has no loadable frontend bundle" }, + 404, + ); + } + let bytes: Buffer; + try { + bytes = await readFile(asset.path); + } catch { + return context.json({ ok: false, error: "bundle file missing" }, 404); + } + return appAssetResponse(context, bytes, { + assetKey: `${hash}:${spec.kind}`, + cache: appAssetCompressionCache, + contentType: spec.contentType, + cacheControl: "public, max-age=31536000, immutable", + contentHash: asset.hash, + }); + }); + app.get("/plugins/:id/assets/icons/:file", (context) => { const file = context.req.param("file"); const name = file.endsWith(".svg") ? file.slice(0, -".svg".length) : null; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index ebe92c9d21a..eb9b66d7060 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -165,7 +165,7 @@ const SLOW_API_REQUEST_LOG_THRESHOLD_MS = 1_000; const THREAD_EVENT_WAIT_PATH_PATTERN = /^\/api\/v1\/threads\/[^/]+\/events\/wait$/u; const PLUGIN_APP_ASSET_PATH_PATTERN = - /^\/api\/v1\/plugins\/[^/]+\/assets\/app\.(?:js|css)$/u; + /^\/api\/v1\/(?:plugins\/[^/]+\/assets|plugin-app-assets\/[a-f0-9]{16})\/app\.(?:js|css)$/u; const PRECOMPRESSED_STATIC_FILES = [ { encoding: "br", extension: ".br" }, { encoding: "gzip", extension: ".gz" }, diff --git a/apps/server/src/services/plugins/app-bundle.ts b/apps/server/src/services/plugins/app-bundle.ts index bb08994135a..81db348df8c 100644 --- a/apps/server/src/services/plugins/app-bundle.ts +++ b/apps/server/src/services/plugins/app-bundle.ts @@ -320,9 +320,10 @@ export async function loadPluginAppBundle( const hasher = createHash("sha256").update(js); if (css !== null) hasher.update(css); hasher.update(metaRaw); + hasher.update(pluginId); const hash = hasher.digest("hex").slice(0, 16); const assetUrl = (file: string) => - `/api/v1/plugins/${encodeURIComponent(pluginId)}/assets/${file}?h=${hash}`; + `/api/v1/plugin-app-assets/${hash}/${file}`; return { state: { hasApp: true, diff --git a/apps/server/src/services/plugins/plugin-service.ts b/apps/server/src/services/plugins/plugin-service.ts index 902bf54a8fd..a906e1dac3d 100644 --- a/apps/server/src/services/plugins/plugin-service.ts +++ b/apps/server/src/services/plugins/plugin-service.ts @@ -282,6 +282,10 @@ export interface PluginService { id: string, kind: "js" | "css", ): { path: string; hash: string } | undefined; + getAppAssetByHash( + hash: string, + kind: "js" | "css", + ): { path: string; hash: string } | undefined; getBrandingAsset( id: string, variant: PluginBrandingAssetVariant, @@ -1604,6 +1608,16 @@ export function createPluginService(deps: PluginServiceDeps): PluginService { return { path, hash: assets.hash }; }, + getAppAssetByHash(hash, kind) { + for (const [id, snapshot] of appBundles) { + if (!loaded.has(id) || snapshot.assets?.hash !== hash) continue; + const path = + kind === "js" ? snapshot.assets.jsPath : snapshot.assets.cssPath; + if (path !== null) return { path, hash }; + } + return undefined; + }, + getBrandingAsset(id, variant) { const set = brandingAssetSetFor(id); const asset = diff --git a/apps/server/test/services/plugins/plugin-app-bundle.test.ts b/apps/server/test/services/plugins/plugin-app-bundle.test.ts index 73f8335a6ca..32b7b29dbae 100644 --- a/apps/server/test/services/plugins/plugin-app-bundle.test.ts +++ b/apps/server/test/services/plugins/plugin-app-bundle.test.ts @@ -122,10 +122,10 @@ describe("plugin app bundles (build policy, inventory, asset routes)", () => { expect(bundle.sdkMajor).toBe(PLUGIN_SDK_MAJOR); expect(bundle.sdkVersion).toBe(PLUGIN_SDK_VERSION); expect(bundle.jsUrl).toBe( - `/api/v1/plugins/appy/assets/app.js?h=${bundle.hash}`, + `/api/v1/plugin-app-assets/${bundle.hash}/app.js`, ); expect(bundle.cssUrl).toBe( - `/api/v1/plugins/appy/assets/app.css?h=${bundle.hash}`, + `/api/v1/plugin-app-assets/${bundle.hash}/app.css`, ); const jsStat = await stat(join(rootDir, "dist", "app.js")); await stat(join(rootDir, "dist", "app.meta.json")); @@ -212,6 +212,10 @@ describe("plugin app bundles (build policy, inventory, asset routes)", () => { `${BASE}/api/v1/plugins/nope/assets/app.js`, ); expect(unknownPlugin.status).toBe(404); + const unknownHash = await harness.app.request( + `${BASE}/api/v1/plugin-app-assets/0000000000000000/app.js`, + ); + expect(unknownHash.status).toBe(404); const unknownFile = await harness.app.request( `${BASE}/api/v1/plugins/appy/assets/evil.js`, ); @@ -395,7 +399,7 @@ describe("plugin app bundles (build policy, inventory, asset routes)", () => { expect(after).not.toBeNull(); expect(after?.hash).not.toBe(before?.hash); expect(after?.jsUrl).toBe( - `/api/v1/plugins/devy/assets/app.js?h=${after?.hash}`, + `/api/v1/plugin-app-assets/${after?.hash}/app.js`, ); const js = await harness.app.request(`${BASE}${after?.jsUrl ?? ""}`); expect(js.status).toBe(200);