GitHub

Raw Responses

Setting contentType switches a spec from the HTML pipeline to a raw response mode. The view returns the response body directly — no doctype, no hydration script. Security headers and compression still apply.

The basics

Set contentType to any valid MIME type. When present, the normal HTML wrapper (doctype, head, body, hydration script) is skipped. The render function receives (ctx, serverState) and returns a string:

export default {
  route: '/feed.xml',
  contentType: 'application/rss+xml',
  state: {},
  server: {
    data: async () => ({
      posts: await db.posts.latest(20),
    }),
  },
  view: (ctx, server) => `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>My Blog</title>
    <link>https://example.com</link>
    <description>Recent posts</description>
    ${server.posts.map(post => `
    <item>
      <title>${esc(post.title)}</title>
      <link>https://example.com/blog/${post.slug}</link>
      <pubDate>${new Date(post.date).toUTCString()}</pubDate>
      <description>${esc(post.excerpt)}</description>
    </item>
    `).join('')}
  </channel>
</rss>`,
}

JSON API endpoints

export default {
  route: '/api/products',
  contentType: 'application/json',
  state: {},
  server: {
    data: async (ctx) => ({
      products: await db.products.list({
        page:     parseInt(ctx.query.page ?? '1', 10),
        category: ctx.query.category,
      }),
    }),
  },
  view: (ctx, server) => JSON.stringify({
    products: server.products,
    page:     parseInt(ctx.query.page ?? '1', 10),
  }),
}

XML sitemap

export default {
  route: '/sitemap.xml',
  contentType: 'application/xml',
  serverTtl:   3600,
  state: {},
  server: {
    data: async () => ({
      pages: await db.pages.allPublished(),
    }),
  },
  view: (ctx, server) => `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  ${server.pages.map(p => `
  <url>
    <loc>https://example.com${p.path}</loc>
    <lastmod>${p.updatedAt.toISOString().slice(0, 10)}</lastmod>
  </url>
  `).join('')}
</urlset>`,
}

View function signature

For raw responses, the view function signature is (ctx, serverState), not (state, serverState). The ctx argument is the request context object with params, query, headers, and cookies.

There is no client state (state) for raw response specs — they are purely server-side. The state: {} field is still required for spec validation, but is not used.

Escaping in XML and HTML

Pulse does not auto-escape raw response bodies. When returning XML or HTML, escaping all user-supplied and dynamic content is required — unescaped output is an injection vulnerability:

// Simple escape helper for XML/HTML contexts
function esc(str) {
  return String(str)
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;')
}
Never interpolate raw user data or database content into XML/HTML without escaping. This applies to raw response specs just as much as to HTML view functions.

Caching raw responses

Raw response specs support the same serverTtl and cache options as HTML specs. For feeds and sitemaps that update infrequently, a generous TTL dramatically reduces server load:

export default {
  route:       '/feed.xml',
  contentType: 'application/rss+xml',
  serverTtl:   300,   // cache data for 5 minutes
  cache: {
    public:  true,
    maxAge:  300,
  },
  // ...
}