Skip to content
This repository was archived by the owner on Mar 26, 2026. It is now read-only.

Commit 88a191a

Browse files
authored
Merge pull request #17 from pyreon/improvements-part-2
Add compression, testing utilities, and dev route table
2 parents fb4fcb5 + 9ded385 commit 88a191a

8 files changed

Lines changed: 534 additions & 2 deletions

File tree

CLAUDE.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,14 +121,22 @@ File-based API routes: `.ts` files in `src/routes/api/` export HTTP method handl
121121

122122
`rateLimitMiddleware(config)` — in-memory per-client limiting with `X-RateLimit-*` headers. Configurable `max`, `window`, `include`/`exclude` patterns, custom key function.
123123

124+
### Compression (`compression.ts`)
125+
126+
`compressionMiddleware()` signals encoding preference, `compressResponse(response, encoding, threshold)` compresses text-based responses (HTML, JSON, JS, CSS, SVG) using gzip or deflate via `CompressionStream`.
127+
128+
### Testing (`testing.ts`)
129+
130+
`createTestContext(path, options)` — mock `MiddlewareContext` for testing middleware. `testMiddleware(mw, path)` — run middleware and capture response + headers. `createTestApiServer(routes)` — test API routes with `server.request(path)`. `createMockHandler(config)` — mock API handler that records calls.
131+
124132
### Shared Utilities (`utils/`)
125133

126134
- `use-intersection-observer.ts` — reusable observer composable (used by Image, Link)
127135
- `with-headers.ts` — clone Response with modified headers (used by cache middleware)
128136

129137
## Package Exports
130138

131-
`.` (core), `./client`, `./config`, `./image`, `./link`, `./script`, `./font`, `./cache`, `./seo`, `./theme`, `./image-plugin`, `./actions`, `./api-routes`, `./cors`, `./rate-limit`
139+
`.` (core), `./client`, `./config`, `./image`, `./link`, `./script`, `./font`, `./cache`, `./seo`, `./theme`, `./image-plugin`, `./actions`, `./api-routes`, `./cors`, `./rate-limit`, `./compression`, `./testing`
132140

133141
## Starter Template (`packages/create-zero/templates/default/`)
134142

packages/cli/src/commands/dev.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { resolve } from 'node:path'
1+
import { existsSync } from 'node:fs'
2+
import { join, resolve } from 'node:path'
23
import { createServer } from 'vite'
34

45
export interface DevOptions {
@@ -22,8 +23,55 @@ export async function dev(root: string | undefined, options: DevOptions) {
2223

2324
await server.listen()
2425
server.printUrls()
26+
27+
// Print route table after server starts
28+
await printRouteTable(projectRoot)
2529
} catch (error) {
2630
console.error('Failed to start dev server:', (error as Error).message)
2731
process.exit(1)
2832
}
2933
}
34+
35+
async function printRouteTable(projectRoot: string) {
36+
try {
37+
const routesDir = join(projectRoot, 'src/routes')
38+
if (!existsSync(routesDir)) return
39+
40+
const { scanRouteFiles, parseFileRoutes } = await import('@pyreon/zero')
41+
const { isApiRoute, apiFilePathToPattern } = await import(
42+
'@pyreon/zero/api-routes'
43+
)
44+
45+
const files = await scanRouteFiles(routesDir)
46+
const pageRoutes = parseFileRoutes(files).filter(
47+
(r) => !r.isLayout && !r.isError && !r.isLoading && !isApiRoute(r.filePath),
48+
)
49+
const apiFiles = files.filter(isApiRoute)
50+
51+
if (pageRoutes.length === 0 && apiFiles.length === 0) return
52+
53+
console.log('')
54+
console.log(' \x1b[36m Routes\x1b[0m')
55+
console.log('')
56+
57+
for (const route of pageRoutes) {
58+
const mode = route.renderMode.toUpperCase()
59+
console.log(
60+
` \x1b[2m${mode.padEnd(4)}\x1b[0m ${route.urlPath}`,
61+
)
62+
}
63+
64+
if (apiFiles.length > 0) {
65+
console.log('')
66+
console.log(' \x1b[33m API Routes\x1b[0m')
67+
console.log('')
68+
for (const file of apiFiles) {
69+
console.log(` \x1b[2mAPI \x1b[0m ${apiFilePathToPattern(file)}`)
70+
}
71+
}
72+
73+
console.log('')
74+
} catch {
75+
// Route table is informational — don't fail dev server
76+
}
77+
}

packages/zero/package.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,16 @@
9696
"bun": "./src/rate-limit.ts",
9797
"import": "./lib/rate-limit.js",
9898
"types": "./lib/types/rate-limit.d.ts"
99+
},
100+
"./compression": {
101+
"bun": "./src/compression.ts",
102+
"import": "./lib/compression.js",
103+
"types": "./lib/types/compression.d.ts"
104+
},
105+
"./testing": {
106+
"bun": "./src/testing.ts",
107+
"import": "./lib/testing.js",
108+
"types": "./lib/types/testing.d.ts"
99109
}
100110
},
101111
"scripts": {

packages/zero/src/compression.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import type { Middleware, MiddlewareContext } from '@pyreon/server'
2+
3+
// ─── Compression middleware ─────────────────────────────────────────────────
4+
5+
export interface CompressionConfig {
6+
/** Minimum response size in bytes to compress. Default: `1024` (1KB) */
7+
threshold?: number
8+
/** Encoding preference order. Default: `["gzip", "deflate"]` */
9+
encodings?: ('gzip' | 'deflate')[]
10+
}
11+
12+
/**
13+
* Compression middleware — compresses responses using gzip or deflate
14+
* based on the client's Accept-Encoding header.
15+
*
16+
* Only compresses text-based content types (HTML, JSON, JS, CSS, XML, SVG).
17+
* Skips responses below the size threshold.
18+
*
19+
* @example
20+
* import { compressionMiddleware } from "@pyreon/zero/compression"
21+
*
22+
* compressionMiddleware() // gzip with 1KB threshold
23+
* compressionMiddleware({ threshold: 512, encodings: ["gzip"] })
24+
*/
25+
export function compressionMiddleware(
26+
config: CompressionConfig = {},
27+
): Middleware {
28+
const { encodings = ['gzip', 'deflate'] } = config
29+
30+
return async (ctx: MiddlewareContext) => {
31+
const acceptEncoding = ctx.req.headers.get('accept-encoding') ?? ''
32+
33+
// Find the best supported encoding
34+
const encoding = encodings.find((enc) => acceptEncoding.includes(enc))
35+
if (!encoding) return
36+
37+
// Signal to downstream that we handle encoding
38+
ctx.headers.set('X-Zero-Compress', encoding)
39+
}
40+
}
41+
42+
/**
43+
* Compress a Response body if it meets the criteria.
44+
* Call this after the response is generated (post-middleware).
45+
*
46+
* @example
47+
* const response = await handler(request)
48+
* const compressed = await compressResponse(response, 'gzip', 1024)
49+
*/
50+
export async function compressResponse(
51+
response: Response,
52+
encoding: 'gzip' | 'deflate',
53+
threshold: number,
54+
): Promise<Response> {
55+
const contentType = response.headers.get('content-type') ?? ''
56+
57+
// Only compress text-based content
58+
if (!isCompressible(contentType)) return response
59+
60+
// Skip if already encoded
61+
if (response.headers.get('content-encoding')) return response
62+
63+
const body = await response.arrayBuffer()
64+
65+
// Skip below threshold
66+
if (body.byteLength < threshold) return response
67+
68+
const compressed = await compress(body, encoding)
69+
70+
const headers = new Headers(response.headers)
71+
headers.set('Content-Encoding', encoding)
72+
headers.delete('Content-Length') // Let the runtime recalculate
73+
headers.append('Vary', 'Accept-Encoding')
74+
75+
return new Response(compressed, {
76+
status: response.status,
77+
statusText: response.statusText,
78+
headers,
79+
})
80+
}
81+
82+
const COMPRESSIBLE_TYPES = [
83+
'text/',
84+
'application/json',
85+
'application/javascript',
86+
'application/xml',
87+
'application/xhtml+xml',
88+
'image/svg+xml',
89+
]
90+
91+
function isCompressible(contentType: string): boolean {
92+
return COMPRESSIBLE_TYPES.some((t) => contentType.includes(t))
93+
}
94+
95+
async function compress(
96+
data: ArrayBuffer,
97+
encoding: 'gzip' | 'deflate',
98+
): Promise<ArrayBuffer> {
99+
const format = encoding === 'gzip' ? 'gzip' : 'deflate'
100+
const stream = new Blob([data])
101+
.stream()
102+
.pipeThrough(new CompressionStream(format))
103+
return new Response(stream).arrayBuffer()
104+
}

packages/zero/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,11 @@ export { corsMiddleware } from './cors'
126126
export type { RateLimitConfig } from './rate-limit'
127127
export { rateLimitMiddleware } from './rate-limit'
128128

129+
// ─── Compression ────────────────────────────────────────────────────────────
130+
131+
export type { CompressionConfig } from './compression'
132+
export { compressionMiddleware, compressResponse } from './compression'
133+
129134
// ─── Actions ─────────────────────────────────────────────────────────────────
130135

131136
export type { Action, ActionContext, ActionHandler } from './actions'

packages/zero/src/testing.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import type { Middleware, MiddlewareContext } from '@pyreon/server'
2+
import type { ApiHandler, ApiRouteEntry } from './api-routes'
3+
import { createApiMiddleware } from './api-routes'
4+
5+
// ─── Test helpers for Zero applications ─────────────────────────────────────
6+
7+
/**
8+
* Create a mock MiddlewareContext for testing middleware.
9+
*
10+
* @example
11+
* import { createTestContext } from "@pyreon/zero/testing"
12+
*
13+
* const ctx = createTestContext("/api/posts", { method: "POST", body: { title: "Hello" } })
14+
* const result = await myMiddleware(ctx)
15+
*/
16+
export function createTestContext(
17+
path: string,
18+
options: {
19+
method?: string
20+
headers?: Record<string, string>
21+
body?: unknown
22+
} = {},
23+
): MiddlewareContext {
24+
const { method = 'GET', headers = {}, body } = options
25+
const url = new URL(`http://localhost${path}`)
26+
27+
const requestHeaders: Record<string, string> = { ...headers }
28+
let requestBody: string | undefined
29+
30+
if (body !== undefined) {
31+
requestHeaders['Content-Type'] = 'application/json'
32+
requestBody = JSON.stringify(body)
33+
}
34+
35+
const req = new Request(url.toString(), {
36+
method,
37+
headers: requestHeaders,
38+
body: requestBody,
39+
})
40+
41+
return {
42+
req,
43+
url,
44+
path,
45+
headers: new Headers(),
46+
locals: {},
47+
}
48+
}
49+
50+
/**
51+
* Test a middleware by running it with a mock context and returning
52+
* the result along with the response headers it set.
53+
*
54+
* @example
55+
* import { testMiddleware } from "@pyreon/zero/testing"
56+
*
57+
* const { response, headers } = await testMiddleware(
58+
* corsMiddleware({ origin: "*" }),
59+
* "/api/posts"
60+
* )
61+
* expect(headers.get("Access-Control-Allow-Origin")).toBe("*")
62+
*/
63+
export async function testMiddleware(
64+
middleware: Middleware,
65+
path: string,
66+
options: {
67+
method?: string
68+
headers?: Record<string, string>
69+
body?: unknown
70+
} = {},
71+
): Promise<{ response: Response | undefined; headers: Headers }> {
72+
const ctx = createTestContext(path, options)
73+
const response = (await middleware(ctx)) as Response | undefined
74+
return { response, headers: ctx.headers }
75+
}
76+
77+
/**
78+
* Create a test server for API routes. Returns a function that
79+
* accepts Request objects and dispatches to the correct handler.
80+
*
81+
* @example
82+
* import { createTestApiServer } from "@pyreon/zero/testing"
83+
*
84+
* const server = createTestApiServer([
85+
* { pattern: "/api/posts", module: postsApi },
86+
* { pattern: "/api/posts/:id", module: postByIdApi },
87+
* ])
88+
*
89+
* const response = await server.request("/api/posts")
90+
* expect(response.status).toBe(200)
91+
*
92+
* const data = await server.request("/api/posts", { method: "POST", body: { title: "Hi" } })
93+
* expect(data.status).toBe(201)
94+
*/
95+
export function createTestApiServer(routes: ApiRouteEntry[]) {
96+
const middleware = createApiMiddleware(routes)
97+
98+
return {
99+
async request(
100+
path: string,
101+
options: {
102+
method?: string
103+
headers?: Record<string, string>
104+
body?: unknown
105+
} = {},
106+
): Promise<Response> {
107+
const ctx = createTestContext(path, options)
108+
const result = await middleware(ctx)
109+
if (!result) {
110+
return new Response('Not Found', { status: 404 })
111+
}
112+
return result
113+
},
114+
}
115+
}
116+
117+
/**
118+
* Create a mock API handler for testing.
119+
* Records all calls and returns a configurable response.
120+
*
121+
* @example
122+
* import { createMockHandler } from "@pyreon/zero/testing"
123+
*
124+
* const handler = createMockHandler({ status: 200, body: { ok: true } })
125+
* // ... use handler in your API route module
126+
* expect(handler.calls).toHaveLength(1)
127+
* expect(handler.calls[0].params).toEqual({ id: "123" })
128+
*/
129+
export function createMockHandler(
130+
responseConfig: {
131+
status?: number
132+
body?: unknown
133+
headers?: Record<string, string>
134+
} = {},
135+
): ApiHandler & {
136+
calls: Array<{ path: string; params: Record<string, string> }>
137+
} {
138+
const { status = 200, body = null, headers = {} } = responseConfig
139+
const calls: Array<{ path: string; params: Record<string, string> }> = []
140+
141+
const handler: ApiHandler = (ctx) => {
142+
calls.push({ path: ctx.path, params: ctx.params })
143+
return new Response(JSON.stringify(body), {
144+
status,
145+
headers: { 'Content-Type': 'application/json', ...headers },
146+
})
147+
}
148+
149+
return Object.assign(handler, { calls })
150+
}

0 commit comments

Comments
 (0)