@setu-ts/view-plugin

Server-rendered HTML as a capability: register a view engine, name a component, answer with markup the application did not concatenate by hand.

Overview

  • Views are named by reference — a component the application already has, never a path. There is no view resolver, no views directory, and no filesystem lookup, which is what makes the capability Workers-portable by construction.
  • Two zero-new-dependency arms: 'hono-jsx' (default, JSX components) and 'hono-html' (the html tagged template, usable in a plain .ts file), plus a 'custom' arm for any engine that adapts its templates to Component<P> functions.
  • Two entry points, one implementation: @Render(Component) for the class-based style, the free renderView(ctx, Component, props) for the functional default. Both resolve the SAME engine under CAPABILITIES.VIEW and answer through IResponse.html(...) (text/html; charset=utf-8).

Installation

setu add view writes the dependency and JSX setup below. For styling and the Workers limit, see styling server-rendered views.

npm install @setu-ts/view-plugin hono
# Deno: deno add npm:@setu-ts/view-plugin npm:hono

Declare hono directly. The default 'hono-jsx' arm compiles JSX through hono/jsx/jsx-runtime, and the 'hono-html' arm’s components import hono/html — both resolved by the application’s own compiler, not by this package. This package depends on hono, so a hoisting installer (npm, Bun) happens to make it resolvable; pnpm’s strict layout and a Deno import map do not, and hono/jsx/jsx-runtime fails to resolve on the first component. Add the JSX transform to the application manifest alongside it:

{
  "compilerOptions": {
    "jsx": "react-jsx",
    "jsxImportSource": "hono/jsx"
  }
}

A project using only the 'hono-html' arm needs hono but neither compiler option.

Usage

Register the plugin, then render from a route:

import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { ViewPlugin } from '@setu-ts/view-plugin';

const app = createApplication({
  plugins: [RuntimePlugin(), ViewPlugin()],
});

await app.start({ port: 3000 });

A functional route renders a component and answers HTML:

import type { IRequestContext } from '@setu-ts/common';
import { renderView } from '@setu-ts/view-plugin';

const UserList = (props: { readonly users: readonly string[] }) => (
  <ul>{props.users.map((user) => <li>{user}</li>)}</ul>
);

export function usersRoute(ctx: IRequestContext) {
  return renderView(ctx, UserList, { users: ['ada', 'grace'] });
}

The class-based style attaches the component to the route; the handler returns the component’s PROPS, and the framework answers with the rendered markup — never JSON:

import { Controller, Get, Render } from '@setu-ts/decorator-plugin';

const UserList = (props: { readonly users: readonly string[] }) => (
  <ul>{props.users.map((user) => <li>{user}</li>)}</ul>
);

@Controller('/pages')
class PagesController {
  @Render(UserList)
  @Get('/users')
  users() {
    return { users: ['ada', 'grace'] };
  }
}

Components are JSX, so their module is .tsx and the application manifest declares jsx / jsxImportSource and depends on hono (see Installation) — the controller itself may stay .ts and import them. If you would rather not add a JSX toolchain at all, ViewPlugin({ engine: 'hono-html' }) takes components written with hono’s html tagged template in a plain .ts file:

import { html } from 'hono/html';

const UserList = (props: { readonly users: readonly string[] }) =>
  html`<ul>${props.users.map((user) => html`<li>${user}</li>`)}</ul>`;

Both escape every interpolation, and the same engine renders both — the arm names the authoring mode, not a rendering strategy. What is NOT safe is a plain template literal: it is a string, so it is returned unchanged and nothing escapes it (see Escaping).

@Render type-checks the handler’s return against the component’s props: a wrong props bag is a compile error naming the mismatch — strictly stronger than a by-name template checked against nothing. A rendered route whose application registers no CAPABILITIES.VIEW provider fails at register(), naming the controller, the handler and both remedies. The check is per route, so an application with no rendered route needs no view plugin.

Options

ViewPluginOptions is a union discriminated on engine, so a missing per-arm field is a compile error rather than a startup throw:

OptionTypeDefaultDescription
engine'hono-jsx''hono-jsx'Components are JSX functions. The application manifest declares jsx / jsxImportSource
engine'hono-html'—Components return an html tagged template; works in a plain .ts file
engine'custom'—Requires view; the supplied engine is registered verbatim
viewIViewEngine—'custom' arm only — the application’s own engine

Both values name the authoring mode — which import your components use — not a rendering strategy. One engine serves both, because escaping belongs to the rendering runtime; the selected mode is reported by the view health indicator.

Rendering nothing

A top-level return follows the rendering runtime’s own rules, so the usual conditional idiom works unchanged:

// Renders the banner when `show`, and nothing at all when not.
const Banner = (props: { readonly show: boolean }) =>
  props.show && `<div class="banner">Sale</div>`;

null, false, true and '' render as the empty string; 0 renders as 0. undefined is refused with ViewRenderError — it is almost always a missing return, so serving an empty page for it would hide the mistake.

Escaping

Escaping is ON through both default arms, and no escaping logic lives in this package — the JSX runtime and the html tagged template do it. The documented opt-out is hono’s own raw(), re-exported from this package’s barrel so an application does not import hono directly:

The escaping is the runtime’s, not this package’s — a plain string component gets none. Component<P> is structural, so (props) => string is valid and its output is returned unchanged. That is right for a by-name engine whose compiled template already escaped, and an XSS hole for a hand-written literal like (p) => `<p>${p.name}</p>`. Write views with JSX or the html tag.

import { html } from 'hono/html';
import { raw } from '@setu-ts/view-plugin';

// UNCHECKED-EXEMPT: this fence IS the opt-out's documentation — raw() is the
// feature being shown, and the probe's raw() stub hides exactly what it must.
const Snippet = (props: { readonly markup: string }) => html`<div>${raw(props.markup)}</div>`;

The boundary has one exception-shaped hole worth naming: escaping protects HTML structure, not URL schemes. A javascript:alert(1) carries none of the characters the escape pass rewrites, so it survives both arms intact and executes when the link is clicked. This is a difference between rendering runtimes — React’s server renderer rewrites the same attribute, hono’s passes it through — so a correct-for-React mental model is wrong here. The remedy is application-side: validate a user-supplied URL’s scheme (http: / https:) before it reaches an href, src or action, and keep a CSP without 'unsafe-inline' as the defence in depth. The full worked example and the repository’s own check live in docs/mvc.md.

Suspense is refused, not served as a fallback

Rendering buffers to a string. A tree holding a pending <Suspense> boundary would silently serve only the fallback — with a 200 and no error — so the engine refuses it by name with UnresolvedSuspenseError, pointing at the deferred streaming milestone. An async component WITHOUT Suspense renders clean; only the boundary is refused. The remedy today is to move the boundary out of the rendered tree.

Layouts are components

A layout is an ordinary component taking children. There is deliberately no plugin-level layout option: it would wrap every render, including the HTMX-style fragments and partial responses IResponse.html already serves, where a full document is the wrong answer.

Health Indicator

The plugin registers a view indicator reporting the selected engine. It is always up — rendering is stateless and touches no backend, and a probe must not fabricate reachability it cannot observe.

Exports

ExportKind
renderViewfunction
ViewPluginfunction
UnresolvedSuspenseErrorclass
ViewRenderErrorclass
rawconst
ViewPluginOptionstype

Generated from the package barrel by pnpm run docs:exports; pnpm run check:docs fails when it drifts.

Full API

Every export and option is documented in PUBLIC_API.md.

API reference

View Plugin (@setu-ts/view-plugin)

Server-rendered HTML as a capability, shipped in Milestone 92. The plugin registers an IViewEngine under CAPABILITIES.VIEW; the view is named BY REFERENCE — a component the application already has — so there is no view resolver, no views directory and no filesystem lookup, which makes the capability Workers-portable by construction.

Registration

import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { ViewPlugin } from '@setu-ts/view-plugin';

const app = createApplication({
  plugins: [
    RuntimePlugin(),
    ViewPlugin(), // or ViewPlugin({ engine: 'hono-html' })
  ],
});

Options

ViewPluginOptions is a union discriminated on engine, so a missing per-arm field is a compile error rather than a startup throw:

OptionTypeDefaultBehavior
engine'hono-jsx''hono-jsx'Components are JSX functions; the application manifest declares jsx / jsxImportSource.
engine'hono-html'—Components return an html tagged template; needs no jsxImportSource, works in a plain .ts.
engine'custom'—Requires view; the supplied engine is registered verbatim.
viewIViewEngine—'custom' arm only — the application’s own engine.

'hono-jsx' and 'hono-html' name the authoring mode, not a rendering strategy: both return values funnel through one engine, because escaping belongs to the rendering runtime and there is nothing left to configure per mode. The selected mode is reported by the view health indicator.

Redirecting from a rendered route. A @Render handler may return a HandlerResult from ctx.response instead of the props bag, so the standard form pattern works: return the props to re-render the form carrying validation errors, or ctx.response.redirect(target, 303) once the submission is accepted. The redirect short-circuits before the view runs, so no HTML body is produced. HandlerResult is branded, so widening the return union costs no type safety — a props bag of the wrong shape is still a compile error.

Rendering nothing. A component’s top-level return follows the rendering runtime’s own rules, so (props) => props.show && <Banner /> behaves at the top level exactly as it does nested: null, false, true and '' render as the empty string, while 0 renders as 0. undefined is the one exception — it is refused with ViewRenderError, because it is almost always a missing return rather than a deliberate empty render.

Exports

ExportKindPurpose
ViewPluginfunctionPlugin factory — registers the selected engine under CAPABILITIES.VIEW and a view health indicator reporting it (always up)
renderViewfunctionFunctional entry point — renderView(ctx, component, props) resolves the engine per request and answers IResponse.html(...)
rawfunctionThe escaping opt-out, re-exported from hono/html so an application does not import hono directly
ViewRenderErrorclassA component threw while rendering (original carried as cause), or returned undefined (almost always a missing return)
UnresolvedSuspenseErrorclassThe rendered tree holds a pending <Suspense> boundary; buffered rendering would serve only the fallback, so it is refused
ViewPluginOptionstypeThe discriminated options union above

Behavior notes

  • Two entry points, one implementation. @Render(Component) resolves the engine once at register() (the token joins DecoratorPlugin’s optionalDependencies); renderView resolves it per request. Both answer through the same IResponse.html(...) write — byte-identical bodies and headers, pinned by an integration test under a non-default configuration.
  • Buffered by design. IViewEngine.render answers string | Promise<string> and nothing else. A tree holding a pending <Suspense> boundary is refused with UnresolvedSuspenseError rather than served as its fallback with a 200; streaming resolution is deferred to a follow-up milestone. An async component WITHOUT <Suspense> renders clean, as does an html template with an async interpolation — the refusal has no false positive.
  • Escaping is on for both default arms, and is the RUNTIME’s doing rather than the plugin’s. A plain (props) => string component is a valid Component<P> and its output is returned unchanged, so a hand-written template literal escapes nothing — write views with JSX or the html tag. Both default arms escape interpolations through their rendering runtime; raw() is the documented opt-out. Rendered output is always a primitive string.
  • Layouts are components. A layout is an ordinary component taking children. There is no plugin-level layout option: it would wrap every render, including fragment responses where a full document is wrong.

Agent card · TypeDoc API reference