GitHub

Spec Reference

The spec is a plain JavaScript object that defines a complete contract for a page. Pulse validates every spec at startup and rejects invalid ones before the server accepts connections. At runtime, it enforces state bounds, validation rules, and lifecycle order automatically.

Quick reference

FieldTypeRequiredDescription
routestringYesURL pattern for this page. Supports :param segments.
stateobjectYesInitial client-side state. Deep-cloned on mount.
viewfunctionYesReturns an HTML string. Receives (state, serverState).
metaobjectNoPage metadata: title, description, styles, OG tags, schema.
hydratestringNoBrowser-importable path to this spec file. Enables client hydration.
mutationsobjectNoSynchronous state updaters keyed by name.
actionsobjectNoAsync operations with full lifecycle hooks.
validationobjectNoDeclarative validation rules keyed by dot-path state keys.
constraintsobjectNoMin/max bounds enforced after every mutation.
persiststring[]NoState keys to save in localStorage.
serverobjectNoServer-side data fetcher. Result passed to view as second arg.
storestring[]NoGlobal store keys this page subscribes to. See Global Store.
methodsstring[]NoHTTP methods this page accepts. Default ['GET', 'HEAD']. Add 'POST' etc. to opt in.
streamobjectNoStreaming SSR config: shell + deferred segment names.
cacheobjectNoHTTP cache control headers for the page response.
serverTtlnumberNoSeconds to cache server data in-process.
serverTimeoutnumberNoTimeout in ms for all server fetchers on this page. Overrides the global fetcherTimeout option.
contentTypestringNoOverride response Content-Type. Enables raw (non-HTML) responses.
onViewErrorfunctionNoFallback renderer called when view() throws. Return an HTML string.

route

The URL pattern this spec handles. Supports static segments and dynamic :param segments.

route: '/products/:id'   // matches /products/42
route: '/blog/:year/:slug'

Dynamic segments are available in server data and actions via ctx.params. See Routing for more.

state

The initial client-side state for the page. Always a plain object. Pulse deep-clones it on every mount — mutations never affect the original spec, and state cannot leak between page loads.

state: {
  count: 0,
  user: { name: '', email: '' },
  items: [],
}

The state object is passed as the first argument to view, and as the first argument to every mutation and action hook. See State.

view

A pure function that receives (state, serverState) and returns an HTML string. Side effects are not permitted — the same inputs must always produce the same output. Pulse uses this guarantee to diff and re-render efficiently after mutations.

view: (state, server) => `
  <main>
    <h1>Hello, ${state.name}</h1>
    ${server.items.map(item => `<p>${item.title}</p>`).join('')}
  </main>
`

For streaming SSR, view can be an object of named segment functions. See Streaming SSR.

meta

Page-level metadata. All fields are optional.

meta: {
  title:       'Page Title — Site Name',
  description: 'Meta description for search engines.',
  styles:      ['/app.css', '/page.css'],
  ogTitle:     'Open Graph title',
  ogImage:     'https://example.com/og.jpg',
  schema:      { '@type': 'WebPage', name: 'Page Title' }, // ld+json
}

See Metadata & SEO for the full reference.

hydrate

A browser-importable path to this spec file. Setting this enables client-side hydration — Pulse emits a bootstrap script that imports the spec bundle and calls mount(). In production, the path is resolved automatically via manifest.json.

hydrate: '/src/pages/counter.js'   // dev: source file path
// Production: resolved automatically via manifest.json
Omit hydrate for purely server-rendered pages with no client interactivity. Pulse sends zero JavaScript to the browser — no runtime overhead, no hydration cost.

mutations

Synchronous state updaters. Each mutation is a function (state, event) => partialState. The returned partial object is merged into state. See Mutations.

mutations: {
  increment: (state) => ({ count: state.count + 1 }),
  setName:   (state, event) => ({ name: event.target.value }),
}

Mutations can return _toast to show a notification — it is stripped from state automatically. See Toast notifications.

actions

Async operations with a full lifecycle. Each action has hooks for onStart, optional validate, run, onSuccess, and onError. See Actions.

actions: {
  submit: {
    onStart:   (state, formData) => ({ status: 'loading' }),
    validate:  true,
    run:       async (state, serverState, formData) => {
      const res = await fetch('/api/submit', { method: 'POST', body: formData })
      return res.json()
    },
    onSuccess: (state, payload) => ({ status: 'success', data: payload }),
    onError:   (state, err) => ({
      status: 'error',
      errors: err?.validation ?? [{ message: err.message }],
    }),
  },
}

validation

Declarative rules checked when an action has validate: true. Keys are dot-path strings into state. See Validation.

validation: {
  'fields.email': { required: true, format: 'email' },
  'fields.name':  { required: true, minLength: 2, maxLength: 100 },
  'fields.age':   { required: true, min: 18, max: 120 },
}

constraints

Min/max bounds enforced automatically after every mutation. Constraints cannot be bypassed — the state is clamped before the view re-renders, regardless of what the mutation returns. See Constraints.

constraints: {
  count:    { min: 0, max: 100 },
  quantity: { min: 1, max: 99 },
}

persist

An array of dot-path state keys to persist in localStorage. Values are restored on the next visit. See Persist.

persist: ['theme', 'user.preferences']

server

Server-only data fetching. The result is passed to view as the second argument. Not available on the client. See Server Data.

server: {
  data: async (ctx) => {
    const product = await db.products.find(ctx.params.id)
    return { product }
  }
}

stream

Enables streaming SSR. Declare which named view segments are in the shell (rendered immediately) and which are deferred (streamed when ready). See Streaming SSR.

stream: {
  shell:    ['header', 'nav'],
  deferred: ['feed', 'sidebar'],
}

cache

HTTP Cache-Control header configuration for the page response. See Caching.

cache: {
  public:              true,
  maxAge:              300,       // seconds
  staleWhileRevalidate: 86400,
}

serverTtl

Number of seconds to cache the result of server.data() in-process. Subsequent requests within the TTL skip the async data fetch and re-render the HTML with the cached data. See Caching.

serverTtl: 60  // cache server data for 60 seconds
serverTtl vs cacheserverTtl caches only the server data fetcher results. The HTML is re-rendered on every request (good for personalised pages where only the fetched data is stable). cache caches the complete rendered HTML and sets Cache-Control headers (good for fully public pages that are identical for all users).

serverTimeout

Timeout in milliseconds for all server.* fetchers on this page. If any fetcher does not resolve within this limit, it rejects with a timeout error and the request returns a 500. Use this to prevent a slow DB query or external API from hanging the response indefinitely.

serverTimeout: 5000  // fail after 5 s — overrides createServer fetcherTimeout

A global default applies to all pages via the fetcherTimeout option in createServer. spec.serverTimeout overrides it per page.

onViewError

An optional function called when view() throws at runtime. Return an HTML string to display in place of the crashed view. Without this, the Pulse runtime renders a default inline error message and logs the error to the console.

onViewError: (err, state, serverState) => `
  <div class="u-p-4 u-text-center">
    <p>Something went wrong. <a href="">Reload</a></p>
  </div>
`
On the server, a throwing view propagates to the server's error handler (500 response) unless onViewError is defined — in which case the page renders with the fallback HTML and a 200 status. On the client, the runtime always catches view errors and shows a fallback, whether or not onViewError is defined.

methods

HTTP methods this page accepts. Defaults to ['GET', 'HEAD'] — all other methods return 405. Add 'POST' to handle form submissions or webhooks directly on a page route without a separate API endpoint.

methods: ['GET', 'POST']

Read the method in guard to branch on POST vs GET:

export default {
  route:   '/contact',
  methods: ['GET', 'POST'],

  guard: async (ctx) => {
    if (ctx.method === 'POST') {
      const data = await ctx.formData()
      if (!data?.email) return { status: 422, json: { error: 'Email required' } }
      await db.leads.create(data)
      return { redirect: '/contact?sent=1' }
    }
  },

  state: {},
  view:  (state) => `<form method="POST">...</form>`,
}
For raw response specs (contentType set), all HTTP methods are accepted by default — methods has no effect. Use ctx.method inside render to branch.

contentType

Override the response Content-Type. When set, the view function receives (ctx, serverState) and returns the raw response body — the normal HTML wrapper is bypassed. See Raw Responses.

contentType: 'application/rss+xml'