@pyreon/zero 0.12.1 → 0.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/lib/index.js +1476 -82
  2. package/lib/index.js.map +1 -1
  3. package/lib/types/adapters/cloudflare.d.ts +26 -0
  4. package/lib/types/adapters/cloudflare.d.ts.map +1 -0
  5. package/lib/types/adapters/index.d.ts +3 -0
  6. package/lib/types/adapters/index.d.ts.map +1 -1
  7. package/lib/types/adapters/netlify.d.ts +21 -0
  8. package/lib/types/adapters/netlify.d.ts.map +1 -0
  9. package/lib/types/adapters/vercel.d.ts +21 -0
  10. package/lib/types/adapters/vercel.d.ts.map +1 -0
  11. package/lib/types/ai.d.ts +182 -0
  12. package/lib/types/ai.d.ts.map +1 -0
  13. package/lib/types/csp.d.ts +107 -0
  14. package/lib/types/csp.d.ts.map +1 -0
  15. package/lib/types/env.d.ts +118 -0
  16. package/lib/types/env.d.ts.map +1 -0
  17. package/lib/types/favicon.d.ts +42 -0
  18. package/lib/types/favicon.d.ts.map +1 -1
  19. package/lib/types/index.d.ts +13 -3
  20. package/lib/types/index.d.ts.map +1 -1
  21. package/lib/types/logger.d.ts +68 -0
  22. package/lib/types/logger.d.ts.map +1 -0
  23. package/lib/types/meta.d.ts +36 -0
  24. package/lib/types/meta.d.ts.map +1 -1
  25. package/lib/types/og-image.d.ts +107 -0
  26. package/lib/types/og-image.d.ts.map +1 -0
  27. package/lib/types/types.d.ts +1 -1
  28. package/lib/types/types.d.ts.map +1 -1
  29. package/package.json +35 -10
  30. package/src/adapters/cloudflare.ts +82 -0
  31. package/src/adapters/index.ts +13 -1
  32. package/src/adapters/netlify.ts +84 -0
  33. package/src/adapters/vercel.ts +84 -0
  34. package/src/ai.ts +623 -0
  35. package/src/csp.ts +207 -0
  36. package/src/env.ts +344 -0
  37. package/src/favicon.ts +221 -80
  38. package/src/index.ts +41 -2
  39. package/src/logger.ts +144 -0
  40. package/src/meta.tsx +84 -2
  41. package/src/og-image.ts +378 -0
  42. package/src/types.ts +1 -1
package/src/favicon.ts CHANGED
@@ -27,6 +27,13 @@ function warnSharpMissing() {
27
27
  // import { faviconPlugin } from "@pyreon/zero"
28
28
  // export default { plugins: [zero(), faviconPlugin({ source: "./icon.svg" })] }
29
29
 
30
+ export interface FaviconLocaleConfig {
31
+ /** Locale-specific source icon (SVG or PNG). */
32
+ source: string
33
+ /** Optional dark mode variant for this locale. */
34
+ darkSource?: string
35
+ }
36
+
30
37
  export interface FaviconPluginConfig {
31
38
  /** Path to the source icon (SVG or PNG, at least 512x512 for PNG). */
32
39
  source: string
@@ -44,6 +51,26 @@ export interface FaviconPluginConfig {
44
51
  * to switch between light and dark variants.
45
52
  */
46
53
  darkSource?: string
54
+ /**
55
+ * Locale-specific icon overrides. Each key is a locale code,
56
+ * value is a source icon (and optional dark variant).
57
+ * Locales not in this map use the base `source`.
58
+ *
59
+ * Generated files are placed under `/{locale}/` prefix:
60
+ * /de/favicon.svg, /de/favicon-32x32.png, etc.
61
+ *
62
+ * @example
63
+ * ```ts
64
+ * faviconPlugin({
65
+ * source: "./icon.svg",
66
+ * locales: {
67
+ * de: { source: "./icon-de.svg" },
68
+ * cs: { source: "./icon-cs.svg" },
69
+ * },
70
+ * })
71
+ * ```
72
+ */
73
+ locales?: Record<string, FaviconLocaleConfig>
47
74
  }
48
75
 
49
76
  interface FaviconSize {
@@ -95,24 +122,41 @@ export function faviconPlugin(config: FaviconPluginConfig): Plugin {
95
122
  // Dev server: serve generated favicons on-the-fly
96
123
  configureServer(server) {
97
124
  const sourcePath = join(root, config.source)
125
+ const devCache = new Map<string, Uint8Array>()
98
126
 
99
127
  server.middlewares.use(async (req, res, next) => {
100
128
  const url = req.url ?? ''
101
129
 
130
+ // Resolve locale-specific source: /{locale}/favicon.svg → locale source
131
+ const localeSource = resolveLocaleSource(url, config, root)
132
+
102
133
  // Serve source as favicon.svg in dev
103
- if (url === '/favicon.svg' && config.source.endsWith('.svg')) {
134
+ const svgUrl = localeSource ? localeSource.url : url
135
+ const svgPath = localeSource ? localeSource.sourcePath : sourcePath
136
+ const isSvgSource = localeSource ? localeSource.source.endsWith('.svg') : config.source.endsWith('.svg')
137
+
138
+ if (svgUrl.endsWith('/favicon.svg') && isSvgSource) {
104
139
  try {
105
- const content = await readFile(sourcePath, 'utf-8')
140
+ const content = await readFile(svgPath, 'utf-8')
106
141
  res.setHeader('Content-Type', 'image/svg+xml')
107
142
  res.end(content)
108
143
  return
109
144
  } catch { /* fall through */ }
110
145
  }
111
146
 
112
- // Serve generated PNGs on-demand
113
- const sizeMatch = SIZES.find((s) => url === `/${s.name}`)
147
+ // Serve generated PNGs on-demand (supports /{locale}/favicon-32x32.png)
148
+ const baseName = svgUrl.split('/').pop() ?? ''
149
+ const sizeMatch = SIZES.find((s) => s.name === baseName)
114
150
  if (sizeMatch) {
115
- const png = await resizeToPng(sourcePath, sizeMatch.size)
151
+ const cacheKey = `${svgPath}:${sizeMatch.size}`
152
+ let png = devCache.get(cacheKey)
153
+ if (!png) {
154
+ const result = await resizeToPng(svgPath, sizeMatch.size)
155
+ if (result) {
156
+ png = result
157
+ devCache.set(cacheKey, result)
158
+ }
159
+ }
116
160
  if (png) {
117
161
  res.setHeader('Content-Type', 'image/png')
118
162
  res.setHeader('Cache-Control', 'no-cache')
@@ -122,8 +166,16 @@ export function faviconPlugin(config: FaviconPluginConfig): Plugin {
122
166
  }
123
167
 
124
168
  // Serve generated ICO on-demand
125
- if (url === '/favicon.ico') {
126
- const ico = await generateIco(sourcePath)
169
+ if (baseName === 'favicon.ico') {
170
+ const cacheKey = `ico:${svgPath}`
171
+ let ico: Uint8Array | undefined = devCache.get(cacheKey)
172
+ if (!ico) {
173
+ const result = await generateIco(svgPath)
174
+ if (result) {
175
+ ico = result
176
+ devCache.set(cacheKey, result)
177
+ }
178
+ }
127
179
  if (ico) {
128
180
  res.setHeader('Content-Type', 'image/x-icon')
129
181
  res.setHeader('Cache-Control', 'no-cache')
@@ -132,14 +184,15 @@ export function faviconPlugin(config: FaviconPluginConfig): Plugin {
132
184
  }
133
185
  }
134
186
 
135
- // Serve manifest
136
- if (url === '/site.webmanifest' && generateManifest) {
187
+ // Serve manifest (supports /{locale}/site.webmanifest)
188
+ if (baseName === 'site.webmanifest' && generateManifest) {
189
+ const prefix = localeSource ? `/${localeSource.locale}` : ''
137
190
  const manifest = {
138
191
  name: config.name ?? 'App',
139
192
  short_name: config.name ?? 'App',
140
193
  icons: [
141
- { src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
142
- { src: '/icon-512.png', sizes: '512x512', type: 'image/png' },
194
+ { src: `${prefix}/icon-192.png`, sizes: '192x192', type: 'image/png' },
195
+ { src: `${prefix}/icon-512.png`, sizes: '512x512', type: 'image/png' },
143
196
  ],
144
197
  theme_color: themeColor,
145
198
  background_color: backgroundColor,
@@ -209,78 +262,15 @@ export function faviconPlugin(config: FaviconPluginConfig): Plugin {
209
262
  async generateBundle() {
210
263
  if (!isBuild) return
211
264
 
212
- const sourcePath = join(root, config.source)
213
- if (!existsSync(sourcePath)) {
214
- // oxlint-disable-next-line no-console
215
- console.warn(`[zero:favicon] Source not found: ${sourcePath}`)
216
- return
217
- }
218
-
219
- const isSvg = config.source.endsWith('.svg')
220
-
221
- // Copy SVG as favicon.svg
222
- if (isSvg) {
223
- const svgContent = await readFile(sourcePath, 'utf-8')
224
- let finalSvg = svgContent
225
-
226
- // If dark mode variant provided, wrap in media query
227
- if (config.darkSource) {
228
- const darkPath = join(root, config.darkSource)
229
- if (existsSync(darkPath)) {
230
- const darkSvg = await readFile(darkPath, 'utf-8')
231
- finalSvg = wrapSvgWithDarkMode(svgContent, darkSvg)
232
- }
233
- }
234
-
235
- this.emitFile({
236
- type: 'asset',
237
- fileName: 'favicon.svg',
238
- source: finalSvg,
239
- })
240
- }
265
+ // Generate favicons for the base (default) source
266
+ await generateFaviconSet.call(this, root, config.source, config.darkSource, '', config, themeColor, backgroundColor, generateManifest)
241
267
 
242
- // Generate PNG sizes via sharp
243
- for (const { size, name } of SIZES) {
244
- const pngBuffer = await resizeToPng(sourcePath, size)
245
- if (pngBuffer) {
246
- this.emitFile({
247
- type: 'asset',
248
- fileName: name,
249
- source: pngBuffer,
250
- })
268
+ // Generate locale-specific favicon sets
269
+ if (config.locales) {
270
+ for (const [locale, localeConfig] of Object.entries(config.locales)) {
271
+ await generateFaviconSet.call(this, root, localeConfig.source, localeConfig.darkSource, `${locale}/`, config, themeColor, backgroundColor, generateManifest)
251
272
  }
252
273
  }
253
-
254
- // Generate favicon.ico (16 + 32)
255
- const ico = await generateIco(sourcePath)
256
- if (ico) {
257
- this.emitFile({
258
- type: 'asset',
259
- fileName: 'favicon.ico',
260
- source: ico,
261
- })
262
- }
263
-
264
- // Generate web manifest
265
- if (generateManifest) {
266
- const manifest = {
267
- name: config.name ?? 'App',
268
- short_name: config.name ?? 'App',
269
- icons: [
270
- { src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
271
- { src: '/icon-512.png', sizes: '512x512', type: 'image/png' },
272
- ],
273
- theme_color: themeColor,
274
- background_color: backgroundColor,
275
- display: 'standalone',
276
- }
277
-
278
- this.emitFile({
279
- type: 'asset',
280
- fileName: 'site.webmanifest',
281
- source: JSON.stringify(manifest, null, 2),
282
- })
283
- }
284
274
  },
285
275
  }
286
276
  }
@@ -311,6 +301,157 @@ function stripSvgWrapper(svg: string): string {
311
301
  .trim()
312
302
  }
313
303
 
304
+ /**
305
+ * Resolve the source path for a locale-prefixed favicon URL.
306
+ * Returns null if the URL is not locale-prefixed or locale has no override.
307
+ */
308
+ function resolveLocaleSource(
309
+ url: string,
310
+ config: FaviconPluginConfig,
311
+ rootDir: string,
312
+ ): { locale: string; url: string; source: string; sourcePath: string } | null {
313
+ if (!config.locales) return null
314
+
315
+ for (const [locale, localeConfig] of Object.entries(config.locales)) {
316
+ const prefix = `/${locale}/`
317
+ if (url.startsWith(prefix)) {
318
+ return {
319
+ locale,
320
+ url,
321
+ source: localeConfig.source,
322
+ sourcePath: join(rootDir, localeConfig.source),
323
+ }
324
+ }
325
+ }
326
+ return null
327
+ }
328
+
329
+ /**
330
+ * Generate a complete favicon set (SVG, PNGs, ICO, manifest) with a file prefix.
331
+ * Called once for base (prefix = '') and once per locale (prefix = '{locale}/').
332
+ */
333
+ async function generateFaviconSet(
334
+ this: any,
335
+ rootDir: string,
336
+ source: string,
337
+ darkSource: string | undefined,
338
+ prefix: string,
339
+ config: FaviconPluginConfig,
340
+ themeColor: string,
341
+ backgroundColor: string,
342
+ generateManifest: boolean,
343
+ ): Promise<void> {
344
+ const sourcePath = join(rootDir, source)
345
+ if (!existsSync(sourcePath)) {
346
+ // oxlint-disable-next-line no-console
347
+ console.warn(`[zero:favicon] Source not found: ${sourcePath}`)
348
+ return
349
+ }
350
+
351
+ const isSvg = source.endsWith('.svg')
352
+
353
+ // Copy SVG as favicon.svg
354
+ if (isSvg) {
355
+ const svgContent = await readFile(sourcePath, 'utf-8')
356
+ let finalSvg = svgContent
357
+
358
+ if (darkSource) {
359
+ const darkPath = join(rootDir, darkSource)
360
+ if (existsSync(darkPath)) {
361
+ const darkSvg = await readFile(darkPath, 'utf-8')
362
+ finalSvg = wrapSvgWithDarkMode(svgContent, darkSvg)
363
+ }
364
+ }
365
+
366
+ this.emitFile({
367
+ type: 'asset',
368
+ fileName: `${prefix}favicon.svg`,
369
+ source: finalSvg,
370
+ })
371
+ }
372
+
373
+ // Generate PNG sizes via sharp
374
+ for (const { size, name } of SIZES) {
375
+ const pngBuffer = await resizeToPng(sourcePath, size)
376
+ if (pngBuffer) {
377
+ this.emitFile({
378
+ type: 'asset',
379
+ fileName: `${prefix}${name}`,
380
+ source: pngBuffer,
381
+ })
382
+ }
383
+ }
384
+
385
+ // Generate favicon.ico (16 + 32)
386
+ const ico = await generateIco(sourcePath)
387
+ if (ico) {
388
+ this.emitFile({
389
+ type: 'asset',
390
+ fileName: `${prefix}favicon.ico`,
391
+ source: ico,
392
+ })
393
+ }
394
+
395
+ // Generate web manifest
396
+ if (generateManifest) {
397
+ const manifestPrefix = prefix ? `/${prefix.slice(0, -1)}` : ''
398
+ const manifest = {
399
+ name: config.name ?? 'App',
400
+ short_name: config.name ?? 'App',
401
+ icons: [
402
+ { src: `${manifestPrefix}/icon-192.png`, sizes: '192x192', type: 'image/png' },
403
+ { src: `${manifestPrefix}/icon-512.png`, sizes: '512x512', type: 'image/png' },
404
+ ],
405
+ theme_color: themeColor,
406
+ background_color: backgroundColor,
407
+ display: 'standalone',
408
+ }
409
+
410
+ this.emitFile({
411
+ type: 'asset',
412
+ fileName: `${prefix}site.webmanifest`,
413
+ source: JSON.stringify(manifest, null, 2),
414
+ })
415
+ }
416
+ }
417
+
418
+ /**
419
+ * Get favicon link tags for a specific locale.
420
+ * Returns link objects suitable for `useHead()` or direct HTML injection.
421
+ *
422
+ * @example
423
+ * ```ts
424
+ * const links = faviconLinks("de", { source: "./icon.svg", locales: { de: { source: "./icon-de.svg" } } })
425
+ * // → [{ rel: "icon", type: "image/svg+xml", href: "/de/favicon.svg" }, ...]
426
+ * ```
427
+ */
428
+ export function faviconLinks(
429
+ locale: string | undefined,
430
+ config: FaviconPluginConfig,
431
+ ): Array<{ rel: string; type?: string; sizes?: string; href: string }> {
432
+ const hasLocaleOverride = locale && config.locales?.[locale]
433
+ const prefix = hasLocaleOverride ? `/${locale}` : ''
434
+ const isSvg = (hasLocaleOverride ? config.locales![locale]!.source : config.source).endsWith('.svg')
435
+
436
+ const links: Array<{ rel: string; type?: string; sizes?: string; href: string }> = []
437
+
438
+ if (isSvg) {
439
+ links.push({ rel: 'icon', type: 'image/svg+xml', href: `${prefix}/favicon.svg` })
440
+ }
441
+
442
+ links.push(
443
+ { rel: 'icon', type: 'image/png', sizes: '32x32', href: `${prefix}/favicon-32x32.png` },
444
+ { rel: 'icon', type: 'image/png', sizes: '16x16', href: `${prefix}/favicon-16x16.png` },
445
+ { rel: 'apple-touch-icon', sizes: '180x180', href: `${prefix}/apple-touch-icon.png` },
446
+ )
447
+
448
+ if (config.manifest !== false) {
449
+ links.push({ rel: 'manifest', href: `${prefix}/site.webmanifest` })
450
+ }
451
+
452
+ return links
453
+ }
454
+
314
455
  async function resizeToPng(input: string, size: number): Promise<Uint8Array | null> {
315
456
  try {
316
457
  const sharp = await import('sharp').then((m) => m.default ?? m)
package/src/index.ts CHANGED
@@ -32,9 +32,12 @@ export { createISRHandler } from "./isr";
32
32
 
33
33
  export {
34
34
  bunAdapter,
35
+ cloudflareAdapter,
36
+ netlifyAdapter,
35
37
  nodeAdapter,
36
38
  resolveAdapter,
37
39
  staticAdapter,
40
+ vercelAdapter,
38
41
  } from "./adapters";
39
42
 
40
43
  // ─── Components ─────────────────────────────────────────────────────────────
@@ -148,8 +151,17 @@ export { createActionMiddleware, defineAction } from "./actions";
148
151
 
149
152
  // ─── Favicon ────────────────────────────────────────────────────────────────
150
153
 
151
- export type { FaviconPluginConfig } from "./favicon";
152
- export { faviconPlugin } from "./favicon";
154
+ export type { FaviconLocaleConfig, FaviconPluginConfig } from "./favicon";
155
+ export { faviconLinks, faviconPlugin } from "./favicon";
156
+
157
+ // ─── OG Image ───────────────────────────────────────────────────────────────
158
+
159
+ export type {
160
+ OgImageLayer,
161
+ OgImagePluginConfig,
162
+ OgImageTemplate,
163
+ } from "./og-image";
164
+ export { ogImagePath, ogImagePlugin } from "./og-image";
153
165
 
154
166
  // ─── Meta ───────────────────────────────────────────────────────────────────
155
167
 
@@ -169,6 +181,33 @@ export {
169
181
  useLocale,
170
182
  } from "./i18n-routing";
171
183
 
184
+ // ─── CSP ────────────────────────────────────────────────────────────────────
185
+
186
+ export type { CspConfig, CspDirectives } from "./csp";
187
+ export { buildCspHeader, cspMiddleware, useNonce } from "./csp";
188
+
189
+ // ─── Environment validation ─────────────────────────────────────────────────
190
+
191
+ export type { LogEntry, LoggerConfig } from "./logger";
192
+ export { loggerMiddleware } from "./logger";
193
+
194
+ // ─── Request logging ────────────────────────────────────────────────────────
195
+
196
+ export type { EnvValidator } from "./env";
197
+ export { bool, num, oneOf, publicEnv, schema, str, url, validateEnv } from "./env";
198
+
199
+ // ─── AI integration ─────────────────────────────────────────────────────────
200
+
201
+ export type { AiPluginConfig, InferJsonLdOptions } from "./ai";
202
+ export {
203
+ aiPlugin,
204
+ generateAiPluginManifest,
205
+ generateLlmsFullTxt,
206
+ generateLlmsTxt,
207
+ generateOpenApiSpec,
208
+ inferJsonLd,
209
+ } from "./ai";
210
+
172
211
  // ─── Types ───────────────────────────────────────────────────────────────────
173
212
 
174
213
  export type {
package/src/logger.ts ADDED
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Request logging middleware.
3
+ *
4
+ * Logs HTTP requests with method, path, status, and duration.
5
+ * Supports custom formatters and log levels.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * import { loggerMiddleware } from "@pyreon/zero"
10
+ *
11
+ * export default defineConfig({
12
+ * middleware: [loggerMiddleware()],
13
+ * })
14
+ * ```
15
+ */
16
+ import type { Middleware, MiddlewareContext } from '@pyreon/server'
17
+
18
+ export interface LoggerConfig {
19
+ /**
20
+ * Log level — controls which requests are logged.
21
+ * - "all": log every request
22
+ * - "none": disable logging
23
+ * Default: "all"
24
+ */
25
+ level?: 'all' | 'none'
26
+ /**
27
+ * Custom log formatter. Receives request details and returns
28
+ * the string to log (or null to skip).
29
+ */
30
+ format?: (entry: LogEntry) => string | null
31
+ /**
32
+ * Skip logging for these path prefixes.
33
+ * Default: ["/__", "/@", "/node_modules"]
34
+ */
35
+ skip?: string[]
36
+ /**
37
+ * Enable colorized output (ANSI codes).
38
+ * Default: true in development, false in production.
39
+ */
40
+ colors?: boolean
41
+ }
42
+
43
+ export interface LogEntry {
44
+ method: string
45
+ path: string
46
+ duration: number
47
+ timestamp: Date
48
+ userAgent?: string | undefined
49
+ ip?: string | undefined
50
+ }
51
+
52
+ const COLORS = {
53
+ reset: '\x1b[0m',
54
+ dim: '\x1b[2m',
55
+ green: '\x1b[32m',
56
+ yellow: '\x1b[33m',
57
+ red: '\x1b[31m',
58
+ cyan: '\x1b[36m',
59
+ magenta: '\x1b[35m',
60
+ }
61
+
62
+ function methodColor(method: string, colors: boolean): string {
63
+ if (!colors) return method.padEnd(7)
64
+ const padded = method.padEnd(7)
65
+ switch (method) {
66
+ case 'GET': return `${COLORS.green}${padded}${COLORS.reset}`
67
+ case 'POST': return `${COLORS.cyan}${padded}${COLORS.reset}`
68
+ case 'PUT': return `${COLORS.yellow}${padded}${COLORS.reset}`
69
+ case 'PATCH': return `${COLORS.yellow}${padded}${COLORS.reset}`
70
+ case 'DELETE': return `${COLORS.red}${padded}${COLORS.reset}`
71
+ default: return `${COLORS.magenta}${padded}${COLORS.reset}`
72
+ }
73
+ }
74
+
75
+ function defaultFormat(entry: LogEntry, colors: boolean): string {
76
+ const dur = entry.duration < 1
77
+ ? '<1ms'
78
+ : entry.duration < 1000
79
+ ? `${Math.round(entry.duration)}ms`
80
+ : `${(entry.duration / 1000).toFixed(2)}s`
81
+
82
+ const dim = colors ? COLORS.dim : ''
83
+ const reset = colors ? COLORS.reset : ''
84
+
85
+ return ` ${methodColor(entry.method, colors)} ${entry.path} ${dim}${dur}${reset}`
86
+ }
87
+
88
+ /**
89
+ * Request logging middleware.
90
+ *
91
+ * Logs incoming requests with method, path, and duration.
92
+ * Runs in middleware phase — logs timing from middleware start to
93
+ * microtask completion (approximate request duration).
94
+ *
95
+ * @example
96
+ * ```ts
97
+ * // Basic usage
98
+ * loggerMiddleware()
99
+ *
100
+ * // Custom format
101
+ * loggerMiddleware({
102
+ * format: (e) => `${e.method} ${e.path} (${e.duration}ms)`,
103
+ * })
104
+ * ```
105
+ */
106
+ export function loggerMiddleware(config?: LoggerConfig): Middleware {
107
+ const level = config?.level ?? 'all'
108
+ if (level === 'none') return () => {}
109
+
110
+ const skip = config?.skip ?? ['/__', '/@', '/node_modules']
111
+ const isDev = typeof process !== 'undefined' && process.env.NODE_ENV !== 'production'
112
+ const colors = config?.colors ?? isDev
113
+
114
+ return (ctx: MiddlewareContext) => {
115
+ // Skip internal paths
116
+ if (skip.some((p) => ctx.path.startsWith(p))) return
117
+
118
+ const start = performance.now()
119
+
120
+ const entry: LogEntry = {
121
+ method: ctx.req.method ?? 'GET',
122
+ path: ctx.path,
123
+ duration: 0,
124
+ timestamp: new Date(),
125
+ userAgent: ctx.req.headers.get('user-agent') ?? undefined,
126
+ }
127
+
128
+ // Use queueMicrotask to log after the middleware chain completes
129
+ queueMicrotask(() => {
130
+ entry.duration = performance.now() - start
131
+
132
+ if (config?.format) {
133
+ const line = config.format(entry)
134
+ if (line) {
135
+ // oxlint-disable-next-line no-console
136
+ console.log(line)
137
+ }
138
+ } else {
139
+ // oxlint-disable-next-line no-console
140
+ console.log(defaultFormat(entry, colors))
141
+ }
142
+ })
143
+ }
144
+ }