Skip to content
Open
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
2 changes: 1 addition & 1 deletion packages/blocks-admin/src/admin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export {
setInvokeLoaders,
} from "./invoke";
export { LIVE_CONTROLS_SCRIPT } from "./liveControls";
export { handleMeta, setMetaData } from "./meta";
export { handleMeta, setMetaData, setMetaLoader } from "./meta";
export { handleRender, setPreviewWrapper, setRenderShell } from "./render";
export {
type PreviewResolution,
Expand Down
60 changes: 58 additions & 2 deletions packages/blocks-admin/src/admin/meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { composeMeta, type MetaResponse } from "@decocms/blocks/cms";
const G = globalThis as unknown as {
__deco_meta_data?: MetaResponse | null;
__deco_meta_etag?: string | null;
__deco_meta_loader?: (() => Promise<MetaResponse>) | null;
__deco_meta_loading?: Promise<MetaResponse | null> | null;
};

function getMetaData(): MetaResponse | null {
Expand Down Expand Up @@ -35,6 +37,60 @@ function setCachedEtag(etag: string | null) {
*/
export function invalidateMetaCache() {
setCachedEtag(null);
// Drop the composed schema too, but ONLY when it can be rebuilt. With a
// loader registered the next request re-imports and re-composes (composeMeta
// injects page schemas over the current blocks, so a decofile change really
// does invalidate it). With data set explicitly via `setMetaData` there is
// nothing to reload — clearing it would 503 the admin permanently.
if (G.__deco_meta_loader) setMetaDataInternal(null);
}

/**
* Register the schema loader instead of resolving it now.
*
* The schema is a JSON Schema bundle covering every section and app, and it is
* needed by exactly one route: `/live/_meta`. Loading it at boot meant every
* isolate parsed it and pinned the composed graph on `globalThis` for its whole
* life — for traffic that never touches the admin. Same shape as the decofile
* bug: a large JSON graph permanently reachable from a module-level binding.
*
* `createAdminSetup` has always documented this as lazy; it just wasn't.
*/
export function setMetaLoader(loader: () => Promise<MetaResponse>) {
G.__deco_meta_loader = loader;
}

/**
* The composed schema, importing + composing it on first use.
*
* Concurrent first requests share one in-flight load. A failed load is NOT
* latched: the rejection is swallowed to `null` (the caller answers 503) and
* the next request retries, so a transient import failure can't disable the
* admin for the isolate's life.
*/
async function ensureMetaData(): Promise<MetaResponse | null> {
const existing = getMetaData();
if (existing) return existing;

const loader = G.__deco_meta_loader;
if (!loader) return null;

G.__deco_meta_loading ??= loader()
.then((data) => {
const composed = composeMeta(data);
setMetaDataInternal(composed);
setCachedEtag(null);
return composed;
})
.catch((err) => {
console.warn("[deco] admin meta schema failed to load:", err);
return null;
})
.finally(() => {
G.__deco_meta_loading = null;
});

return G.__deco_meta_loading;
}

/**
Expand Down Expand Up @@ -62,8 +118,8 @@ function getEtag(): string {
return etag;
}

export function handleMeta(request: Request): Response {
const metaData = getMetaData();
export async function handleMeta(request: Request): Promise<Response> {
const metaData = await ensureMetaData();
if (!metaData) {
return new Response(JSON.stringify({ error: "Schema not initialized" }), {
status: 503,
Expand Down
121 changes: 121 additions & 0 deletions packages/blocks-admin/src/admin/metaLazy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createAdminSetup } from "../createAdminSetup";
import { handleMeta, invalidateMetaCache, setMetaData, setMetaLoader } from "./meta";

/**
* The admin JSON Schema bundle covers every section and app, and exactly one
* route needs it. Loading it at boot made every isolate parse it and pin the
* composed graph on globalThis for its whole life — for traffic that never
* touches the admin. Same shape as the decofile bug.
*
* These tests pin the property that matters: nothing loads the schema until a
* request asks for it.
*/

const G = globalThis as unknown as Record<string, unknown>;

function reset() {
G.__deco_meta_data = null;
G.__deco_meta_etag = null;
G.__deco_meta_loader = null;
G.__deco_meta_loading = null;
}

const SCHEMA = { definitions: { "site/sections/Hero.tsx": { type: "object" } } };
const req = () => new Request("https://site.test/live/_meta");

beforeEach(reset);

describe("lazy admin meta schema", () => {
it("does NOT invoke the loader at setup time", () => {
const meta = vi.fn(() => Promise.resolve(SCHEMA));
createAdminSetup({ meta, css: "/app.css" });
// The whole point: boot must not touch the schema.
expect(meta).not.toHaveBeenCalled();
});

it("invokes it on the first /live/_meta, once", async () => {
const meta = vi.fn(() => Promise.resolve(SCHEMA));
createAdminSetup({ meta, css: "/app.css" });

const first = await handleMeta(req());
expect(first.status).toBe(200);
expect(meta).toHaveBeenCalledTimes(1);

await handleMeta(req());
expect(meta).toHaveBeenCalledTimes(1);
});

it("shares one in-flight load across concurrent first requests", async () => {
let release!: (v: unknown) => void;
const meta = vi.fn(
() =>
new Promise<never>((r) => {
release = r as never;
}),
);
setMetaLoader(meta as unknown as () => Promise<typeof SCHEMA>);

const all = Promise.all([handleMeta(req()), handleMeta(req()), handleMeta(req())]);
release(SCHEMA);
const responses = await all;

expect(meta).toHaveBeenCalledTimes(1);
for (const r of responses) expect(r.status).toBe(200);
});

it("serves the COMPOSED schema, not the raw input", async () => {
setMetaLoader(() => Promise.resolve(SCHEMA));
const body = (await (await handleMeta(req())).json()) as { framework?: unknown };
// `framework` is composeMeta's own sentinel (it is the field it sets and
// then uses as its idempotency guard), so its presence proves the lazy
// path still composes rather than serving the file through.
expect(body.framework).toBeDefined();
});

it("retries after a failed load instead of latching a dead isolate", async () => {
const meta = vi
.fn<() => Promise<typeof SCHEMA>>()
.mockRejectedValueOnce(new Error("chunk load failed"))
.mockResolvedValueOnce(SCHEMA);
setMetaLoader(meta);

expect((await handleMeta(req())).status).toBe(503);
expect((await handleMeta(req())).status).toBe(200);
expect(meta).toHaveBeenCalledTimes(2);
});

it("503s when no loader and no data were ever provided", async () => {
expect((await handleMeta(req())).status).toBe(503);
});

it("still honors an explicitly set schema, with no loader", async () => {
setMetaData(SCHEMA);
expect((await handleMeta(req())).status).toBe(200);
});

it("keeps explicitly-set data across invalidation — there is nothing to reload", async () => {
// Clearing it would 503 the admin permanently for a site on setMetaData.
setMetaData(SCHEMA);
invalidateMetaCache();
expect((await handleMeta(req())).status).toBe(200);
});

it("re-composes after invalidation when a loader can rebuild it", async () => {
const meta = vi.fn(() => Promise.resolve(SCHEMA));
setMetaLoader(meta);
await handleMeta(req());
invalidateMetaCache();
await handleMeta(req());
expect(meta).toHaveBeenCalledTimes(2);
});

it("answers 304 to a matching If-None-Match", async () => {
setMetaLoader(() => Promise.resolve(SCHEMA));
const etag = (await handleMeta(req())).headers.get("etag")!;
const res = await handleMeta(
new Request("https://site.test/live/_meta", { headers: { "if-none-match": etag } }),
);
expect(res.status).toBe(304);
});
});
22 changes: 13 additions & 9 deletions packages/blocks-admin/src/createAdminSetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,17 @@
* here because these four steps need admin/index.ts's setters, which
* runtime cannot import without creating a circular dependency.
*/
import {
setInvokeLoaders,
setMetaData,
setPreviewWrapper,
setRenderShell,
} from "./admin/index";
import { setInvokeLoaders, setMetaLoader, setPreviewWrapper, setRenderShell } from "./admin/index";

export interface AdminSetupOptions {
/**
* Lazy loader for admin meta schema — only fetched when admin requests it:
* Lazy loader for admin meta schema — only invoked on the first `/live/_meta`
* request, never at boot:
* `() => import("./server/admin/meta.gen.json").then(m => m.default)`
*
* Keep it a dynamic `import()`. A static import would defeat the laziness:
* the module graph would pull the schema in at boot no matter when this thunk
* runs.
*/
meta: () => Promise<any>;

Expand All @@ -43,8 +43,12 @@ export interface AdminSetupOptions {
* createSiteSetup() (@decocms/blocks/setup).
*/
export function createAdminSetup(options: AdminSetupOptions): void {
// 7. Admin meta schema (lazy)
options.meta().then((data) => setMetaData(data));
// 7. Admin meta schema — REGISTERED, not loaded. The schema is a JSON Schema
// bundle covering every section and app, and exactly one route needs it
// (`/live/_meta`). Invoking the thunk here made every isolate parse it at boot
// and pin the composed graph for its whole life, for traffic that never
// touches the admin.
setMetaLoader(options.meta);

// 8. Render shell
setRenderShell({
Expand Down
2 changes: 1 addition & 1 deletion packages/nextjs/src/routeHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ function preflight(request: Request): Response {

/** For app/live/_meta/route.ts: `export { metaGET as GET } from "@decocms/nextjs/routeHandlers"` */
export async function metaGET(request: Request): Promise<Response> {
return withCors(request, handleMeta(request));
return withCors(request, await handleMeta(request));
}

/** For app/.decofile/route.ts (or an equivalent rewritten path — Next.js route
Expand Down
2 changes: 1 addition & 1 deletion packages/tanstack/src/sdk/workerEntry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ export interface SegmentKey {
* (not pulled into the client Vite build).
*/
export interface AdminHandlers {
handleMeta: (request: Request) => Response;
handleMeta: (request: Request) => Response | Promise<Response>;
handleDecofileRead: () => Response;
handleDecofileReload: (request: Request) => Response | Promise<Response>;
handleRender: (request: Request) => Response | Promise<Response>;
Expand Down
20 changes: 20 additions & 0 deletions packages/tanstack/src/vite/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,26 @@ export function decoVitePlugin({ fastDeploy = false } = {}) {
// backward-compatible sites that haven't regenerated).
}

// meta.gen.json — the admin JSON Schema bundle, on the SERVER.
//
// Vite's JSON plugin would turn it into an object literal, which V8 must
// run through the full JS parser. Emitting `JSON.parse("...")` instead
// (same treatment blocks.gen gets, and the same reason) uses V8's fast
// JSON parser and — the part that matters for memory — keeps the schema
// as ONE string until something actually calls the parse. Since
// `createAdminSetup` now only invokes its loader on the first
// `/live/_meta`, an isolate that never serves the admin never
// materializes the object graph at all.
//
// The client is stubbed in `resolveId` above and never reaches here.
if (id.endsWith("meta.gen.json") && options?.ssr) {
if (existsSync(id)) {
const raw = readFileSync(id, "utf-8");
return `export default JSON.parse(${JSON.stringify(raw)});`;
}
// Absent (pre-generate-schema) — let Vite report it normally.
}

// loaders.gen.ts — the site's invoke registry (`export const siteLoaders`).
// It registers every site loader/action behind a dynamic `import()`, so if
// it stays in the CLIENT module graph (it's reachable via
Expand Down