| … | … | @@ -1,4 +1,4 @@ |
| 1 | 1 | // Server-side exports |
| 2 | export { Router, json, html, readBody, readJson } from './lib/router.js' | |
| 2 | export { Router, json, html, readBody, readJson, parsePagination } from './lib/router.js' | |
| 3 | 3 | export { authMiddleware, createChallenge, verifyChallenge } from './lib/auth.js' |
| 4 | 4 | export { serveStatic } from './lib/static.js' |
| … | … | @@ -14,9 +14,11 @@ |
| 14 | 14 | */ |
| 15 | 15 | |
| 16 | 16 | export class Router { |
| 17 | constructor (prefix = '') { | |
| 17 | constructor (prefix = '', opts = {}) { | |
| 18 | 18 | this.prefix = prefix |
| 19 | 19 | this._routes = [] |
| 20 | this._cors = opts.cors === true | |
| 21 | this._corsOrigin = opts.corsOrigin || '*' | |
| 20 | 22 | } |
| 21 | 23 | |
| 22 | 24 | get (pattern, handler, opts) { this._routes.push({ method: 'GET', pattern, handler, opts }) } |
| … | … | @@ -29,6 +31,14 @@ export class Router { |
| 29 | 31 | const url = req.url.replace(this.prefix, '') || '/' |
| 30 | 32 | const [pathStr, queryString] = url.split('?') |
| 31 | 33 | |
| 34 | // CORS preflight | |
| 35 | if (this._cors && method === 'OPTIONS') { | |
| 36 | setCorsHeaders(res, this._corsOrigin) | |
| 37 | res.writeHead(204) | |
| 38 | res.end() | |
| 39 | return true | |
| 40 | } | |
| 41 | ||
| 32 | 42 | for (const route of this._routes) { |
| 33 | 43 | if (route.method !== method) continue |
| 34 | 44 | const params = match(route.pattern, pathStr) |
| … | … | @@ -49,15 +59,15 @@ export class Router { |
| 49 | 59 | |
| 50 | 60 | if (wantsHtml && route.opts && route.opts.render) { |
| 51 | 61 | // SSR: render HTML from data |
| 52 | html(res, route.opts.render(data)) | |
| 62 | html(res, route.opts.render(data), 200, this._cors ? this._corsOrigin : null) | |
| 53 | 63 | } else { |
| 54 | 64 | // JSON (default) |
| 55 | json(res, data) | |
| 65 | json(res, data, 200, this._cors ? this._corsOrigin : null) | |
| 56 | 66 | } |
| 57 | 67 | } |
| 58 | 68 | } catch (err) { |
| 59 | 69 | if (!res.writableEnded) { |
| 60 | json(res, { error: err.message }, 500) | |
| 70 | json(res, { error: err.message }, 500, this._cors ? this._corsOrigin : null) | |
| 61 | 71 | } |
| 62 | 72 | } |
| 63 | 73 | return true |
| … | … | @@ -95,27 +105,36 @@ function parseQuery (qs) { |
| 95 | 105 | } |
| 96 | 106 | |
| 97 | 107 | // Response helpers |
| 98 | export function json (res, data, status = 200) { | |
| 108 | export function json (res, data, status = 200, corsOrigin = null) { | |
| 99 | 109 | const body = JSON.stringify(data) |
| 100 | res.writeHead(status, { | |
| 110 | const headers = { | |
| 101 | 111 | 'Content-Type': 'application/json', |
| 102 | 112 | 'Content-Length': Buffer.byteLength(body) |
| 103 | }) | |
| 113 | } | |
| 114 | if (corsOrigin) headers['Access-Control-Allow-Origin'] = corsOrigin | |
| 115 | res.writeHead(status, headers) | |
| 104 | 116 | res.end(body) |
| 105 | 117 | } |
| 106 | 118 | |
| 107 | export function html (res, content, status = 200) { | |
| 119 | export function html (res, content, status = 200, corsOrigin = null) { | |
| 108 | 120 | const body = typeof content === 'string' ? content : String(content) |
| 109 | res.writeHead(status, { | |
| 121 | const headers = { | |
| 110 | 122 | 'Content-Type': 'text/html; charset=utf-8', |
| 111 | 123 | 'Content-Length': Buffer.byteLength(body) |
| 112 | }) | |
| 124 | } | |
| 125 | if (corsOrigin) headers['Access-Control-Allow-Origin'] = corsOrigin | |
| 126 | res.writeHead(status, headers) | |
| 113 | 127 | res.end(body) |
| 114 | 128 | } |
| 115 | 129 | |
| 116 | export async function readBody (req) { | |
| 130 | export async function readBody (req, maxSize = 10 * 1024 * 1024) { | |
| 117 | 131 | const chunks = [] |
| 118 | for await (const chunk of req) chunks.push(chunk) | |
| 132 | let size = 0 | |
| 133 | for await (const chunk of req) { | |
| 134 | size += chunk.length | |
| 135 | if (size > maxSize) throw new Error('Body too large') | |
| 136 | chunks.push(chunk) | |
| 137 | } | |
| 119 | 138 | return Buffer.concat(chunks) |
| 120 | 139 | } |
| 121 | 140 | |
| … | … | @@ -123,3 +142,27 @@ export async function readJson (req) { |
| 123 | 142 | const body = await readBody(req) |
| 124 | 143 | return JSON.parse(body.toString()) |
| 125 | 144 | } |
| 145 | ||
| 146 | /** | |
| 147 | * Parse pagination from query params | |
| 148 | * @param {Object} query - parsed query object (req.query) | |
| 149 | * @param {{ defaultLimit?: number, maxLimit?: number }} opts | |
| 150 | * @returns {{ limit: number, offset: number }} | |
| 151 | */ | |
| 152 | export function parsePagination (query, opts = {}) { | |
| 153 | const maxLimit = opts.maxLimit || 100 | |
| 154 | const defaultLimit = opts.defaultLimit || 20 | |
| 155 | let limit = parseInt(query.limit, 10) | |
| 156 | let offset = parseInt(query.offset, 10) | |
| 157 | if (isNaN(limit) || limit < 1) limit = defaultLimit | |
| 158 | if (limit > maxLimit) limit = maxLimit | |
| 159 | if (isNaN(offset) || offset < 0) offset = 0 | |
| 160 | return { limit, offset } | |
| 161 | } | |
| 162 | ||
| 163 | function setCorsHeaders (res, origin) { | |
| 164 | res.setHeader('Access-Control-Allow-Origin', origin) | |
| 165 | res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS') | |
| 166 | res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization') | |
| 167 | res.setHeader('Access-Control-Max-Age', '86400') | |
| 168 | } |
| … | … | @@ -1,5 +1,5 @@ |
| 1 | 1 | import test from 'brittle' |
| 2 | import { Router, json, html, readJson } from '../lib/router.js' | |
| 2 | import { Router, json, html, readJson, parsePagination } from '../lib/router.js' | |
| 3 | 3 | |
| 4 | 4 | // Mock req/res |
| 5 | 5 | function mockReq (method, url, opts = {}) { |
| … | … | @@ -20,9 +20,11 @@ function mockRes () { |
| 20 | 20 | const res = { |
| 21 | 21 | statusCode: null, |
| 22 | 22 | headers: {}, |
| 23 | _setHeaders: {}, | |
| 23 | 24 | body: '', |
| 24 | 25 | writableEnded: false, |
| 25 | writeHead (status, headers) { res.statusCode = status; res.headers = headers }, | |
| 26 | writeHead (status, headers) { res.statusCode = status; res.headers = { ...res._setHeaders, ...headers } }, | |
| 27 | setHeader (k, v) { res._setHeaders[k] = v }, | |
| 26 | 28 | end (data) { res.body = data || ''; res.writableEnded = true }, |
| 27 | 29 | write (data) { res.body += data } |
| 28 | 30 | } |
| … | … | @@ -151,3 +153,78 @@ test('error handler returns 500', async (t) => { |
| 151 | 153 | t.is(res.statusCode, 500) |
| 152 | 154 | t.is(JSON.parse(res.body).error, 'boom') |
| 153 | 155 | }) |
| 156 | ||
| 157 | // ── parsePagination ─────────────────────────────────────── | |
| 158 | ||
| 159 | test('parsePagination — defaults', (t) => { | |
| 160 | const p = parsePagination({}) | |
| 161 | t.is(p.limit, 20) | |
| 162 | t.is(p.offset, 0) | |
| 163 | }) | |
| 164 | ||
| 165 | test('parsePagination — custom values', (t) => { | |
| 166 | const p = parsePagination({ limit: '50', offset: '10' }) | |
| 167 | t.is(p.limit, 50) | |
| 168 | t.is(p.offset, 10) | |
| 169 | }) | |
| 170 | ||
| 171 | test('parsePagination — clamps to maxLimit', (t) => { | |
| 172 | const p = parsePagination({ limit: '999' }, { maxLimit: 50 }) | |
| 173 | t.is(p.limit, 50) | |
| 174 | }) | |
| 175 | ||
| 176 | test('parsePagination — rejects negative/NaN', (t) => { | |
| 177 | const p = parsePagination({ limit: '-5', offset: 'abc' }) | |
| 178 | t.is(p.limit, 20) | |
| 179 | t.is(p.offset, 0) | |
| 180 | }) | |
| 181 | ||
| 182 | // ── CORS ────────────────────────────────────────────────── | |
| 183 | ||
| 184 | test('CORS preflight returns 204', async (t) => { | |
| 185 | const router = new Router('', { cors: true }) | |
| 186 | router.get('/api/status', async () => ({ ok: true })) | |
| 187 | ||
| 188 | const req = mockReq('OPTIONS', '/api/status') | |
| 189 | const res = mockRes() | |
| 190 | const handled = await router.handle(req, res) | |
| 191 | ||
| 192 | t.is(handled, true) | |
| 193 | t.is(res.statusCode, 204) | |
| 194 | t.is(res.headers['Access-Control-Allow-Origin'], '*') | |
| 195 | t.is(res.headers['Access-Control-Allow-Methods'], 'GET, POST, PATCH, DELETE, OPTIONS') | |
| 196 | }) | |
| 197 | ||
| 198 | test('CORS headers on JSON response', async (t) => { | |
| 199 | const router = new Router('', { cors: true }) | |
| 200 | router.get('/api/data', async () => ({ x: 1 })) | |
| 201 | ||
| 202 | const req = mockReq('GET', '/api/data') | |
| 203 | const res = mockRes() | |
| 204 | await router.handle(req, res) | |
| 205 | ||
| 206 | t.is(res.headers['Access-Control-Allow-Origin'], '*') | |
| 207 | t.is(res.statusCode, 200) | |
| 208 | }) | |
| 209 | ||
| 210 | test('CORS custom origin', async (t) => { | |
| 211 | const router = new Router('', { cors: true, corsOrigin: 'https://example.com' }) | |
| 212 | router.get('/api/data', async () => ({ x: 1 })) | |
| 213 | ||
| 214 | const req = mockReq('GET', '/api/data') | |
| 215 | const res = mockRes() | |
| 216 | await router.handle(req, res) | |
| 217 | ||
| 218 | t.is(res.headers['Access-Control-Allow-Origin'], 'https://example.com') | |
| 219 | }) | |
| 220 | ||
| 221 | test('CORS disabled by default on existing routers (backward compat)', async (t) => { | |
| 222 | const router = new Router() | |
| 223 | router.get('/api/data', async () => ({ x: 1 })) | |
| 224 | ||
| 225 | const req = mockReq('GET', '/api/data') | |
| 226 | const res = mockRes() | |
| 227 | await router.handle(req, res) | |
| 228 | ||
| 229 | t.is(res.headers['Access-Control-Allow-Origin'], undefined) | |
| 230 | }) |