Add buildPaginationModel and ctx.assetHref, and bump the plugin contract to 0.5.0
CI / full-gate (push) Failing after 5s
CI / full-gate (push) Failing after 5s
This commit is contained in:
@@ -392,6 +392,22 @@ test("renders the 500 HTML page when a handler throws", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("a plugin versions the /public/ files its own markup links, through the assetHref view local or ctx.assetHref", async (t) => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "pp-plugins-"));
|
||||
mkdirSync(join(dir, "drag", "views"), { recursive: true });
|
||||
mkdirSync(join(dir, "drag", "public"), { recursive: true });
|
||||
writeFileSync(join(dir, "drag", "views", "page.ejs"), `<script src="<%= assetHref("/public/drag/board.js") %>"></script><img src="<%= logo %>" alt="">`);
|
||||
writeFileSync(join(dir, "drag", "public", "board.js"), "void 0;");
|
||||
writeFileSync(join(dir, "drag", "public", "logo.svg"), "<svg/>");
|
||||
t.after(() => rmSync(dir, { force: true, recursive: true }));
|
||||
const drag: Plugin = { apiVersion: "1.0.0", id: "drag", routes: [{ handler: (ctx) => ({ data: { logo: ctx.assetHref("/public/drag/logo.svg") }, view: "page" }), method: "GET", path: "/page", public: true }] };
|
||||
const url = await startApp(t, [drag], dir);
|
||||
|
||||
const html = await (await fetch(url + "/drag/page")).text();
|
||||
assert.match(html, /src="\/public\/drag\/board\.js\?v=[\w-]+"/);
|
||||
assert.match(html, /src="\/public\/drag\/logo\.svg\?v=[\w-]+"/);
|
||||
});
|
||||
|
||||
// Every /public/ reference in the page, as written in the HTML.
|
||||
const publicRefs = (html: string): string[] => [...html.matchAll(/(?:href|src)="(\/public\/[^"]*)"/g)].map((m) => m[1] ?? "");
|
||||
|
||||
|
||||
+2
-2
@@ -268,9 +268,9 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
|
||||
// Base context (no route params), for the built-in routes. Every plugin-owned render — a
|
||||
// landing slot, a hook short-circuit, a plugin route — gets `contextFor(id)` instead.
|
||||
const ctx = buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, declaredSettings: settingsCatalog, user, ...i18nFor(), log: reqLog, verifyCsrf, ...(system ? { system } : {}) });
|
||||
const ctx = buildContext(req, res, { assetHref: assets.href, chrome, declaredPermissions: permissionCatalog, declaredSettings: settingsCatalog, user, ...i18nFor(), log: reqLog, verifyCsrf, ...(system ? { system } : {}) });
|
||||
const contextFor = (pluginId: string, params?: Record<string, string>): RequestContext =>
|
||||
buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, declaredSettings: settingsCatalog, user, ...i18nFor(pluginId), log: reqLog, ...(params ? { params } : {}), verifyCsrf, ...(system ? { system } : {}) });
|
||||
buildContext(req, res, { assetHref: assets.href, chrome, declaredPermissions: permissionCatalog, declaredSettings: settingsCatalog, user, ...i18nFor(pluginId), log: reqLog, ...(params ? { params } : {}), verifyCsrf, ...(system ? { system } : {}) });
|
||||
renderPage = viewsFor(ctx);
|
||||
|
||||
// Plugin onRequest hooks run before routing and may short-circuit the request.
|
||||
|
||||
@@ -22,6 +22,10 @@ export interface User {
|
||||
}
|
||||
|
||||
export interface RequestContext {
|
||||
// A /public/ href with `?v=<content hash>`, so the browser caches the file for a year; any other
|
||||
// href, or one already carrying a query or fragment, comes back unchanged. The same function is
|
||||
// the `assetHref` view local, for hrefs a handler builds into `data`.
|
||||
assetHref(href: string): string;
|
||||
// Page chrome (brand/global-nav/user/theme/csrf) a plugin view hands to partials/shell so its
|
||||
// page renders the native app shell; the host builds it per request (anonymous default otherwise).
|
||||
chrome: PageChrome;
|
||||
@@ -66,6 +70,7 @@ export interface RequestContext {
|
||||
}
|
||||
|
||||
export interface BuildContextOptions {
|
||||
assetHref?: (href: string) => string;
|
||||
// Lazy chrome factory: composing the global menu is only paid for if the handler actually reads
|
||||
// ctx.chrome (a json/redirect handler, or the public "/" with a standalone home, pays nothing).
|
||||
// The host's factory is memoised, so the menu composes at most once per request across contexts.
|
||||
@@ -99,6 +104,7 @@ export function buildContext(
|
||||
const buildChrome = options.chrome;
|
||||
let chromeMemo: PageChrome | undefined; // resolve the factory at most once per context
|
||||
return {
|
||||
assetHref: options.assetHref ?? ((href) => href),
|
||||
get chrome(): PageChrome { return (chromeMemo ??= buildChrome ? buildChrome() : ANON_CHROME); },
|
||||
declaredPermissions: options.declaredPermissions ?? [],
|
||||
declaredSettings: options.declaredSettings ?? [],
|
||||
|
||||
+5
-6
@@ -50,12 +50,11 @@ export function routePublic(restPath: string, publicDir: string, pluginsDir: str
|
||||
return { dir: publicDir, subPath: restPath };
|
||||
}
|
||||
|
||||
// Caching: the host renders every /public/ href through assetHref, which appends `?v=<hash of the
|
||||
// file's content>`. A request whose `v` is the current hash is cached for a year as immutable, so a
|
||||
// repeat page view downloads no asset; a changed file gets a new hash, and with it a new URL. Any other
|
||||
// request — an href a plugin's own markup emits, a stale `v` mid-deploy — is cached for five minutes,
|
||||
// then revalidated against the ETag (that same hash) or Last-Modified. A plugin has no way to version
|
||||
// its own hrefs yet: `assetHref` is a reserved local but not contract, since offering it is a minor.
|
||||
// Caching: every /public/ href rendered through assetHref (the host's, and a plugin's through the view
|
||||
// local or ctx.assetHref) gets `?v=<hash of the file's content>`. A request whose `v` is the current
|
||||
// hash is cached for a year as immutable, so a repeat page view downloads no asset; a changed file gets
|
||||
// a new hash, and with it a new URL. Any other request — an unwrapped href, a stale `v` mid-deploy — is
|
||||
// cached for five minutes, then revalidated against the ETag (that same hash) or Last-Modified.
|
||||
const IMMUTABLE = "public, max-age=31536000, immutable";
|
||||
const SHORT = "public, max-age=300";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user