@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'(thehtmltagged template, usable in a plain.tsfile), plus a'custom'arm for any engine that adapts its templates toComponent<P>functions. - Two entry points, one implementation:
@Render(Component)for the class-based style, the freerenderView(ctx, Component, props)for the functional default. Both resolve the SAME engine underCAPABILITIES.VIEWand answer throughIResponse.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:
| Option | Type | Default | Description |
|---|---|---|---|
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 |
view | IViewEngine | — | '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
| Export | Kind |
|---|---|
renderView | function |
ViewPlugin | function |
UnresolvedSuspenseError | class |
ViewRenderError | class |
raw | const |
ViewPluginOptions | type |
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:
| Option | Type | Default | Behavior |
|---|---|---|---|
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. |
view | IViewEngine | — | '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
| Export | Kind | Purpose |
|---|---|---|
ViewPlugin | function | Plugin factory — registers the selected engine under CAPABILITIES.VIEW and a view health indicator reporting it (always up) |
renderView | function | Functional entry point — renderView(ctx, component, props) resolves the engine per request and answers IResponse.html(...) |
raw | function | The escaping opt-out, re-exported from hono/html so an application does not import hono directly |
ViewRenderError | class | A component threw while rendering (original carried as cause), or returned undefined (almost always a missing return) |
UnresolvedSuspenseError | class | The rendered tree holds a pending <Suspense> boundary; buffered rendering would serve only the fallback, so it is refused |
ViewPluginOptions | type | The discriminated options union above |
Behavior notes
- Two entry points, one implementation.
@Render(Component)resolves the engine once atregister()(the token joinsDecoratorPlugin’soptionalDependencies);renderViewresolves it per request. Both answer through the sameIResponse.html(...)write — byte-identical bodies and headers, pinned by an integration test under a non-default configuration. - Buffered by design.
IViewEngine.renderanswersstring | Promise<string>and nothing else. A tree holding a pending<Suspense>boundary is refused withUnresolvedSuspenseErrorrather than served as its fallback with a200; streaming resolution is deferred to a follow-up milestone. An async component WITHOUT<Suspense>renders clean, as does anhtmltemplate 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) => stringcomponent is a validComponent<P>and its output is returned unchanged, so a hand-written template literal escapes nothing — write views with JSX or thehtmltag. 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-levellayoutoption: it would wrap every render, including fragment responses where a full document is wrong.