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
25 changes: 23 additions & 2 deletions apps/app/src/lib/plugin-frontend-reload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,8 @@ function makeDeps(initial: PluginFrontendCandidate[] = []): TestReconcileDeps {
fetchCandidates: vi.fn(
async (): Promise<PluginFrontendCandidate[]> => initial,
),
importModule: vi.fn(
async (_url: string): Promise<unknown> => pluginModule("hello"),
importModule: vi.fn(async (_url: string): Promise<unknown> =>
pluginModule("hello"),
),
applyCss: vi.fn(),
retainCss: vi.fn(() => vi.fn()),
Expand Down Expand Up @@ -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")]);
Expand Down
40 changes: 35 additions & 5 deletions apps/app/src/lib/plugin-frontend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,9 +322,14 @@ async function runWithConcurrencyLimit<T>(
await Promise.all(lanes);
}

function appendPluginImportRetry(url: string, retryCount: number): string {
return `${url}${url.includes("?") ? "&" : "?"}bb_retry=${retryCount}`;
}

interface PluginFrontendReconcileState {
records: Map<string, PluginFrontendRecord>;
appliedHashes: Map<string, string>;
failedImportAttempts: Map<string, { hash: string; count: number }>;
activeGenerations: Map<string, ActivePluginFrontendGeneration>;
generationByPluginId: Map<string, number>;
pendingControllers: Map<string, AbortController>;
Expand All @@ -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(),
Expand Down Expand Up @@ -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, {
Expand All @@ -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);
Expand Down Expand Up @@ -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?.();
Expand Down
35 changes: 35 additions & 0 deletions apps/server/src/routes/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/services/plugins/app-bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions apps/server/src/services/plugins/plugin-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 =
Expand Down
10 changes: 7 additions & 3 deletions apps/server/test/services/plugins/plugin-app-bundle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down Expand Up @@ -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`,
);
Expand Down Expand Up @@ -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);
Expand Down
Loading