Skip to content

A publish.html filter returning null skips the bake and serves an empty 200 for every page #394

Description

@asachs01

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.

  1. 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.

  2. hookBus.applyFilter has no guard, and return current as T (src/core/plugins/hookBus.ts:183) launders null back into a Promise<string>.

  3. 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions