Cookies

Read incoming cookies and set response cookies with the ctx.cookies jar.

MiiaJS exposes cookies through two surfaces on the request context: ctx.cookies, a small jar for reading incoming cookies and writing response cookies, and ctx.res.cookie() / ctx.res.deleteCookie() on the fluent ResponseBuilder. Both write paths share the same serialization and validation.

@Get('/session')
session(ctx: RequestContext) {
  const current = ctx.cookies.get('sid')
  ctx.cookies.set('sid', 'abc123', { httpOnly: true, sameSite: 'lax', maxAge: 3600 })
  return { previous: current ?? null }
}

Reading cookies

The jar parses the incoming Cookie header lazily on first access and caches the result for the rest of the request. Values are decodeURIComponent-decoded (falling back to the raw value on malformed encoding), and surrounding double quotes are stripped. When a name appears more than once, the first occurrence wins.

@Get('/whoami')
whoami(ctx: RequestContext) {
  const sid = ctx.cookies.get('sid')          // string | undefined
  const all = ctx.cookies.getAll()            // Record<string, string>
  const loggedIn = ctx.cookies.has('sid')     // boolean
  return { sid, loggedIn, count: Object.keys(all).length }
}

get() and has() read from an internal null-prototype cache, so cookie names that collide with Object.prototype members (toString, constructor, __proto__) are treated as honest own properties rather than inherited ones. getAll() returns a snapshot copy as an ordinary plain object - mutating the returned object does not affect later reads from the jar.

Setting cookies

ctx.res.cookie(name, value, options?) appends a Set-Cookie header. It returns this, so it chains with the rest of the ResponseBuilder API. ctx.cookies.set() is the equivalent shorthand and delegates to the same method.

@Post('/login')
login(ctx: RequestContext) {
  return ctx.res
    .cookie('sid', 'abc123', { httpOnly: true, secure: true, sameSite: 'strict' })
    .json({ ok: true })
}

Multiple calls accumulate - each cookie becomes its own Set-Cookie header rather than overwriting the previous one. The value is encodeURIComponent-encoded automatically. path defaults to '/' at this layer, so a cookie is visible across the whole site unless you narrow it.

Deleting cookies

ctx.res.deleteCookie(name, { path?, domain? }) (or ctx.cookies.delete()) expires a cookie by emitting an epoch Expires and Max-Age=0.

@Post('/logout')
logout(ctx: RequestContext) {
  return ctx.res.deleteCookie('sid').json({ ok: true })
}
A browser only removes a cookie when the deletion's path and domain match the ones the cookie was set with. If you set a cookie with path: '/admin', you must delete it with the same path - deleteCookie('sid') alone (which uses path: '/') will not clear it.

CookieOptions covers the standard Set-Cookie attributes:

OptionTypeDefaultDescription
domainstringnoneDomain attribute. Validated against header-injection characters.
pathstring'/' (via cookie())Path attribute. Validated against header-injection characters.
expiresDatenoneAbsolute expiry. An invalid Date throws.
maxAgenumbernoneRelative expiry in seconds (unlike Express, which uses milliseconds). maxAge: 0 is preserved.
httpOnlybooleanfalseHides the cookie from document.cookie.
securebooleansee noteSecure attribute.
sameSite'strict' | 'lax' | 'none'noneSameSite attribute.
priority'low' | 'medium' | 'high'nonePriority attribute.
partitionedbooleanfalsePartitioned attribute (CHIPS).
sameSite: 'none' auto-enables Secure, because browsers reject a SameSite=None cookie that is not also Secure (normative in RFC 6265bis). Passing secure: false together with sameSite: 'none' throws a TypeError rather than emitting a cookie the browser would silently discard.

priority is a non-standard Chromium extension (from an expired 2016 IETF draft); other browsers ignore it. partitioned opts a cookie into CHIPS (Cookies Having Independent Partitioned State), shipped in Chrome, Firefox, and Safari, and requires Secure - otherwise it throws.

Validation

Serialization is strict: instead of emitting a malformed or browser-rejected cookie, it throws a TypeError. The conditions are:

  • name is not a valid RFC 6265 token.
  • path or domain contains CR, LF, ;, or control characters (header injection).
  • maxAge is not finite (NaN, Infinity).
  • expires is an invalid Date.
  • secure: false is combined with sameSite: 'none'.
  • partitioned is set without a resolved Secure.
There is no size limit enforced - browsers guarantee at least 4096 bytes per cookie, and MiiaJS does not truncate. Keep session data small or store it server-side.

Low-level primitives

For custom scenarios, @miiajs/core exports the serialization and parsing functions directly. serializeCookie(name, value, options?) returns a Set-Cookie string and, unlike ctx.res.cookie(), does not default path. parseCookieHeader(header) returns a null-prototype Record<string, string>.

import { serializeCookie, parseCookieHeader } from '@miiajs/core'

const header = serializeCookie('sid', 'abc123', { httpOnly: true, maxAge: 3600 })
const jar = parseCookieHeader('a=1; b=2')  // { a: '1', b: '2' }, null prototype

Integration notes

Returning a raw Response from a handler bypasses ctx.res entirely, so cookies you set through the jar or the builder are dropped. When you return a native Response, set Set-Cookie on that response directly.

A cookie you write during a request is not visible through ctx.cookies.get() in that same request - the jar only reflects the incoming Cookie header, never your pending writes. Response cookies are marked on the ResponseBuilder, so they survive the optimized fast path and remain set even when a handler throws and the framework produces an error response.

In tests, read outgoing cookies with res.headers.getSetCookie(), which returns an array of the individual Set-Cookie headers:

const res = await app.request('POST', '/login')
const cookies = res.headers.getSetCookie()
expect(cookies[0]).toContain('sid=abc123')