Cookies
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 })
}
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.Cookie options
CookieOptions covers the standard Set-Cookie attributes:
| Option | Type | Default | Description |
|---|---|---|---|
domain | string | none | Domain attribute. Validated against header-injection characters. |
path | string | '/' (via cookie()) | Path attribute. Validated against header-injection characters. |
expires | Date | none | Absolute expiry. An invalid Date throws. |
maxAge | number | none | Relative expiry in seconds (unlike Express, which uses milliseconds). maxAge: 0 is preserved. |
httpOnly | boolean | false | Hides the cookie from document.cookie. |
secure | boolean | see note | Secure attribute. |
sameSite | 'strict' | 'lax' | 'none' | none | SameSite attribute. |
priority | 'low' | 'medium' | 'high' | none | Priority attribute. |
partitioned | boolean | false | Partitioned 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:
nameis not a valid RFC 6265 token.pathordomaincontains CR, LF,;, or control characters (header injection).maxAgeis not finite (NaN,Infinity).expiresis an invalidDate.secure: falseis combined withsameSite: 'none'.partitionedis set without a resolvedSecure.
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
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')