A publish.html handler that returns null blanks every page it touches — 200, content-type: text/html, zero-byte body.
Reproduce: register a filter that returns null for the "leave it alone" branch, then publish.
hooks.filter('publish.html', (html) => {
if (!needsRewrite(html)) return null
return rewrite(html)
})
The declared type is string (src/core/plugin-sdk/types/hooks.ts:53, handler signature at :95-105), so a TypeScript author is protected. The shipped plugin template is plain JavaScript, though — examples/plugins/template/server/index.js, with no .ts counterpart — so on the documented authoring path nothing catches it.
Why it gets through. Three places could stop it; none do.
-
The VM's dispatch restores the previous value only for undefined (server/plugins/quickjs/bootstrap/src/boundary.ts:31-33):
export function toJson(value: unknown, fallback?: unknown): string {
return JSON.stringify(value === undefined ? fallback : value)
}
Called as toJson(next, value) at server/plugins/quickjs/bootstrap/src/pluginRuntime.ts:212-223. null serialises to "null" and survives.
-
hookBus.applyFilter has no guard, and return current as T (src/core/plugins/hookBus.ts:183) launders null back into a Promise<string>.
-
applyPublishedHtmlPipeline returns it verbatim as the page HTML (server/publish/publishedHtmlPipeline.ts:61-70).
Then it fails twice, and neither failure names the plugin.
- Bake.
writeArtefact throws ERR_INVALID_ARG_TYPE: The "data" argument must be of type string or an instance of Buffer… (server/publish/staticArtefact.ts:242-253). server/publish/publishSite.ts:283-285 catches it per page, logs failed to bake artefact for … (falls through to live renderer), and carries on — so the publish reports success and the log reads like a disk problem.
- Serve. With no Layer A artefact the request falls through to the live render, which runs the same pipeline, gets
null again, and returns new Response(null, …) — a 200 with an empty body (server/publish/publicRouter.ts:272-277; same shape at :336-340 and server/handlers/cms/data/preview.ts:132-145).
The codebase already guards this exact class elsewhere. media.url.transform checks what came back instead of trusting it (server/publish/mediaPresentation.ts:59-62):
const next = await hookBus.applyFilter<TransformFilterPayload>('media.url.transform', payload)
return typeof next?.path === 'string' ? next.path : payload.path
and its VM side normalises too (pluginRuntime.ts:313: return toJson(typeof next === 'string' ? next : null)). publish.html and content.entry.cells (server/publish/contentEvents.ts:70-84) have neither.
Suggested fix: treat a result of the wrong type the way a thrown handler is already treated in applyFilter (hookBus.ts:179-181) — keep the previous value and console.error the offending plugin id. One guard covers publish.html and content.entry.cells, and it gives the author the thing that is missing today: a message that points at their plugin instead of at the filesystem.
src/__tests__/plugins/pluginHookBus.test.ts covers chaining (:25-29) and the throw fallback (:43-49) but has no wrong-type case; that looks like the place for the regression test.
Happy to send the PR if the applyFilter-level guard is the shape you want.
Verified at 6b055cf7 (v0.0.16-3).
A
publish.htmlhandler that returnsnullblanks every page it touches — 200,content-type: text/html, zero-byte body.Reproduce: register a filter that returns
nullfor the "leave it alone" branch, then publish.The declared type is
string(src/core/plugin-sdk/types/hooks.ts:53, handler signature at:95-105), so a TypeScript author is protected. The shipped plugin template is plain JavaScript, though —examples/plugins/template/server/index.js, with no.tscounterpart — so on the documented authoring path nothing catches it.Why it gets through. Three places could stop it; none do.
The VM's dispatch restores the previous value only for
undefined(server/plugins/quickjs/bootstrap/src/boundary.ts:31-33):Called as
toJson(next, value)atserver/plugins/quickjs/bootstrap/src/pluginRuntime.ts:212-223.nullserialises to"null"and survives.hookBus.applyFilterhas no guard, andreturn current as T(src/core/plugins/hookBus.ts:183) laundersnullback into aPromise<string>.applyPublishedHtmlPipelinereturns it verbatim as the page HTML (server/publish/publishedHtmlPipeline.ts:61-70).Then it fails twice, and neither failure names the plugin.
writeArtefactthrowsERR_INVALID_ARG_TYPE: The "data" argument must be of type string or an instance of Buffer…(server/publish/staticArtefact.ts:242-253).server/publish/publishSite.ts:283-285catches it per page, logsfailed to bake artefact for … (falls through to live renderer), and carries on — so the publish reports success and the log reads like a disk problem.nullagain, and returnsnew Response(null, …)— a 200 with an empty body (server/publish/publicRouter.ts:272-277; same shape at:336-340andserver/handlers/cms/data/preview.ts:132-145).The codebase already guards this exact class elsewhere.
media.url.transformchecks what came back instead of trusting it (server/publish/mediaPresentation.ts:59-62):and its VM side normalises too (
pluginRuntime.ts:313:return toJson(typeof next === 'string' ? next : null)).publish.htmlandcontent.entry.cells(server/publish/contentEvents.ts:70-84) have neither.Suggested fix: treat a result of the wrong type the way a thrown handler is already treated in
applyFilter(hookBus.ts:179-181) — keep the previous value andconsole.errorthe offending plugin id. One guard coverspublish.htmlandcontent.entry.cells, and it gives the author the thing that is missing today: a message that points at their plugin instead of at the filesystem.src/__tests__/plugins/pluginHookBus.test.tscovers chaining (:25-29) and the throw fallback (:43-49) but has no wrong-type case; that looks like the place for the regression test.Happy to send the PR if the
applyFilter-level guard is the shape you want.Verified at
6b055cf7(v0.0.16-3).