OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-web-ui commits / 03b7333

Add pagination helper, CORS support, and body size limit
- parsePagination(query, opts) extracts limit/offset with defaults - Router CORS: opt-in via { cors: true }, handles OPTIONS preflight - readBody now enforces maxSize (default 10 MB) - 8 new tests (17 total) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
03b733321babce1de0bb76e091c70326cf7ffb67
by plankatron <fleeky@gmail.com> · 2026-03-30T13:50:37.000Z

Changed files (3) +135 -15

index.js +1 -1
……@@ -1,4 +1,4 @@
11// Server-side exports
2export { Router, json, html, readBody, readJson } from './lib/router.js'
2export { Router, json, html, readBody, readJson, parsePagination } from './lib/router.js'
33export { authMiddleware, createChallenge, verifyChallenge } from './lib/auth.js'
44export { serveStatic } from './lib/static.js'
lib/router.js +55 -12
……@@ -14,9 +14,11 @@
1414 */
1515
1616export class Router {
17 constructor (prefix = '') {
17 constructor (prefix = '', opts = {}) {
1818 this.prefix = prefix
1919 this._routes = []
20 this._cors = opts.cors === true
21 this._corsOrigin = opts.corsOrigin || '*'
2022 }
2123
2224 get (pattern, handler, opts) { this._routes.push({ method: 'GET', pattern, handler, opts }) }
……@@ -29,6 +31,14 @@ export class Router {
2931 const url = req.url.replace(this.prefix, '') || '/'
3032 const [pathStr, queryString] = url.split('?')
3133
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
3242 for (const route of this._routes) {
3343 if (route.method !== method) continue
3444 const params = match(route.pattern, pathStr)
……@@ -49,15 +59,15 @@ export class Router {
4959
5060 if (wantsHtml && route.opts && route.opts.render) {
5161 // 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)
5363 } else {
5464 // JSON (default)
55 json(res, data)
65 json(res, data, 200, this._cors ? this._corsOrigin : null)
5666 }
5767 }
5868 } catch (err) {
5969 if (!res.writableEnded) {
60 json(res, { error: err.message }, 500)
70 json(res, { error: err.message }, 500, this._cors ? this._corsOrigin : null)
6171 }
6272 }
6373 return true
……@@ -95,27 +105,36 @@ function parseQuery (qs) {
95105}
96106
97107// Response helpers
98export function json (res, data, status = 200) {
108export function json (res, data, status = 200, corsOrigin = null) {
99109 const body = JSON.stringify(data)
100 res.writeHead(status, {
110 const headers = {
101111 'Content-Type': 'application/json',
102112 'Content-Length': Buffer.byteLength(body)
103 })
113 }
114 if (corsOrigin) headers['Access-Control-Allow-Origin'] = corsOrigin
115 res.writeHead(status, headers)
104116 res.end(body)
105117}
106118
107export function html (res, content, status = 200) {
119export function html (res, content, status = 200, corsOrigin = null) {
108120 const body = typeof content === 'string' ? content : String(content)
109 res.writeHead(status, {
121 const headers = {
110122 'Content-Type': 'text/html; charset=utf-8',
111123 'Content-Length': Buffer.byteLength(body)
112 })
124 }
125 if (corsOrigin) headers['Access-Control-Allow-Origin'] = corsOrigin
126 res.writeHead(status, headers)
113127 res.end(body)
114128}
115129
116export async function readBody (req) {
130export async function readBody (req, maxSize = 10 * 1024 * 1024) {
117131 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 }
119138 return Buffer.concat(chunks)
120139}
121140
……@@ -123,3 +142,27 @@ export async function readJson (req) {
123142 const body = await readBody(req)
124143 return JSON.parse(body.toString())
125144}
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 */
152export 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
163function 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}
test/router.test.js +79 -2
……@@ -1,5 +1,5 @@
11import test from 'brittle'
2import { Router, json, html, readJson } from '../lib/router.js'
2import { Router, json, html, readJson, parsePagination } from '../lib/router.js'
33
44// Mock req/res
55function mockReq (method, url, opts = {}) {
……@@ -20,9 +20,11 @@ function mockRes () {
2020 const res = {
2121 statusCode: null,
2222 headers: {},
23 _setHeaders: {},
2324 body: '',
2425 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 },
2628 end (data) { res.body = data || ''; res.writableEnded = true },
2729 write (data) { res.body += data }
2830 }
……@@ -151,3 +153,78 @@ test('error handler returns 500', async (t) => {
151153 t.is(res.statusCode, 500)
152154 t.is(JSON.parse(res.body).error, 'boom')
153155})
156
157// ── parsePagination ───────────────────────────────────────
158
159test('parsePagination — defaults', (t) => {
160 const p = parsePagination({})
161 t.is(p.limit, 20)
162 t.is(p.offset, 0)
163})
164
165test('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
171test('parsePagination — clamps to maxLimit', (t) => {
172 const p = parsePagination({ limit: '999' }, { maxLimit: 50 })
173 t.is(p.limit, 50)
174})
175
176test('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
184test('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
198test('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
210test('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
221test('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})