Skip to content

Programmatic Fetch ​

Invoke route handlers in-process using the Web Standard Request/Response API — no TCP connection, no serialization overhead. The full dispatch pipeline runs: route matching, context creation, composables, error handling.

Why ​

Server-side rendering (SSR), in-process API calls, and testing all need to call route handlers programmatically. Without fetch(), the only options are:

  • Loopback HTTP request — wasteful TCP round-trip for an in-process call
  • Calling handlers directly — bypasses the entire Wooks pipeline (routing, composables, error handling)
  • Duplicating logic in a separate service layer

fetch() and request() solve this by exposing the application as a (Request) → Response function — the same pattern used by Hono, H3/Nitro, and Bun.

Basic Usage ​

app.request() ​

The convenience method for most use cases. Accepts a URL string (relative paths auto-prefixed with http://localhost), plus optional RequestInit:

ts
import { createHttpApp } from '@wooksjs/event-http'

const app = createHttpApp()

app.get('/api/users', () => {
  return [{ id: 1, name: 'Alice' }]
})

// Programmatic invocation — full pipeline, zero TCP
const res = await app.request('/api/users')
const users = await res.json()
// [{ id: 1, name: 'Alice' }]

POST with body:

ts
const res = await app.request('/api/users', {
  method: 'POST',
  body: JSON.stringify({ name: 'Bob' }),
  headers: { 'content-type': 'application/json' },
})

app.fetch() ​

Accepts a full Web Standard Request object. Use this when you need complete control over the request:

ts
const res = await app.fetch(
  new Request('http://localhost/api/users', {
    method: 'POST',
    body: JSON.stringify({ name: 'Bob' }),
    headers: { 'content-type': 'application/json' },
  })
)

Both methods return a standard Response object with status, headers, and body — or null if no route matched (see Unmatched Routes).

SSR Header Forwarding ​

When fetch() is called from within an existing HTTP handler (e.g., during SSR), identity headers are automatically forwarded from the calling request to the programmatic request. This means the inner API call sees the same user session as the page request — no manual header copying needed.

ts
import { useAuthorization } from '@wooksjs/event-http'

app.get('/dashboard', async () => {
  // The user's authorization and cookie headers are
  // automatically forwarded to this inner call
  const res = await app.request('/api/user-data')
  const data = await res.json()
  return renderPage(data)
})

app.get('/api/user-data', () => {
  // useAuthorization() sees the original user's Bearer token
  const { credentials } = useAuthorization()
  return fetchUserData(credentials())
})

Default Forwarded Headers ​

By default, the following headers are forwarded:

  • authorization — auth identity (Bearer tokens, Basic auth)
  • cookie — session state
  • accept-language — user's language preference
  • x-forwarded-for — client IP chain
  • x-request-id — request tracing

Customizing Header Forwarding ​

Configure globally via createHttpApp options:

ts
import { createHttpApp, DEFAULT_FORWARD_HEADERS } from '@wooksjs/event-http'

// Add headers on top of the defaults
const app = createHttpApp({
  forwardHeaders: [...DEFAULT_FORWARD_HEADERS, 'x-custom-auth'],
})

// Forward ONLY these headers (replaces the default list)
const app = createHttpApp({
  forwardHeaders: ['authorization', 'cookie'],
})

// Disable forwarding entirely
const app = createHttpApp({
  forwardHeaders: false,
})

forwardHeaders replaces the defaults

The list you pass replaces the default list — it does not extend it. forwardHeaders: ['x-custom-auth'] silently stops authorization and cookie from being forwarded. To add headers while keeping the defaults, spread the exported DEFAULT_FORWARD_HEADERS constant as shown above.

Header Precedence ​

Headers explicitly set on the programmatic request always override forwarded headers:

ts
app.get('/page', async () => {
  // Even though the page request has authorization,
  // the inner call uses the explicitly provided override
  const res = await app.request('/api/data', {
    headers: { authorization: 'Bearer service-token' },
  })
  return res.json()
})

When an inner API call sets cookies (e.g., refreshing a session token), those cookies are automatically propagated to the outer response. This ensures cookies reach the browser even when set by an inner programmatic call during SSR:

ts
import { useResponse } from '@wooksjs/event-http'

app.get('/dashboard', async () => {
  // If /api/auth/refresh sets a new session cookie,
  // it will appear on the /dashboard response sent to the browser
  await app.request('/api/auth/refresh')
  return renderDashboard()
})

app.get('/api/auth/refresh', () => {
  const response = useResponse()
  response.setCookie('session', newToken, { httpOnly: true })
  return { ok: true }
})

Cookies from multiple inner calls are all collected on the outer response.

Response Isolation ​

Each fetch() invocation creates a fully isolated context. The inner response's status code, headers (except propagated cookies), and body do not affect the outer response:

ts
app.get('/page', async () => {
  const inner = await app.request('/api/might-fail')

  // Inner 404 does not make the page 404
  if (inner.status === 404) {
    return renderNotFound()
  }

  return renderPage(await inner.json())
})

Route parameters, URL, method, and all composables are scoped to each invocation.

Performance Optimizations ​

json() — Zero-Cost Deserialization ​

When a handler returns a plain object, response.json() returns the original object reference — no JSON.stringify → JSON.parse round-trip:

ts
app.get('/api/data', () => {
  return { users: [{ id: 1 }], total: 1 }
})

const res = await app.request('/api/data')
const data = await res.json()
// `data` is the exact same object the handler returned — not a parsed copy

text() — Direct String Access ​

For string responses, response.text() returns the pre-computed string directly without reading the body stream:

ts
app.get('/greeting', () => 'Hello World!')

const res = await app.request('/greeting')
const text = await res.text()  // returns the string directly

Raw ServerResponse Access ​

Handlers that call getRawRes() and write directly to the Node.js ServerResponse work during programmatic fetch. Writes are intercepted and captured into the Web Response:

ts
import { useResponse } from '@wooksjs/event-http'

app.get('/raw', () => {
  const res = useResponse().getRawRes()
  res.writeHead(200, { 'content-type': 'text/plain', 'x-custom': 'value' })
  res.write('chunk 1')
  res.end('chunk 2')
})

const res = await app.request('/raw')
await res.text()  // 'chunk 1chunk 2'
res.headers.get('x-custom')  // 'value'

Limitations of raw access

When using getRawRes() during programmatic fetch, the json() / text() optimizations do not apply — the response body is captured as raw bytes.

Unmatched Routes ​

When no route matches, fetch() and request() return null instead of a 404 response. This lets callers distinguish "no route exists" from "a handler explicitly returned 404":

ts
const res = await app.request('/maybe-exists')
if (!res) {
  // No route matched — handle accordingly
}
if (res.status === 404) {
  // A handler matched but threw HttpError(404) — real 404
}

The route is matched before anything else happens: an unmatched call returns null without creating an event context (no Event:start span from a ContextInjector) and without reading the Request body, so the caller can still pass the same Request on to a real fetch().

onNotFound is ignored by fetch()

The onNotFound option only applies to the HTTP server callback (getServerCb()). Programmatic fetch() always returns null for unmatched routes — the caller decides what to do.

Middleware Integration ​

getServerCb(onNoMatch?) ​

When integrating Wooks with another server (e.g., Vite dev server), pass an onNoMatch callback to getServerCb(). It receives the raw req/res when no Wooks route matches, allowing you to forward the request to the next middleware:

ts
import { createServer } from 'http'
import { createViteServer } from 'vite'
import { createHttpApp } from '@wooksjs/event-http'

const app = createHttpApp()
const vite = await createViteServer({ server: { middlewareMode: true } })

const server = createServer(
  app.getServerCb((req, res) => {
    // No Wooks route matched — let Vite handle it
    vite.middlewares.handle(req, res)
  })
)
app.attachServer(server)
server.listen(3000)

When you create the server yourself, call app.attachServer(server) so app.close() can stop it — app.getServer() returns the attached server later. If your app registers WebSocket upgrade routes, also wire the upgrade event: server.on('upgrade', app.getUpgradeCb()).

When onNoMatch is provided, it takes priority over the onNotFound option. This means you can use both — onNotFound handles 404s for standalone server mode, while onNoMatch bypasses it for middleware integration.

onNoMatch runs outside any Wooks event

In middleware mode the request is routed first. A request no route matches goes straight to onNoMatch(req, res) — before any Wooks event starts:

  • no event context exists inside onNoMatch — current() throws, tryGetCurrent() returns undefined, and composables (useRequest(), useResponse(), …) are unavailable
  • no response wrapper is created, and no ContextInjector span or hook fires (an OpenTelemetry integration sees nothing for host-app requests such as static assets)
  • app.fetch() called from onNoMatch has no caller context, so it forwards no identity headers — wrap the work in withHttpContext() when it needs them

Matched requests get the full pipeline: a response wrapper, an event context with route params, and your handlers.

Without the callback, unmatched routes fall through to onNotFound (if set), or return a 404 response — the standard behavior for standalone servers.

SSR Without Dispatch: withHttpContext() ​

Sometimes the SSR render doesn't go through Wooks route dispatch at all — a Vite middleware owns the (req, res) pair and calls your render() function directly. Without an HTTP context, fetch() calls made during the render have no caller to inherit identity from: no forwarded authorization or cookie, and rate limiting sees the server's own IP instead of the viewer's.

withHttpContext(req, res, fn) runs fn inside an HTTP event context seeded from the real request — without dispatching any route:

ts
const { result: html, response } = await app.withHttpContext(req, res, () => render(req.url!))

// Apply cookies collected during the render (e.g. a session refresh)
for (const cookie of response.getSetCookieStrings()) {
  res.appendHeader('Set-Cookie', cookie)
}
res.setHeader('content-type', 'text/html')
res.end(html)

Inside fn:

  • Request composables work — useRequest(), useHeaders(), useCookies(), useAuthorization() read the real request. Route-scoped state (useRouteParams()) is empty, since no route was matched.
  • Nested fetch() calls see this context as their caller, so header forwarding and cookie propagation apply exactly as they do inside route handlers.

The method awaits fn before returning and never writes to res — the caller owns the wire. The returned response wrapper is created in capture mode, so even a stray response.send() inside fn cannot touch the socket.

Combined with getServerCb(onNoMatch), this wraps SSR page renders in a Vite-style middleware setup:

ts
const server = createServer(
  app.getServerCb(async (req, res) => {
    // No Wooks route matched — SSR render with the viewer's identity
    const { result: html, response } = await app.withHttpContext(req, res, () =>
      render(req.url!)
    )
    for (const cookie of response.getSetCookieStrings()) {
      res.appendHeader('Set-Cookie', cookie)
    }
    res.setHeader('content-type', 'text/html')
    res.end(html)
  })
)

Draining Cookies: getSetCookieStrings() ​

Cookies set during fn — directly via useResponse().setCookie(), or propagated from nested fetch() calls — are buffered on the returned wrapper. Since the wrapper never responds, drain them with response.getSetCookieStrings(): it renders all buffered cookies as Set-Cookie header strings without sending anything. Apply them to your own response before writing the page body.

Non-destructive

getSetCookieStrings() leaves the cookie buffers intact — repeated calls return the same list. It does not include set-cookie values placed directly into headers via setHeader().

Limitations ​

Body is Read Eagerly ​

The request body is fully read into memory before the handler runs. This is fine for typical API payloads (JSON, form data) but not suitable for streaming large file uploads via fetch().

Released under the MIT License.