@pyreon/zero 0.12.1 → 0.12.3

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 (140) hide show
  1. package/lib/actions.js +97 -0
  2. package/lib/actions.js.map +1 -0
  3. package/lib/ai.js +503 -0
  4. package/lib/ai.js.map +1 -0
  5. package/lib/api-routes.js +137 -0
  6. package/lib/api-routes.js.map +1 -0
  7. package/lib/compression.js +80 -0
  8. package/lib/compression.js.map +1 -0
  9. package/lib/cors.js +57 -0
  10. package/lib/cors.js.map +1 -0
  11. package/lib/csp.js +119 -0
  12. package/lib/csp.js.map +1 -0
  13. package/lib/env.js +217 -0
  14. package/lib/env.js.map +1 -0
  15. package/lib/favicon.js +424 -0
  16. package/lib/favicon.js.map +1 -0
  17. package/lib/i18n-routing.js +167 -0
  18. package/lib/i18n-routing.js.map +1 -0
  19. package/lib/index.js +1631 -179
  20. package/lib/index.js.map +1 -1
  21. package/lib/link.js +5 -0
  22. package/lib/link.js.map +1 -1
  23. package/lib/logger.js +78 -0
  24. package/lib/logger.js.map +1 -0
  25. package/lib/meta.js +336 -0
  26. package/lib/meta.js.map +1 -0
  27. package/lib/middleware.js +53 -0
  28. package/lib/middleware.js.map +1 -0
  29. package/lib/og-image.js +233 -0
  30. package/lib/og-image.js.map +1 -0
  31. package/lib/rate-limit.js +76 -0
  32. package/lib/rate-limit.js.map +1 -0
  33. package/lib/testing.js +179 -0
  34. package/lib/testing.js.map +1 -0
  35. package/lib/theme.js +11 -2
  36. package/lib/theme.js.map +1 -1
  37. package/lib/types/actions.d.ts +27 -24
  38. package/lib/types/actions.d.ts.map +1 -1
  39. package/lib/types/ai.d.ts +163 -0
  40. package/lib/types/ai.d.ts.map +1 -0
  41. package/lib/types/api-routes.d.ts +37 -33
  42. package/lib/types/api-routes.d.ts.map +1 -1
  43. package/lib/types/cache.d.ts +26 -22
  44. package/lib/types/cache.d.ts.map +1 -1
  45. package/lib/types/client.d.ts +13 -9
  46. package/lib/types/client.d.ts.map +1 -1
  47. package/lib/types/compression.d.ts +14 -10
  48. package/lib/types/compression.d.ts.map +1 -1
  49. package/lib/types/config.d.ts +39 -4
  50. package/lib/types/config.d.ts.map +1 -1
  51. package/lib/types/cors.d.ts +20 -16
  52. package/lib/types/cors.d.ts.map +1 -1
  53. package/lib/types/csp.d.ts +88 -0
  54. package/lib/types/csp.d.ts.map +1 -0
  55. package/lib/types/env.d.ts +118 -0
  56. package/lib/types/env.d.ts.map +1 -0
  57. package/lib/types/favicon.d.ts +70 -24
  58. package/lib/types/favicon.d.ts.map +1 -1
  59. package/lib/types/font.d.ts +68 -65
  60. package/lib/types/font.d.ts.map +1 -1
  61. package/lib/types/i18n-routing.d.ts +43 -37
  62. package/lib/types/i18n-routing.d.ts.map +1 -1
  63. package/lib/types/image-plugin.d.ts +49 -45
  64. package/lib/types/image-plugin.d.ts.map +1 -1
  65. package/lib/types/image.d.ts +47 -36
  66. package/lib/types/image.d.ts.map +1 -1
  67. package/lib/types/index.d.ts +1961 -46
  68. package/lib/types/index.d.ts.map +1 -1
  69. package/lib/types/link.d.ts +61 -56
  70. package/lib/types/link.d.ts.map +1 -1
  71. package/lib/types/logger.d.ts +57 -0
  72. package/lib/types/logger.d.ts.map +1 -0
  73. package/lib/types/meta.d.ts +180 -69
  74. package/lib/types/meta.d.ts.map +1 -1
  75. package/lib/types/middleware.d.ts +8 -4
  76. package/lib/types/middleware.d.ts.map +1 -1
  77. package/lib/types/og-image.d.ts +111 -0
  78. package/lib/types/og-image.d.ts.map +1 -0
  79. package/lib/types/rate-limit.d.ts +20 -16
  80. package/lib/types/rate-limit.d.ts.map +1 -1
  81. package/lib/types/script.d.ts +23 -19
  82. package/lib/types/script.d.ts.map +1 -1
  83. package/lib/types/seo.d.ts +47 -43
  84. package/lib/types/seo.d.ts.map +1 -1
  85. package/lib/types/testing.d.ts +64 -27
  86. package/lib/types/testing.d.ts.map +1 -1
  87. package/lib/types/theme.d.ts +22 -12
  88. package/lib/types/theme.d.ts.map +1 -1
  89. package/package.json +37 -12
  90. package/src/actions.ts +1 -3
  91. package/src/adapters/bun.ts +2 -0
  92. package/src/adapters/cloudflare.ts +84 -0
  93. package/src/adapters/index.ts +13 -1
  94. package/src/adapters/netlify.ts +86 -0
  95. package/src/adapters/node.ts +2 -0
  96. package/src/adapters/validate.ts +16 -0
  97. package/src/adapters/vercel.ts +86 -0
  98. package/src/ai.ts +623 -0
  99. package/src/compression.ts +19 -3
  100. package/src/csp.ts +207 -0
  101. package/src/entry-server.ts +28 -5
  102. package/src/env.ts +344 -0
  103. package/src/favicon.ts +221 -80
  104. package/src/index.ts +42 -2
  105. package/src/link.tsx +6 -0
  106. package/src/logger.ts +144 -0
  107. package/src/meta.tsx +124 -14
  108. package/src/og-image.ts +378 -0
  109. package/src/rate-limit.ts +11 -9
  110. package/src/theme.tsx +12 -1
  111. package/src/types.ts +1 -1
  112. package/src/vite-plugin.ts +5 -1
  113. package/lib/types/adapters/bun.d.ts +0 -6
  114. package/lib/types/adapters/bun.d.ts.map +0 -1
  115. package/lib/types/adapters/index.d.ts +0 -10
  116. package/lib/types/adapters/index.d.ts.map +0 -1
  117. package/lib/types/adapters/node.d.ts +0 -6
  118. package/lib/types/adapters/node.d.ts.map +0 -1
  119. package/lib/types/adapters/static.d.ts +0 -7
  120. package/lib/types/adapters/static.d.ts.map +0 -1
  121. package/lib/types/app.d.ts +0 -24
  122. package/lib/types/app.d.ts.map +0 -1
  123. package/lib/types/entry-server.d.ts +0 -37
  124. package/lib/types/entry-server.d.ts.map +0 -1
  125. package/lib/types/error-overlay.d.ts +0 -6
  126. package/lib/types/error-overlay.d.ts.map +0 -1
  127. package/lib/types/fs-router.d.ts +0 -47
  128. package/lib/types/fs-router.d.ts.map +0 -1
  129. package/lib/types/isr.d.ts +0 -9
  130. package/lib/types/isr.d.ts.map +0 -1
  131. package/lib/types/not-found.d.ts +0 -7
  132. package/lib/types/not-found.d.ts.map +0 -1
  133. package/lib/types/types.d.ts +0 -111
  134. package/lib/types/types.d.ts.map +0 -1
  135. package/lib/types/utils/use-intersection-observer.d.ts +0 -10
  136. package/lib/types/utils/use-intersection-observer.d.ts.map +0 -1
  137. package/lib/types/utils/with-headers.d.ts +0 -6
  138. package/lib/types/utils/with-headers.d.ts.map +0 -1
  139. package/lib/types/vite-plugin.d.ts +0 -17
  140. package/lib/types/vite-plugin.d.ts.map +0 -1
package/lib/ai.js.map ADDED
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ai.js","names":[],"sources":["../src/fs-router.ts","../src/ai.ts"],"sourcesContent":["import type { FileRoute, RenderMode } from './types'\n\n// ─── File-system route conventions ──────────────────────────────────────────\n//\n// src/routes/\n// _layout.tsx → layout for all routes\n// index.tsx → /\n// about.tsx → /about\n// users/\n// _layout.tsx → layout for /users/*\n// _loading.tsx → loading fallback for /users/*\n// _error.tsx → error boundary for /users/*\n// index.tsx → /users\n// [id].tsx → /users/:id\n// [id]/\n// settings.tsx → /users/:id/settings\n// blog/\n// [...slug].tsx → /blog/* (catch-all)\n//\n// Conventions:\n// [param] → dynamic segment → :param\n// [...param] → catch-all → :param*\n// _layout → layout wrapper — must use <RouterView /> to render child routes\n// (props.children is NOT passed — the router handles nesting)\n// _error → error component\n// _loading → loading component\n// _404 → not-found component (renders on 404)\n// _not-found → alias for _404\n// (group) → route group (directory ignored in URL)\n\nconst ROUTE_EXTENSIONS = ['.tsx', '.jsx', '.ts', '.js']\n\n/**\n * Parse a set of file paths (relative to routes dir) into FileRoute objects.\n *\n * @param files Array of file paths like [\"index.tsx\", \"users/[id].tsx\"]\n * @param defaultMode Default rendering mode from config\n */\nexport function parseFileRoutes(files: string[], defaultMode: RenderMode = 'ssr'): FileRoute[] {\n return files\n .filter((f) => ROUTE_EXTENSIONS.some((ext) => f.endsWith(ext)))\n .map((filePath) => parseFilePath(filePath, defaultMode))\n .sort(sortRoutes)\n}\n\nfunction parseFilePath(filePath: string, defaultMode: RenderMode): FileRoute {\n // Remove extension\n let route = filePath\n for (const ext of ROUTE_EXTENSIONS) {\n if (route.endsWith(ext)) {\n route = route.slice(0, -ext.length)\n break\n }\n }\n\n const fileName = getFileName(route)\n const isLayout = fileName === '_layout'\n const isError = fileName === '_error'\n const isLoading = fileName === '_loading'\n const isNotFound = fileName === '_404' || fileName === '_not-found'\n const isCatchAll = route.includes('[...')\n\n // Get directory path (strip groups for consistent grouping)\n const parts = route.split('/')\n parts.pop() // remove filename\n const dirPath = parts.filter((s) => !(s.startsWith('(') && s.endsWith(')'))).join('/')\n\n // Convert file path to URL pattern\n const urlPath = filePathToUrlPath(route)\n const depth = urlPath === '/' ? 0 : urlPath.split('/').filter(Boolean).length\n\n return {\n filePath,\n urlPath,\n dirPath,\n depth,\n isLayout,\n isError,\n isLoading,\n isNotFound,\n isCatchAll,\n renderMode: defaultMode,\n }\n}\n\n/**\n * Convert a file path (without extension) to a URL path pattern.\n *\n * Examples:\n * \"index\" → \"/\"\n * \"about\" → \"/about\"\n * \"users/index\" → \"/users\"\n * \"users/[id]\" → \"/users/:id\"\n * \"blog/[...slug]\" → \"/blog/:slug*\"\n * \"(auth)/login\" → \"/login\" (group stripped)\n * \"_layout\" → \"/\" (layout marker)\n */\nexport function filePathToUrlPath(filePath: string): string {\n const segments = filePath.split('/')\n const urlSegments: string[] = []\n\n for (const seg of segments) {\n // Skip route groups \"(name)\"\n if (seg.startsWith('(') && seg.endsWith(')')) continue\n\n // Skip special files\n if (seg === '_layout' || seg === '_error' || seg === '_loading' || seg === '_404' || seg === '_not-found') continue\n\n // \"index\" maps to the parent path\n if (seg === 'index') continue\n\n // Catch-all: [...param] → :param*\n const catchAll = seg.match(/^\\[\\.\\.\\.(\\w+)\\]$/)\n if (catchAll) {\n urlSegments.push(`:${catchAll[1]}*`)\n continue\n }\n\n // Dynamic: [param] → :param\n const dynamic = seg.match(/^\\[(\\w+)\\]$/)\n if (dynamic) {\n urlSegments.push(`:${dynamic[1]}`)\n continue\n }\n\n urlSegments.push(seg)\n }\n\n const path = `/${urlSegments.join('/')}`\n return path || '/'\n}\n\n/** Sort routes: static before dynamic, catch-all last. */\nfunction sortRoutes(a: FileRoute, b: FileRoute): number {\n // Catch-all routes go last\n if (a.isCatchAll !== b.isCatchAll) return a.isCatchAll ? 1 : -1\n // Layouts go first within same depth\n if (a.isLayout !== b.isLayout) return a.isLayout ? -1 : 1\n // Static segments before dynamic\n const aDynamic = a.urlPath.includes(':')\n const bDynamic = b.urlPath.includes(':')\n if (aDynamic !== bDynamic) return aDynamic ? 1 : -1\n // Alphabetical\n return a.urlPath.localeCompare(b.urlPath)\n}\n\nfunction getFileName(filePath: string): string {\n const parts = filePath.split('/')\n return parts[parts.length - 1] ?? ''\n}\n\n// ─── Route generation (for Vite plugin) ─────────────────────────────────────\n\n/** Internal tree node for building nested route structures. */\ninterface RouteNode {\n /** Page routes at this directory level. */\n pages: FileRoute[]\n /** Layout file for this directory (if any). */\n layout?: FileRoute\n /** Error boundary file (if any). */\n error?: FileRoute\n /** Loading fallback file (if any). */\n loading?: FileRoute\n /** Not-found (404) file (if any). */\n notFound?: FileRoute\n /** Child directories. */\n children: Map<string, RouteNode>\n}\n\n/**\n * Group flat file routes into a directory tree.\n */\nfunction getOrCreateChild(node: RouteNode, segment: string): RouteNode {\n let child = node.children.get(segment)\n if (!child) {\n child = { pages: [], children: new Map() }\n node.children.set(segment, child)\n }\n return child\n}\n\nfunction resolveNode(root: RouteNode, dirPath: string): RouteNode {\n let node = root\n if (dirPath) {\n for (const segment of dirPath.split('/')) {\n node = getOrCreateChild(node, segment)\n }\n }\n return node\n}\n\nfunction placeRoute(node: RouteNode, route: FileRoute) {\n if (route.isLayout) node.layout = route\n else if (route.isError) node.error = route\n else if (route.isLoading) node.loading = route\n else if (route.isNotFound) node.notFound = route\n else node.pages.push(route)\n}\n\nfunction buildRouteTree(routes: FileRoute[]): RouteNode {\n const root: RouteNode = { pages: [], children: new Map() }\n for (const route of routes) {\n placeRoute(resolveNode(root, route.dirPath), route)\n }\n return root\n}\n\n/**\n * Generate a virtual module that exports a nested route tree.\n * Wires up layouts as parent routes with children, loaders, guards,\n * error/loading components, middleware, and meta from route module exports.\n */\nexport interface GenerateRouteModuleOptions {\n /**\n * When true, skip lazy() for route components and use static imports.\n * Use for SSG/prerender mode where all routes are rendered at build time\n * and code splitting provides no benefit. Avoids Rolldown warnings about\n * static + dynamic imports of the same module.\n */\n staticImports?: boolean\n}\n\nexport function generateRouteModule(\n files: string[],\n routesDir: string,\n options?: GenerateRouteModuleOptions,\n): string {\n const routes = parseFileRoutes(files)\n const tree = buildRouteTree(routes)\n const imports: string[] = []\n let importCounter = 0\n const useStaticImports = options?.staticImports ?? false\n\n function nextImport(filePath: string, exportName = 'default'): string {\n const name = `_${importCounter++}`\n const fullPath = `${routesDir}/${filePath}`\n if (exportName === 'default') {\n imports.push(`import ${name} from \"${fullPath}\"`)\n } else {\n imports.push(`import { ${exportName} as ${name} } from \"${fullPath}\"`)\n }\n return name\n }\n\n function nextLazy(filePath: string, loadingName?: string, errorName?: string): string {\n const name = `_${importCounter++}`\n const fullPath = `${routesDir}/${filePath}`\n\n if (useStaticImports) {\n // SSG mode: static import avoids Rolldown warnings about\n // static + dynamic imports of the same module\n imports.push(`import ${name} from \"${fullPath}\"`)\n } else {\n const opts: string[] = []\n if (loadingName) opts.push(`loading: ${loadingName}`)\n if (errorName) opts.push(`error: ${errorName}`)\n const optsStr = opts.length > 0 ? `, { ${opts.join(', ')} }` : ''\n imports.push(`const ${name} = lazy(() => import(\"${fullPath}\")${optsStr})`)\n }\n return name\n }\n\n function nextModuleImport(filePath: string): string {\n const name = `_m${importCounter++}`\n const fullPath = `${routesDir}/${filePath}`\n imports.push(`import * as ${name} from \"${fullPath}\"`)\n return name\n }\n\n function generatePageRoute(\n page: FileRoute,\n indent: string,\n loadingName: string | undefined,\n errorName: string | undefined,\n notFoundName: string | undefined,\n ): string {\n const mod = nextModuleImport(page.filePath)\n const comp = nextLazy(page.filePath, loadingName, errorName)\n\n const props: string[] = [\n `${indent} path: ${JSON.stringify(page.urlPath)}`,\n `${indent} component: ${comp}`,\n `${indent} loader: ${mod}.loader`,\n `${indent} beforeEnter: ${mod}.guard`,\n `${indent} meta: { ...${mod}.meta, renderMode: ${mod}.renderMode }`,\n ]\n\n // Only emit errorComponent when there's an actual _error file in scope\n // or the route module exports an error component. Avoids referencing\n // undefined .error exports that produce noisy bundler warnings.\n if (errorName) {\n props.push(`${indent} errorComponent: ${mod}.error || ${errorName}`)\n }\n\n if (notFoundName) {\n props.push(`${indent} notFoundComponent: ${notFoundName}`)\n }\n\n return `${indent}{\\n${props.join(',\\n')}\\n${indent}}`\n }\n\n function wrapWithLayout(\n node: RouteNode,\n children: string[],\n indent: string,\n errorName: string | undefined,\n notFoundName: string | undefined,\n ): string {\n const layout = node.layout as FileRoute\n const layoutMod = nextModuleImport(layout.filePath)\n const layoutComp = nextImport(layout.filePath, 'layout')\n\n const props: string[] = [\n `${indent}path: ${JSON.stringify(layout.urlPath)}`,\n `${indent}component: ${layoutComp}`,\n `${indent}loader: ${layoutMod}.loader`,\n `${indent}beforeEnter: ${layoutMod}.guard`,\n `${indent}meta: { ...${layoutMod}.meta, renderMode: ${layoutMod}.renderMode }`,\n ]\n if (errorName) {\n props.push(`${indent}errorComponent: ${errorName}`)\n }\n if (notFoundName) {\n props.push(`${indent}notFoundComponent: ${notFoundName}`)\n }\n if (children.length > 0) {\n props.push(`${indent}children: [\\n${children.join(',\\n')}\\n${indent}]`)\n }\n\n return `${indent}{\\n${props.map((p) => ` ${p}`).join(',\\n')}\\n${indent}}`\n }\n\n /**\n * Generate route definitions for a tree node.\n */\n function generateNode(node: RouteNode, depth: number): string[] {\n const indent = ' '.repeat(depth + 1)\n\n const errorName = node.error ? nextImport(node.error.filePath) : undefined\n const loadingName = node.loading ? nextImport(node.loading.filePath) : undefined\n const notFoundName = node.notFound ? nextImport(node.notFound.filePath) : undefined\n\n const childRouteDefs: string[] = []\n for (const [, childNode] of node.children) {\n childRouteDefs.push(...generateNode(childNode, depth + 1))\n }\n\n const pageRouteDefs = node.pages.map((page) =>\n generatePageRoute(page, indent, loadingName, errorName, notFoundName),\n )\n\n const allChildren = [...pageRouteDefs, ...childRouteDefs]\n\n if (node.layout) {\n return [wrapWithLayout(node, allChildren, indent, errorName, notFoundName)]\n }\n return allChildren\n }\n\n const routeDefs = generateNode(tree, 0)\n\n return [\n `import { lazy } from \"@pyreon/router\"`,\n '',\n ...imports,\n '',\n // Filter out undefined properties at runtime\n `function clean(routes) {`,\n ` return routes.map(r => {`,\n ` const c = {}`,\n ` for (const k in r) if (r[k] !== undefined) c[k] = r[k]`,\n ` if (c.children) c.children = clean(c.children)`,\n ` return c`,\n ` })`,\n `}`,\n '',\n `export const routes = clean([`,\n routeDefs.join(',\\n'),\n `])`,\n ].join('\\n')\n}\n\n/**\n * Generate a virtual module that maps URL patterns to their middleware exports.\n * Used by the server entry to dispatch per-route middleware.\n */\nexport function generateMiddlewareModule(files: string[], routesDir: string): string {\n const routes = parseFileRoutes(files)\n const imports: string[] = []\n const entries: string[] = []\n let counter = 0\n\n for (const route of routes) {\n if (route.isLayout || route.isError || route.isLoading || route.isNotFound) continue\n const name = `_mw${counter++}`\n const fullPath = `${routesDir}/${route.filePath}`\n imports.push(`import { middleware as ${name} } from \"${fullPath}\"`)\n entries.push(` { pattern: ${JSON.stringify(route.urlPath)}, middleware: ${name} }`)\n }\n\n return [\n ...imports,\n '',\n `export const routeMiddleware = [`,\n entries.join(',\\n'),\n `].filter(e => e.middleware)`,\n ].join('\\n')\n}\n\n/**\n * Scan a directory for route files.\n * Returns paths relative to the routes directory.\n */\nexport async function scanRouteFiles(routesDir: string): Promise<string[]> {\n const { readdir } = await import('node:fs/promises')\n const { join, relative } = await import('node:path')\n\n const files: string[] = []\n\n async function walk(dir: string) {\n const entries = await readdir(dir, { withFileTypes: true })\n for (const entry of entries) {\n const fullPath = join(dir, entry.name)\n if (entry.isDirectory()) {\n await walk(fullPath)\n } else if (ROUTE_EXTENSIONS.some((ext) => entry.name.endsWith(ext))) {\n files.push(relative(routesDir, fullPath))\n }\n }\n }\n\n await walk(routesDir)\n return files\n}\n","/**\n * AI integration utilities for Zero.\n *\n * - llms.txt auto-generation from routes and API routes\n * - JSON-LD auto-inference from route meta + Meta props\n * - AI plugin manifest (/.well-known/ai-plugin.json) from API routes\n *\n * @example\n * ```ts\n * // vite.config.ts\n * import { aiPlugin } from \"@pyreon/zero/ai\"\n *\n * export default {\n * plugins: [\n * aiPlugin({\n * name: \"My App\",\n * origin: \"https://example.com\",\n * description: \"A modern web application\",\n * }),\n * ],\n * }\n * ```\n */\nimport type { Plugin } from 'vite'\nimport { parseFileRoutes } from './fs-router'\n\n// ─── Types ──────────────────────────────────────────────────────────────────\n\nexport interface AiPluginConfig {\n /** App/API name. */\n name: string\n /** App description for AI agents. */\n description: string\n /** Base URL. e.g. \"https://example.com\" */\n origin: string\n /** Contact email (required by OpenAI plugin spec). */\n contactEmail?: string\n /** Legal info URL. */\n legalUrl?: string\n /** Logo URL for the plugin. */\n logoUrl?: string\n /** Routes directory relative to project root. Default: \"src/routes\" */\n routesDir?: string\n /** API routes directory relative to project root. Default: \"src/api\" */\n apiDir?: string\n /**\n * API route descriptions — map of pattern to description.\n * Used for llms.txt and ai-plugin.json.\n *\n * @example\n * ```ts\n * apiDescriptions: {\n * \"GET /api/posts\": \"List all blog posts, supports ?page=N&limit=N\",\n * \"GET /api/posts/:id\": \"Get a single post by ID\",\n * \"POST /api/posts\": \"Create a new post (requires auth)\",\n * }\n * ```\n */\n apiDescriptions?: Record<string, string>\n /**\n * Page descriptions — map of URL path to description.\n * Used for llms.txt. Falls back to route meta.title/description.\n */\n pageDescriptions?: Record<string, string>\n /**\n * Additional content to append to llms.txt.\n * Useful for authentication instructions, rate limits, etc.\n */\n llmsExtra?: string\n}\n\n// ─── llms.txt generation ────────────────────────────────────────────────────\n\n/**\n * Generate llms.txt content from route files and config.\n *\n * Format follows the llms.txt proposal:\n * ```\n * # {name}\n * > {description}\n *\n * ## Pages\n * - [/about](/about): About page\n *\n * ## API\n * - GET /api/posts: List posts\n * ```\n *\n * @internal Exported for testing.\n */\nexport function generateLlmsTxt(\n routeFiles: string[],\n apiFiles: string[],\n config: AiPluginConfig,\n): string {\n const lines: string[] = []\n\n // Header\n lines.push(`# ${config.name}`)\n lines.push(`> ${config.description}`)\n lines.push('')\n\n // Pages section\n const routes = parseFileRoutes(routeFiles)\n const pages = routes.filter(\n (r) => !r.isLayout && !r.isError && !r.isLoading && !r.isNotFound\n && !r.isCatchAll && !r.urlPath.includes(':'),\n )\n\n if (pages.length > 0) {\n lines.push('## Pages')\n lines.push('')\n for (const page of pages) {\n const desc = config.pageDescriptions?.[page.urlPath]\n const url = `${config.origin}${page.urlPath === '/' ? '' : page.urlPath}`\n if (desc) {\n lines.push(`- [${page.urlPath}](${url}): ${desc}`)\n } else {\n lines.push(`- [${page.urlPath}](${url})`)\n }\n }\n lines.push('')\n }\n\n // Dynamic routes (documented separately — AI needs to know about params)\n const dynamicRoutes = routes.filter(\n (r) => !r.isLayout && !r.isError && !r.isLoading && !r.isNotFound\n && (r.urlPath.includes(':') || r.isCatchAll),\n )\n if (dynamicRoutes.length > 0) {\n lines.push('## Dynamic Pages')\n lines.push('')\n for (const route of dynamicRoutes) {\n const desc = config.pageDescriptions?.[route.urlPath]\n if (desc) {\n lines.push(`- ${route.urlPath}: ${desc}`)\n } else {\n lines.push(`- ${route.urlPath}`)\n }\n }\n lines.push('')\n }\n\n // API section\n const apiPatterns = parseApiFiles(apiFiles)\n if (apiPatterns.length > 0 || config.apiDescriptions) {\n lines.push('## API Endpoints')\n lines.push('')\n\n // From apiDescriptions (most detailed — user-provided)\n if (config.apiDescriptions) {\n for (const [endpoint, desc] of Object.entries(config.apiDescriptions)) {\n lines.push(`- ${endpoint}: ${desc}`)\n }\n }\n\n // From auto-discovered API files (only those not already described)\n const describedPatterns = new Set(\n Object.keys(config.apiDescriptions ?? {}).map((k) => k.replace(/^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\\s+/, '')),\n )\n for (const pattern of apiPatterns) {\n if (!describedPatterns.has(pattern)) {\n lines.push(`- ${pattern}`)\n }\n }\n lines.push('')\n }\n\n // Extra content\n if (config.llmsExtra) {\n lines.push(config.llmsExtra)\n lines.push('')\n }\n\n return lines.join('\\n')\n}\n\n/**\n * Generate llms-full.txt — expanded version with more detail.\n * Includes all route metadata and API descriptions.\n *\n * @internal Exported for testing.\n */\nexport function generateLlmsFullTxt(\n routeFiles: string[],\n apiFiles: string[],\n config: AiPluginConfig,\n): string {\n const lines: string[] = []\n\n lines.push(`# ${config.name} — Full Reference`)\n lines.push(`> ${config.description}`)\n lines.push('')\n lines.push(`Base URL: ${config.origin}`)\n lines.push('')\n\n // All pages with details\n const routes = parseFileRoutes(routeFiles)\n const pages = routes.filter(\n (r) => !r.isLayout && !r.isError && !r.isLoading && !r.isNotFound,\n )\n\n if (pages.length > 0) {\n lines.push('## All Routes')\n lines.push('')\n for (const page of pages) {\n const desc = config.pageDescriptions?.[page.urlPath] ?? ''\n const dynamic = page.urlPath.includes(':') ? ' (dynamic)' : ''\n const catchAll = page.isCatchAll ? ' (catch-all)' : ''\n lines.push(`### ${page.urlPath}${dynamic}${catchAll}`)\n if (desc) lines.push(desc)\n lines.push(`- File: ${page.filePath}`)\n lines.push(`- Render mode: ${page.renderMode}`)\n lines.push('')\n }\n }\n\n // API endpoints with full detail\n if (config.apiDescriptions) {\n lines.push('## API Reference')\n lines.push('')\n for (const [endpoint, desc] of Object.entries(config.apiDescriptions)) {\n lines.push(`### ${endpoint}`)\n lines.push(desc)\n lines.push('')\n }\n }\n\n if (config.llmsExtra) {\n lines.push('## Additional Information')\n lines.push('')\n lines.push(config.llmsExtra)\n lines.push('')\n }\n\n return lines.join('\\n')\n}\n\n// ─── JSON-LD auto-inference ─────────────────────────────────────────────────\n\nexport interface InferJsonLdOptions {\n /** Page URL. */\n url: string\n /** Page title. */\n title?: string\n /** Page description. */\n description?: string\n /** Page image. */\n image?: string\n /** Site name. */\n siteName?: string\n /** Page type hint. */\n type?: 'website' | 'article' | 'product' | 'profile'\n /** Article metadata. */\n publishedTime?: string\n /** Article author. */\n author?: string\n /** Article tags. */\n tags?: string[]\n /** Breadcrumb path segments. */\n breadcrumbs?: Array<{ name: string; url: string }>\n}\n\n/**\n * Auto-infer JSON-LD structured data from page metadata.\n *\n * Returns an array of JSON-LD objects (multiple schemas can apply to one page).\n * For example, an article page gets both `Article` and `BreadcrumbList`.\n *\n * @example\n * ```tsx\n * const schemas = inferJsonLd({\n * url: \"https://example.com/blog/my-post\",\n * title: \"My Post\",\n * description: \"A great article\",\n * type: \"article\",\n * author: \"Vit Bokisch\",\n * publishedTime: \"2026-03-31\",\n * })\n * // → [Article schema, BreadcrumbList schema]\n * ```\n */\nexport function inferJsonLd(options: InferJsonLdOptions): Record<string, unknown>[] {\n const schemas: Record<string, unknown>[] = []\n\n // Base: WebPage or Article\n if (options.type === 'article') {\n const article: Record<string, unknown> = {\n '@context': 'https://schema.org',\n '@type': 'Article',\n headline: options.title,\n url: options.url,\n }\n if (options.description) article.description = options.description\n if (options.image) article.image = options.image\n if (options.publishedTime) article.datePublished = options.publishedTime\n if (options.author) {\n article.author = { '@type': 'Person', name: options.author }\n }\n if (options.tags && options.tags.length > 0) {\n article.keywords = options.tags.join(', ')\n }\n if (options.siteName) {\n article.publisher = { '@type': 'Organization', name: options.siteName }\n }\n schemas.push(article)\n } else if (options.type === 'product') {\n const product: Record<string, unknown> = {\n '@context': 'https://schema.org',\n '@type': 'Product',\n name: options.title,\n url: options.url,\n }\n if (options.description) product.description = options.description\n if (options.image) product.image = options.image\n schemas.push(product)\n } else {\n const webpage: Record<string, unknown> = {\n '@context': 'https://schema.org',\n '@type': 'WebPage',\n name: options.title,\n url: options.url,\n }\n if (options.description) webpage.description = options.description\n if (options.image) webpage.thumbnailUrl = options.image\n schemas.push(webpage)\n }\n\n // BreadcrumbList from URL path or explicit breadcrumbs\n if (options.breadcrumbs && options.breadcrumbs.length > 0) {\n schemas.push({\n '@context': 'https://schema.org',\n '@type': 'BreadcrumbList',\n itemListElement: options.breadcrumbs.map((bc, i) => ({\n '@type': 'ListItem',\n position: i + 1,\n name: bc.name,\n item: bc.url,\n })),\n })\n } else {\n // Auto-generate breadcrumbs from URL path\n const urlObj = safeParseUrl(options.url)\n if (urlObj) {\n const segments = urlObj.pathname.split('/').filter(Boolean)\n if (segments.length > 0) {\n const items = [\n { '@type': 'ListItem', position: 1, name: 'Home', item: urlObj.origin },\n ]\n let path = ''\n for (let i = 0; i < segments.length; i++) {\n path += `/${segments[i]}`\n items.push({\n '@type': 'ListItem',\n position: i + 2,\n name: capitalize(segments[i]!.replace(/-/g, ' ')),\n item: `${urlObj.origin}${path}`,\n })\n }\n schemas.push({\n '@context': 'https://schema.org',\n '@type': 'BreadcrumbList',\n itemListElement: items,\n })\n }\n }\n }\n\n return schemas\n}\n\n// ─── AI plugin manifest ─────────────────────────────────────────────────────\n\n/**\n * Generate an OpenAI-compatible AI plugin manifest.\n *\n * Follows the /.well-known/ai-plugin.json spec.\n *\n * @internal Exported for testing.\n */\nexport function generateAiPluginManifest(config: AiPluginConfig): Record<string, unknown> {\n return {\n schema_version: 'v1',\n name_for_human: config.name,\n name_for_model: config.name.toLowerCase().replace(/\\s+/g, '_').replace(/[^a-z0-9_]/g, ''),\n description_for_human: config.description,\n description_for_model: config.description,\n auth: { type: 'none' },\n api: {\n type: 'openapi',\n url: `${config.origin}/.well-known/openapi.yaml`,\n },\n logo_url: config.logoUrl ?? `${config.origin}/favicon.svg`,\n contact_email: config.contactEmail ?? '',\n legal_info_url: config.legalUrl ?? `${config.origin}/legal`,\n }\n}\n\n/**\n * Generate a minimal OpenAPI 3.0 spec from API route descriptions.\n *\n * @internal Exported for testing.\n */\nexport function generateOpenApiSpec(\n apiFiles: string[],\n config: AiPluginConfig,\n): Record<string, unknown> {\n const paths: Record<string, Record<string, unknown>> = {}\n\n // From user-provided descriptions\n if (config.apiDescriptions) {\n for (const [endpoint, desc] of Object.entries(config.apiDescriptions)) {\n const match = endpoint.match(/^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\\s+(.+)$/)\n if (match) {\n const method = match[1]!.toLowerCase()\n const path = match[2]!\n // Convert :param to {param} for OpenAPI\n const openApiPath = path.replace(/:(\\w+)/g, '{$1}')\n if (!paths[openApiPath]) paths[openApiPath] = {}\n paths[openApiPath][method] = {\n summary: desc,\n responses: { '200': { description: 'Success' } },\n }\n }\n }\n }\n\n // Auto-discovered API files (fill in gaps)\n for (const pattern of parseApiFiles(apiFiles)) {\n const openApiPath = pattern.replace(/:(\\w+)/g, '{$1}')\n if (!paths[openApiPath]) {\n paths[openApiPath] = {\n get: {\n summary: `${openApiPath} endpoint`,\n responses: { '200': { description: 'Success' } },\n },\n }\n }\n }\n\n return {\n openapi: '3.0.0',\n info: {\n title: config.name,\n description: config.description,\n version: '1.0.0',\n },\n servers: [{ url: config.origin }],\n paths,\n }\n}\n\n// ─── Vite plugin ────────────────────────────────────────────────────────────\n\n/**\n * AI integration Vite plugin.\n *\n * Generates at build time:\n * - `/llms.txt` — concise site summary for AI agents\n * - `/llms-full.txt` — detailed reference for AI agents\n * - `/.well-known/ai-plugin.json` — OpenAI plugin manifest\n * - `/.well-known/openapi.yaml` — minimal OpenAPI spec from API routes\n *\n * In dev, serves these files via middleware.\n *\n * @example\n * ```ts\n * import { aiPlugin } from \"@pyreon/zero/ai\"\n *\n * export default {\n * plugins: [\n * aiPlugin({\n * name: \"My App\",\n * origin: \"https://example.com\",\n * description: \"A modern web application\",\n * apiDescriptions: {\n * \"GET /api/posts\": \"List blog posts\",\n * \"GET /api/posts/:id\": \"Get post by ID\",\n * },\n * }),\n * ],\n * }\n * ```\n */\nexport function aiPlugin(config: AiPluginConfig): Plugin {\n let root = ''\n let isBuild = false\n let routeFiles: string[] = []\n let apiFiles: string[] = []\n\n return {\n name: 'pyreon-zero-ai',\n enforce: 'post',\n\n configResolved(resolvedConfig) {\n root = resolvedConfig.root\n isBuild = resolvedConfig.command === 'build'\n },\n\n async buildStart() {\n // Scan for route and API files\n try {\n const { join } = await import('node:path')\n\n const routesDir = join(root, config.routesDir ?? 'src/routes')\n const apiDir = join(root, config.apiDir ?? 'src/api')\n\n routeFiles = await scanDir(routesDir, routesDir)\n apiFiles = await scanDir(apiDir, apiDir)\n } catch {\n // Directories may not exist\n }\n },\n\n configureServer(server) {\n server.middlewares.use(async (req, res, next) => {\n const url = req.url ?? ''\n\n if (url === '/llms.txt') {\n res.setHeader('Content-Type', 'text/plain; charset=utf-8')\n res.end(generateLlmsTxt(routeFiles, apiFiles, config))\n return\n }\n\n if (url === '/llms-full.txt') {\n res.setHeader('Content-Type', 'text/plain; charset=utf-8')\n res.end(generateLlmsFullTxt(routeFiles, apiFiles, config))\n return\n }\n\n if (url === '/.well-known/ai-plugin.json') {\n res.setHeader('Content-Type', 'application/json')\n res.end(JSON.stringify(generateAiPluginManifest(config), null, 2))\n return\n }\n\n if (url === '/.well-known/openapi.yaml' || url === '/.well-known/openapi.json') {\n res.setHeader('Content-Type', 'application/json')\n res.end(JSON.stringify(generateOpenApiSpec(apiFiles, config), null, 2))\n return\n }\n\n next()\n })\n },\n\n async generateBundle() {\n if (!isBuild) return\n\n this.emitFile({\n type: 'asset',\n fileName: 'llms.txt',\n source: generateLlmsTxt(routeFiles, apiFiles, config),\n })\n\n this.emitFile({\n type: 'asset',\n fileName: 'llms-full.txt',\n source: generateLlmsFullTxt(routeFiles, apiFiles, config),\n })\n\n this.emitFile({\n type: 'asset',\n fileName: '.well-known/ai-plugin.json',\n source: JSON.stringify(generateAiPluginManifest(config), null, 2),\n })\n\n this.emitFile({\n type: 'asset',\n fileName: '.well-known/openapi.json',\n source: JSON.stringify(generateOpenApiSpec(apiFiles, config), null, 2),\n })\n },\n }\n}\n\n// ─── Helpers ────────────────────────────────────────────────────────────────\n\nfunction parseApiFiles(files: string[]): string[] {\n return files\n .filter((f) => f.endsWith('.ts') || f.endsWith('.js'))\n .map((f) => {\n let path = f.replace(/\\.\\w+$/, '').replace(/\\/index$/, '')\n if (!path.startsWith('/')) path = `/${path}`\n // Convert [param] to :param\n path = path.replace(/\\[\\.\\.\\.(\\w+)\\]/g, ':$1*').replace(/\\[(\\w+)\\]/g, ':$1')\n return `/api${path === '/' ? '' : path}`\n })\n}\n\nasync function scanDir(dir: string, base: string): Promise<string[]> {\n const { readdir, stat } = await import('node:fs/promises')\n const { join, relative } = await import('node:path')\n\n try {\n const entries = await readdir(dir)\n const files: string[] = []\n for (const entry of entries) {\n const full = join(dir, entry)\n const s = await stat(full)\n if (s.isDirectory()) {\n files.push(...(await scanDir(full, base)))\n } else {\n files.push(relative(base, full))\n }\n }\n return files\n } catch {\n return []\n }\n}\n\nfunction safeParseUrl(url: string): URL | null {\n try {\n return new URL(url)\n } catch {\n return null\n }\n}\n\nfunction capitalize(s: string): string {\n return s.charAt(0).toUpperCase() + s.slice(1)\n}\n"],"mappings":"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"}
@@ -0,0 +1,137 @@
1
+ //#region src/api-routes.ts
2
+ /**
3
+ * Match a URL path against an API route pattern.
4
+ * Returns extracted params or null if no match.
5
+ */
6
+ function matchApiRoute(pattern, path) {
7
+ const patternParts = pattern.split("/").filter(Boolean);
8
+ const pathParts = path.split("/").filter(Boolean);
9
+ const params = {};
10
+ for (let i = 0; i < patternParts.length; i++) {
11
+ const pp = patternParts[i];
12
+ if (!pp) continue;
13
+ if (pp.endsWith("*")) {
14
+ const paramName = pp.slice(1, -1);
15
+ params[paramName] = pathParts.slice(i).join("/");
16
+ return params;
17
+ }
18
+ if (i >= pathParts.length) return null;
19
+ if (pp.startsWith(":")) {
20
+ params[pp.slice(1)] = pathParts[i];
21
+ continue;
22
+ }
23
+ if (pp !== pathParts[i]) return null;
24
+ }
25
+ return patternParts.length === pathParts.length ? params : null;
26
+ }
27
+ const HTTP_METHODS = [
28
+ "GET",
29
+ "POST",
30
+ "PUT",
31
+ "PATCH",
32
+ "DELETE",
33
+ "HEAD",
34
+ "OPTIONS"
35
+ ];
36
+ /**
37
+ * Create a middleware that dispatches API route requests.
38
+ * API routes are matched by URL pattern and HTTP method.
39
+ */
40
+ function createApiMiddleware(routes) {
41
+ return async (ctx) => {
42
+ for (const route of routes) {
43
+ const params = matchApiRoute(route.pattern, ctx.path);
44
+ if (!params) continue;
45
+ const method = ctx.req.method.toUpperCase();
46
+ const handler = route.module[method];
47
+ if (!handler) {
48
+ const allowed = HTTP_METHODS.filter((m) => route.module[m]).join(", ");
49
+ return new Response(null, {
50
+ status: 405,
51
+ headers: {
52
+ Allow: allowed,
53
+ "Content-Type": "application/json"
54
+ }
55
+ });
56
+ }
57
+ return handler({
58
+ request: ctx.req,
59
+ url: ctx.url,
60
+ path: ctx.path,
61
+ params,
62
+ headers: ctx.req.headers
63
+ });
64
+ }
65
+ };
66
+ }
67
+ /**
68
+ * Detect whether a route file is an API route.
69
+ * API routes are `.ts` or `.js` files inside an `api/` directory.
70
+ */
71
+ function isApiRoute(filePath) {
72
+ const normalized = filePath.replace(/\\/g, "/");
73
+ return normalized.startsWith("api/") && (normalized.endsWith(".ts") || normalized.endsWith(".js")) && !normalized.endsWith(".tsx") && !normalized.endsWith(".jsx");
74
+ }
75
+ /**
76
+ * Convert an API route file path to a URL pattern.
77
+ *
78
+ * Examples:
79
+ * "api/posts.ts" → "/api/posts"
80
+ * "api/posts/index.ts" → "/api/posts"
81
+ * "api/posts/[id].ts" → "/api/posts/:id"
82
+ * "api/[...path].ts" → "/api/:path*"
83
+ */
84
+ function apiFilePathToPattern(filePath) {
85
+ let route = filePath;
86
+ for (const ext of [".ts", ".js"]) if (route.endsWith(ext)) {
87
+ route = route.slice(0, -ext.length);
88
+ break;
89
+ }
90
+ const segments = route.split("/");
91
+ const urlSegments = [];
92
+ for (const seg of segments) {
93
+ if (seg === "index") continue;
94
+ const catchAll = seg.match(/^\[\.\.\.(\w+)\]$/);
95
+ if (catchAll) {
96
+ urlSegments.push(`:${catchAll[1]}*`);
97
+ continue;
98
+ }
99
+ const dynamic = seg.match(/^\[(\w+)\]$/);
100
+ if (dynamic) {
101
+ urlSegments.push(`:${dynamic[1]}`);
102
+ continue;
103
+ }
104
+ urlSegments.push(seg);
105
+ }
106
+ return `/${urlSegments.join("/")}`;
107
+ }
108
+ /**
109
+ * Generate a virtual module that exports API route entries.
110
+ * Each entry maps a URL pattern to a module with HTTP method handlers.
111
+ */
112
+ function generateApiRouteModule(files, routesDir) {
113
+ const apiFiles = files.filter(isApiRoute);
114
+ if (apiFiles.length === 0) return "export const apiRoutes = []\n";
115
+ const imports = [];
116
+ const entries = [];
117
+ for (let i = 0; i < apiFiles.length; i++) {
118
+ const name = `_api${i}`;
119
+ const file = apiFiles[i];
120
+ if (!file) continue;
121
+ const fullPath = `${routesDir}/${file}`;
122
+ const pattern = apiFilePathToPattern(file);
123
+ imports.push(`import * as ${name} from "${fullPath}"`);
124
+ entries.push(` { pattern: ${JSON.stringify(pattern)}, module: ${name} }`);
125
+ }
126
+ return [
127
+ ...imports,
128
+ "",
129
+ "export const apiRoutes = [",
130
+ entries.join(",\n"),
131
+ "]"
132
+ ].join("\n");
133
+ }
134
+
135
+ //#endregion
136
+ export { apiFilePathToPattern, createApiMiddleware, generateApiRouteModule, isApiRoute, matchApiRoute };
137
+ //# sourceMappingURL=api-routes.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"api-routes.js","names":[],"sources":["../src/api-routes.ts"],"sourcesContent":["import type { Middleware, MiddlewareContext } from '@pyreon/server'\n\n// ─── Types ───────────────────────────────────────────────────────────────────\n\n/** HTTP methods supported by API routes. */\nexport type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS'\n\n/** Context passed to API route handlers. */\nexport interface ApiContext {\n /** The incoming request. */\n request: Request\n /** Parsed URL. */\n url: URL\n /** URL path. */\n path: string\n /** Dynamic route parameters (e.g., { id: \"123\" }). */\n params: Record<string, string>\n /** Request headers. */\n headers: Headers\n}\n\n/** An API route handler function. */\nexport type ApiHandler = (ctx: ApiContext) => Response | Promise<Response>\n\n/** An API route module — exports named HTTP method handlers. */\nexport interface ApiRouteModule {\n GET?: ApiHandler\n POST?: ApiHandler\n PUT?: ApiHandler\n PATCH?: ApiHandler\n DELETE?: ApiHandler\n HEAD?: ApiHandler\n OPTIONS?: ApiHandler\n}\n\n/** A registered API route entry. */\nexport interface ApiRouteEntry {\n /** URL pattern (e.g., \"/api/posts/:id\"). */\n pattern: string\n /** The route module with method handlers. */\n module: ApiRouteModule\n}\n\n// ─── Pattern matching ────────────────────────────────────────────────────────\n\n/**\n * Match a URL path against an API route pattern.\n * Returns extracted params or null if no match.\n */\nexport function matchApiRoute(pattern: string, path: string): Record<string, string> | null {\n const patternParts = pattern.split('/').filter(Boolean)\n const pathParts = path.split('/').filter(Boolean)\n const params: Record<string, string> = {}\n\n for (let i = 0; i < patternParts.length; i++) {\n const pp = patternParts[i]\n if (!pp) continue\n\n // Catch-all: :param*\n if (pp.endsWith('*')) {\n const paramName = pp.slice(1, -1)\n params[paramName] = pathParts.slice(i).join('/')\n return params\n }\n\n // No more path segments\n if (i >= pathParts.length) return null\n\n // Dynamic segment: :param\n if (pp.startsWith(':')) {\n params[pp.slice(1)] = pathParts[i]!\n continue\n }\n\n // Static segment\n if (pp !== pathParts[i]) return null\n }\n\n return patternParts.length === pathParts.length ? params : null\n}\n\n// ─── Middleware ───────────────────────────────────────────────────────────────\n\nconst HTTP_METHODS: HttpMethod[] = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']\n\n/**\n * Create a middleware that dispatches API route requests.\n * API routes are matched by URL pattern and HTTP method.\n */\nexport function createApiMiddleware(routes: ApiRouteEntry[]): Middleware {\n return async (ctx: MiddlewareContext) => {\n for (const route of routes) {\n const params = matchApiRoute(route.pattern, ctx.path)\n if (!params) continue\n\n const method = ctx.req.method.toUpperCase() as HttpMethod\n const handler = route.module[method]\n\n if (!handler) {\n // Route matched but method not supported\n const allowed = HTTP_METHODS.filter((m) => route.module[m]).join(', ')\n return new Response(null, {\n status: 405,\n headers: {\n Allow: allowed,\n 'Content-Type': 'application/json',\n },\n })\n }\n\n return handler({\n request: ctx.req,\n url: ctx.url,\n path: ctx.path,\n params,\n headers: ctx.req.headers,\n })\n }\n }\n}\n\n// ─── Virtual module generation ───────────────────────────────────────────────\n\n/**\n * Detect whether a route file is an API route.\n * API routes are `.ts` or `.js` files inside an `api/` directory.\n */\nexport function isApiRoute(filePath: string): boolean {\n const normalized = filePath.replace(/\\\\/g, '/')\n return (\n normalized.startsWith('api/') &&\n (normalized.endsWith('.ts') || normalized.endsWith('.js')) &&\n !normalized.endsWith('.tsx') &&\n !normalized.endsWith('.jsx')\n )\n}\n\n/**\n * Convert an API route file path to a URL pattern.\n *\n * Examples:\n * \"api/posts.ts\" → \"/api/posts\"\n * \"api/posts/index.ts\" → \"/api/posts\"\n * \"api/posts/[id].ts\" → \"/api/posts/:id\"\n * \"api/[...path].ts\" → \"/api/:path*\"\n */\nexport function apiFilePathToPattern(filePath: string): string {\n let route = filePath\n // Remove extension\n for (const ext of ['.ts', '.js']) {\n if (route.endsWith(ext)) {\n route = route.slice(0, -ext.length)\n break\n }\n }\n\n const segments = route.split('/')\n const urlSegments: string[] = []\n\n for (const seg of segments) {\n if (seg === 'index') continue\n\n // Catch-all: [...param]\n const catchAll = seg.match(/^\\[\\.\\.\\.(\\w+)\\]$/)\n if (catchAll) {\n urlSegments.push(`:${catchAll[1]}*`)\n continue\n }\n\n // Dynamic: [param]\n const dynamic = seg.match(/^\\[(\\w+)\\]$/)\n if (dynamic) {\n urlSegments.push(`:${dynamic[1]}`)\n continue\n }\n\n urlSegments.push(seg)\n }\n\n return `/${urlSegments.join('/')}`\n}\n\n/**\n * Generate a virtual module that exports API route entries.\n * Each entry maps a URL pattern to a module with HTTP method handlers.\n */\nexport function generateApiRouteModule(files: string[], routesDir: string): string {\n const apiFiles = files.filter(isApiRoute)\n\n if (apiFiles.length === 0) {\n return 'export const apiRoutes = []\\n'\n }\n\n const imports: string[] = []\n const entries: string[] = []\n\n for (let i = 0; i < apiFiles.length; i++) {\n const name = `_api${i}`\n const file = apiFiles[i]\n if (!file) continue\n const fullPath = `${routesDir}/${file}`\n const pattern = apiFilePathToPattern(file)\n\n imports.push(`import * as ${name} from \"${fullPath}\"`)\n entries.push(` { pattern: ${JSON.stringify(pattern)}, module: ${name} }`)\n }\n\n return [...imports, '', 'export const apiRoutes = [', entries.join(',\\n'), ']'].join('\\n')\n}\n"],"mappings":"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"}
@@ -0,0 +1,80 @@
1
+ //#region src/compression.ts
2
+ /**
3
+ * Compression middleware — compresses responses using gzip or deflate
4
+ * based on the client's Accept-Encoding header.
5
+ *
6
+ * Only compresses text-based content types (HTML, JSON, JS, CSS, XML, SVG).
7
+ * Skips responses below the size threshold and already-encoded responses.
8
+ *
9
+ * @example
10
+ * import { compressionMiddleware } from "@pyreon/zero/compression"
11
+ *
12
+ * compressionMiddleware() // gzip with 1KB threshold
13
+ * compressionMiddleware({ threshold: 512, encodings: ["gzip"] })
14
+ */
15
+ function compressionMiddleware(config = {}) {
16
+ const { threshold = 1024, encodings = ["gzip", "deflate"] } = config;
17
+ return (ctx) => {
18
+ const acceptEncoding = ctx.req.headers.get("accept-encoding") ?? "";
19
+ const encoding = encodings.find((enc) => acceptEncoding.includes(enc));
20
+ if (!encoding) return;
21
+ ctx.locals.__compressionEncoding = encoding;
22
+ ctx.locals.__compressionThreshold = threshold;
23
+ ctx.headers.append("Vary", "Accept-Encoding");
24
+ };
25
+ }
26
+ /**
27
+ * Compress a Response body if it meets the criteria.
28
+ * Use this to post-process responses after the handler runs.
29
+ *
30
+ * @example
31
+ * const response = await handler(request)
32
+ * const compressed = await compressResponse(response, 'gzip', 1024)
33
+ */
34
+ async function compressResponse(response, encoding, threshold) {
35
+ if (!isCompressible(response.headers.get("content-type") ?? "")) return response;
36
+ if (response.headers.get("content-encoding")) return response;
37
+ const body = await response.arrayBuffer();
38
+ if (body.byteLength < threshold) return response;
39
+ const compressed = await compress(body, encoding);
40
+ const headers = new Headers(response.headers);
41
+ headers.set("Content-Encoding", encoding);
42
+ headers.delete("Content-Length");
43
+ headers.append("Vary", "Accept-Encoding");
44
+ return new Response(compressed, {
45
+ status: response.status,
46
+ statusText: response.statusText,
47
+ headers
48
+ });
49
+ }
50
+ const COMPRESSIBLE_TYPES = [
51
+ "text/",
52
+ "application/json",
53
+ "application/javascript",
54
+ "application/xml",
55
+ "application/xhtml+xml",
56
+ "image/svg+xml"
57
+ ];
58
+ /** Check if a content type is compressible. Exported for testing. */
59
+ function isCompressible(contentType) {
60
+ return COMPRESSIBLE_TYPES.some((t) => contentType.includes(t));
61
+ }
62
+ async function compress(data, encoding) {
63
+ if (typeof CompressionStream !== "undefined") {
64
+ const format = encoding === "gzip" ? "gzip" : "deflate";
65
+ const stream = new Blob([data]).stream().pipeThrough(new CompressionStream(format));
66
+ return new Response(stream).arrayBuffer();
67
+ }
68
+ try {
69
+ const zlib = await import("node:zlib");
70
+ const { promisify } = await import("node:util");
71
+ const result = await (encoding === "gzip" ? promisify(zlib.gzip) : promisify(zlib.deflate))(Buffer.from(data));
72
+ return result.buffer.slice(result.byteOffset, result.byteOffset + result.byteLength);
73
+ } catch {
74
+ return data;
75
+ }
76
+ }
77
+
78
+ //#endregion
79
+ export { compressResponse, compressionMiddleware, isCompressible };
80
+ //# sourceMappingURL=compression.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"compression.js","names":[],"sources":["../src/compression.ts"],"sourcesContent":["import type { Middleware, MiddlewareContext } from '@pyreon/server'\n\n// ─── Compression middleware ─────────────────────────────────────────────────\n\nexport interface CompressionConfig {\n /** Minimum response size in bytes to compress. Default: `1024` (1KB) */\n threshold?: number\n /** Encoding preference order. Default: `[\"gzip\", \"deflate\"]` */\n encodings?: ('gzip' | 'deflate')[]\n}\n\n/**\n * Compression middleware — compresses responses using gzip or deflate\n * based on the client's Accept-Encoding header.\n *\n * Only compresses text-based content types (HTML, JSON, JS, CSS, XML, SVG).\n * Skips responses below the size threshold and already-encoded responses.\n *\n * @example\n * import { compressionMiddleware } from \"@pyreon/zero/compression\"\n *\n * compressionMiddleware() // gzip with 1KB threshold\n * compressionMiddleware({ threshold: 512, encodings: [\"gzip\"] })\n */\nexport function compressionMiddleware(config: CompressionConfig = {}): Middleware {\n const { threshold = 1024, encodings = ['gzip', 'deflate'] } = config\n\n return (ctx: MiddlewareContext) => {\n const acceptEncoding = ctx.req.headers.get('accept-encoding') ?? ''\n\n // Find the best supported encoding\n const encoding = encodings.find((enc) => acceptEncoding.includes(enc))\n if (!encoding) return\n\n // Store the encoding choice for post-processing\n ctx.locals.__compressionEncoding = encoding\n ctx.locals.__compressionThreshold = threshold\n ctx.headers.append('Vary', 'Accept-Encoding')\n }\n}\n\n/**\n * Compress a Response body if it meets the criteria.\n * Use this to post-process responses after the handler runs.\n *\n * @example\n * const response = await handler(request)\n * const compressed = await compressResponse(response, 'gzip', 1024)\n */\nexport async function compressResponse(\n response: Response,\n encoding: 'gzip' | 'deflate',\n threshold: number,\n): Promise<Response> {\n const contentType = response.headers.get('content-type') ?? ''\n\n // Only compress text-based content\n if (!isCompressible(contentType)) return response\n\n // Skip if already encoded\n if (response.headers.get('content-encoding')) return response\n\n const body = await response.arrayBuffer()\n\n // Skip below threshold\n if (body.byteLength < threshold) return response\n\n const compressed = await compress(body, encoding)\n\n const headers = new Headers(response.headers)\n headers.set('Content-Encoding', encoding)\n headers.delete('Content-Length')\n headers.append('Vary', 'Accept-Encoding')\n\n return new Response(compressed, {\n status: response.status,\n statusText: response.statusText,\n headers,\n })\n}\n\nconst COMPRESSIBLE_TYPES = [\n 'text/',\n 'application/json',\n 'application/javascript',\n 'application/xml',\n 'application/xhtml+xml',\n 'image/svg+xml',\n]\n\n/** Check if a content type is compressible. Exported for testing. */\nexport function isCompressible(contentType: string): boolean {\n return COMPRESSIBLE_TYPES.some((t) => contentType.includes(t))\n}\n\nasync function compress(data: ArrayBuffer, encoding: 'gzip' | 'deflate'): Promise<ArrayBuffer> {\n // CompressionStream is available in modern browsers and Node 18+/Bun.\n // Fallback: try node:zlib for older runtimes.\n if (typeof CompressionStream !== 'undefined') {\n const format = encoding === 'gzip' ? 'gzip' : 'deflate'\n const stream = new Blob([data]).stream().pipeThrough(new CompressionStream(format))\n return new Response(stream).arrayBuffer()\n }\n\n // Node.js fallback via zlib\n try {\n const zlib = await import('node:zlib')\n const { promisify } = await import('node:util')\n const fn = encoding === 'gzip' ? promisify(zlib.gzip) : promisify(zlib.deflate)\n const result = await fn(Buffer.from(data))\n return result.buffer.slice(result.byteOffset, result.byteOffset + result.byteLength)\n } catch {\n // No compression available — return uncompressed\n return data\n }\n}\n"],"mappings":";;;;;;;;;;;;;;AAwBA,SAAgB,sBAAsB,SAA4B,EAAE,EAAc;CAChF,MAAM,EAAE,YAAY,MAAM,YAAY,CAAC,QAAQ,UAAU,KAAK;AAE9D,SAAQ,QAA2B;EACjC,MAAM,iBAAiB,IAAI,IAAI,QAAQ,IAAI,kBAAkB,IAAI;EAGjE,MAAM,WAAW,UAAU,MAAM,QAAQ,eAAe,SAAS,IAAI,CAAC;AACtE,MAAI,CAAC,SAAU;AAGf,MAAI,OAAO,wBAAwB;AACnC,MAAI,OAAO,yBAAyB;AACpC,MAAI,QAAQ,OAAO,QAAQ,kBAAkB;;;;;;;;;;;AAYjD,eAAsB,iBACpB,UACA,UACA,WACmB;AAInB,KAAI,CAAC,eAHe,SAAS,QAAQ,IAAI,eAAe,IAAI,GAG5B,CAAE,QAAO;AAGzC,KAAI,SAAS,QAAQ,IAAI,mBAAmB,CAAE,QAAO;CAErD,MAAM,OAAO,MAAM,SAAS,aAAa;AAGzC,KAAI,KAAK,aAAa,UAAW,QAAO;CAExC,MAAM,aAAa,MAAM,SAAS,MAAM,SAAS;CAEjD,MAAM,UAAU,IAAI,QAAQ,SAAS,QAAQ;AAC7C,SAAQ,IAAI,oBAAoB,SAAS;AACzC,SAAQ,OAAO,iBAAiB;AAChC,SAAQ,OAAO,QAAQ,kBAAkB;AAEzC,QAAO,IAAI,SAAS,YAAY;EAC9B,QAAQ,SAAS;EACjB,YAAY,SAAS;EACrB;EACD,CAAC;;AAGJ,MAAM,qBAAqB;CACzB;CACA;CACA;CACA;CACA;CACA;CACD;;AAGD,SAAgB,eAAe,aAA8B;AAC3D,QAAO,mBAAmB,MAAM,MAAM,YAAY,SAAS,EAAE,CAAC;;AAGhE,eAAe,SAAS,MAAmB,UAAoD;AAG7F,KAAI,OAAO,sBAAsB,aAAa;EAC5C,MAAM,SAAS,aAAa,SAAS,SAAS;EAC9C,MAAM,SAAS,IAAI,KAAK,CAAC,KAAK,CAAC,CAAC,QAAQ,CAAC,YAAY,IAAI,kBAAkB,OAAO,CAAC;AACnF,SAAO,IAAI,SAAS,OAAO,CAAC,aAAa;;AAI3C,KAAI;EACF,MAAM,OAAO,MAAM,OAAO;EAC1B,MAAM,EAAE,cAAc,MAAM,OAAO;EAEnC,MAAM,SAAS,OADJ,aAAa,SAAS,UAAU,KAAK,KAAK,GAAG,UAAU,KAAK,QAAQ,EACvD,OAAO,KAAK,KAAK,CAAC;AAC1C,SAAO,OAAO,OAAO,MAAM,OAAO,YAAY,OAAO,aAAa,OAAO,WAAW;SAC9E;AAEN,SAAO"}
package/lib/cors.js ADDED
@@ -0,0 +1,57 @@
1
+ //#region src/cors.ts
2
+ const DEFAULT_METHODS = [
3
+ "GET",
4
+ "POST",
5
+ "PUT",
6
+ "PATCH",
7
+ "DELETE",
8
+ "OPTIONS"
9
+ ];
10
+ const DEFAULT_HEADERS = ["Content-Type", "Authorization"];
11
+ /**
12
+ * CORS middleware — handles preflight requests and sets appropriate
13
+ * Access-Control headers on all responses.
14
+ *
15
+ * @example
16
+ * import { corsMiddleware } from "@pyreon/zero/cors"
17
+ *
18
+ * corsMiddleware({ origin: "https://example.com", credentials: true })
19
+ *
20
+ * // Allow any origin
21
+ * corsMiddleware({ origin: "*" })
22
+ *
23
+ * // Multiple origins
24
+ * corsMiddleware({ origin: ["https://app.com", "https://admin.com"] })
25
+ */
26
+ function corsMiddleware(config = {}) {
27
+ const { origin = "*", methods = DEFAULT_METHODS, allowedHeaders = DEFAULT_HEADERS, exposedHeaders = [], credentials = false, maxAge = 86400 } = config;
28
+ return (ctx) => {
29
+ const resolvedOrigin = resolveOrigin(origin, ctx.req.headers.get("origin") ?? "");
30
+ if (!resolvedOrigin) return;
31
+ ctx.headers.set("Access-Control-Allow-Origin", resolvedOrigin);
32
+ if (credentials) ctx.headers.set("Access-Control-Allow-Credentials", "true");
33
+ if (exposedHeaders.length > 0) ctx.headers.set("Access-Control-Expose-Headers", exposedHeaders.join(", "));
34
+ if (resolvedOrigin !== "*") ctx.headers.append("Vary", "Origin");
35
+ if (ctx.req.method === "OPTIONS") return new Response(null, {
36
+ status: 204,
37
+ headers: {
38
+ "Access-Control-Allow-Origin": resolvedOrigin,
39
+ "Access-Control-Allow-Methods": methods.join(", "),
40
+ "Access-Control-Allow-Headers": allowedHeaders.join(", "),
41
+ "Access-Control-Max-Age": String(maxAge),
42
+ ...credentials ? { "Access-Control-Allow-Credentials": "true" } : {}
43
+ }
44
+ });
45
+ };
46
+ }
47
+ function resolveOrigin(config, requestOrigin) {
48
+ if (config === "*") return "*";
49
+ if (typeof config === "string") return config === requestOrigin ? config : null;
50
+ if (typeof config === "function") return config(requestOrigin) ? requestOrigin : null;
51
+ if (Array.isArray(config)) return config.includes(requestOrigin) ? requestOrigin : null;
52
+ return null;
53
+ }
54
+
55
+ //#endregion
56
+ export { corsMiddleware };
57
+ //# sourceMappingURL=cors.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cors.js","names":[],"sources":["../src/cors.ts"],"sourcesContent":["import type { Middleware, MiddlewareContext } from '@pyreon/server'\n\n// ─── CORS middleware ────────────────────────────────────────────────────────\n\nexport interface CorsConfig {\n /** Allowed origins. Use `\"*\"` for any origin. Default: `\"*\"` */\n origin?: string | string[] | ((origin: string) => boolean)\n /** Allowed HTTP methods. Default: `[\"GET\", \"POST\", \"PUT\", \"PATCH\", \"DELETE\", \"OPTIONS\"]` */\n methods?: string[]\n /** Allowed request headers. Default: `[\"Content-Type\", \"Authorization\"]` */\n allowedHeaders?: string[]\n /** Headers exposed to the client. Default: `[]` */\n exposedHeaders?: string[]\n /** Allow credentials (cookies, auth headers). Default: `false` */\n credentials?: boolean\n /** Preflight cache duration in seconds. Default: `86400` (24 hours) */\n maxAge?: number\n}\n\nconst DEFAULT_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']\nconst DEFAULT_HEADERS = ['Content-Type', 'Authorization']\n\n/**\n * CORS middleware — handles preflight requests and sets appropriate\n * Access-Control headers on all responses.\n *\n * @example\n * import { corsMiddleware } from \"@pyreon/zero/cors\"\n *\n * corsMiddleware({ origin: \"https://example.com\", credentials: true })\n *\n * // Allow any origin\n * corsMiddleware({ origin: \"*\" })\n *\n * // Multiple origins\n * corsMiddleware({ origin: [\"https://app.com\", \"https://admin.com\"] })\n */\nexport function corsMiddleware(config: CorsConfig = {}): Middleware {\n const {\n origin = '*',\n methods = DEFAULT_METHODS,\n allowedHeaders = DEFAULT_HEADERS,\n exposedHeaders = [],\n credentials = false,\n maxAge = 86400,\n } = config\n\n return (ctx: MiddlewareContext) => {\n const requestOrigin = ctx.req.headers.get('origin') ?? ''\n const resolvedOrigin = resolveOrigin(origin, requestOrigin)\n\n if (!resolvedOrigin) return\n\n // Set CORS headers on all responses\n ctx.headers.set('Access-Control-Allow-Origin', resolvedOrigin)\n if (credentials) {\n ctx.headers.set('Access-Control-Allow-Credentials', 'true')\n }\n if (exposedHeaders.length > 0) {\n ctx.headers.set('Access-Control-Expose-Headers', exposedHeaders.join(', '))\n }\n if (resolvedOrigin !== '*') {\n ctx.headers.append('Vary', 'Origin')\n }\n\n // Handle preflight\n if (ctx.req.method === 'OPTIONS') {\n return new Response(null, {\n status: 204,\n headers: {\n 'Access-Control-Allow-Origin': resolvedOrigin,\n 'Access-Control-Allow-Methods': methods.join(', '),\n 'Access-Control-Allow-Headers': allowedHeaders.join(', '),\n 'Access-Control-Max-Age': String(maxAge),\n ...(credentials ? { 'Access-Control-Allow-Credentials': 'true' } : {}),\n },\n })\n }\n }\n}\n\nfunction resolveOrigin(config: CorsConfig['origin'], requestOrigin: string): string | null {\n if (config === '*') return '*'\n if (typeof config === 'string') {\n return config === requestOrigin ? config : null\n }\n if (typeof config === 'function') {\n return config(requestOrigin) ? requestOrigin : null\n }\n if (Array.isArray(config)) {\n return config.includes(requestOrigin) ? requestOrigin : null\n }\n return null\n}\n"],"mappings":";AAmBA,MAAM,kBAAkB;CAAC;CAAO;CAAQ;CAAO;CAAS;CAAU;CAAU;AAC5E,MAAM,kBAAkB,CAAC,gBAAgB,gBAAgB;;;;;;;;;;;;;;;;AAiBzD,SAAgB,eAAe,SAAqB,EAAE,EAAc;CAClE,MAAM,EACJ,SAAS,KACT,UAAU,iBACV,iBAAiB,iBACjB,iBAAiB,EAAE,EACnB,cAAc,OACd,SAAS,UACP;AAEJ,SAAQ,QAA2B;EAEjC,MAAM,iBAAiB,cAAc,QADf,IAAI,IAAI,QAAQ,IAAI,SAAS,IAAI,GACI;AAE3D,MAAI,CAAC,eAAgB;AAGrB,MAAI,QAAQ,IAAI,+BAA+B,eAAe;AAC9D,MAAI,YACF,KAAI,QAAQ,IAAI,oCAAoC,OAAO;AAE7D,MAAI,eAAe,SAAS,EAC1B,KAAI,QAAQ,IAAI,iCAAiC,eAAe,KAAK,KAAK,CAAC;AAE7E,MAAI,mBAAmB,IACrB,KAAI,QAAQ,OAAO,QAAQ,SAAS;AAItC,MAAI,IAAI,IAAI,WAAW,UACrB,QAAO,IAAI,SAAS,MAAM;GACxB,QAAQ;GACR,SAAS;IACP,+BAA+B;IAC/B,gCAAgC,QAAQ,KAAK,KAAK;IAClD,gCAAgC,eAAe,KAAK,KAAK;IACzD,0BAA0B,OAAO,OAAO;IACxC,GAAI,cAAc,EAAE,oCAAoC,QAAQ,GAAG,EAAE;IACtE;GACF,CAAC;;;AAKR,SAAS,cAAc,QAA8B,eAAsC;AACzF,KAAI,WAAW,IAAK,QAAO;AAC3B,KAAI,OAAO,WAAW,SACpB,QAAO,WAAW,gBAAgB,SAAS;AAE7C,KAAI,OAAO,WAAW,WACpB,QAAO,OAAO,cAAc,GAAG,gBAAgB;AAEjD,KAAI,MAAM,QAAQ,OAAO,CACvB,QAAO,OAAO,SAAS,cAAc,GAAG,gBAAgB;AAE1D,QAAO"}
package/lib/csp.js ADDED
@@ -0,0 +1,119 @@
1
+ import { useRequestLocals } from "@pyreon/server";
2
+
3
+ //#region src/csp.ts
4
+ /** Client-side fallback nonce (dev server, SPA). */
5
+ let _clientNonce = "";
6
+ /**
7
+ * Read the current CSP nonce in a component.
8
+ *
9
+ * SSR: reads from per-request `ctx.locals.cspNonce` via Pyreon's context
10
+ * system — fully isolated between concurrent requests via AsyncLocalStorage.
11
+ * Client/dev: falls back to module-level variable set by middleware.
12
+ *
13
+ * @example
14
+ * ```tsx
15
+ * import { useNonce } from "@pyreon/zero/csp"
16
+ *
17
+ * function InlineScript() {
18
+ * const nonce = useNonce()
19
+ * return <script nonce={nonce}>console.log("safe")<\/script>
20
+ * }
21
+ * ```
22
+ */
23
+ function useNonce() {
24
+ const locals = useRequestLocals();
25
+ if (locals.cspNonce) return locals.cspNonce;
26
+ return _clientNonce;
27
+ }
28
+ const DIRECTIVE_MAP = {
29
+ defaultSrc: "default-src",
30
+ scriptSrc: "script-src",
31
+ styleSrc: "style-src",
32
+ imgSrc: "img-src",
33
+ fontSrc: "font-src",
34
+ connectSrc: "connect-src",
35
+ mediaSrc: "media-src",
36
+ objectSrc: "object-src",
37
+ frameSrc: "frame-src",
38
+ childSrc: "child-src",
39
+ workerSrc: "worker-src",
40
+ frameAncestors: "frame-ancestors",
41
+ formAction: "form-action",
42
+ baseUri: "base-uri",
43
+ manifestSrc: "manifest-src",
44
+ reportUri: "report-uri",
45
+ reportTo: "report-to"
46
+ };
47
+ /**
48
+ * Build a CSP header string from directives.
49
+ * Exported for testing.
50
+ */
51
+ function buildCspHeader(directives, nonce) {
52
+ const parts = [];
53
+ for (const [key, cssProp] of Object.entries(DIRECTIVE_MAP)) {
54
+ const value = directives[key];
55
+ if (!value) continue;
56
+ if (Array.isArray(value)) {
57
+ const resolved = nonce ? value.map((v) => v === "'nonce'" ? `'nonce-${nonce}'` : v) : value.filter((v) => v !== "'nonce'");
58
+ parts.push(`${cssProp} ${resolved.join(" ")}`);
59
+ } else if (typeof value === "string") parts.push(`${cssProp} ${value}`);
60
+ }
61
+ if (directives.upgradeInsecureRequests) parts.push("upgrade-insecure-requests");
62
+ if (directives.blockAllMixedContent) parts.push("block-all-mixed-content");
63
+ return parts.join("; ");
64
+ }
65
+ /**
66
+ * Generate a random nonce string (base64, 16 bytes).
67
+ */
68
+ function generateNonce() {
69
+ if (typeof crypto !== "undefined" && crypto.getRandomValues) {
70
+ const bytes = new Uint8Array(16);
71
+ crypto.getRandomValues(bytes);
72
+ let binary = "";
73
+ for (const byte of bytes) binary += String.fromCharCode(byte);
74
+ return typeof btoa === "function" ? btoa(binary) : Buffer.from(bytes).toString("base64");
75
+ }
76
+ return Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
77
+ }
78
+ /**
79
+ * CSP middleware — sets Content-Security-Policy header.
80
+ *
81
+ * When directives contain `"'nonce'"`, a fresh nonce is generated per-request
82
+ * and attached to `ctx.locals.cspNonce` for use in inline script tags.
83
+ *
84
+ * @example
85
+ * ```ts
86
+ * // Apply to all routes
87
+ * export default defineConfig({
88
+ * middleware: [
89
+ * cspMiddleware({
90
+ * directives: {
91
+ * defaultSrc: ["'self'"],
92
+ * scriptSrc: ["'self'", "'nonce'"],
93
+ * styleSrc: ["'self'", "'unsafe-inline'"],
94
+ * imgSrc: ["'self'", "data:", "https:"],
95
+ * },
96
+ * }),
97
+ * ],
98
+ * })
99
+ * ```
100
+ */
101
+ function cspMiddleware(config) {
102
+ const headerName = config.reportOnly ? "Content-Security-Policy-Report-Only" : "Content-Security-Policy";
103
+ const staticHeader = Object.values(config.directives).some((v) => Array.isArray(v) && v.includes("'nonce'")) ? null : buildCspHeader(config.directives);
104
+ return (ctx) => {
105
+ if (staticHeader) {
106
+ _clientNonce = "";
107
+ ctx.headers.set(headerName, staticHeader);
108
+ } else {
109
+ const nonce = generateNonce();
110
+ _clientNonce = nonce;
111
+ ctx.locals.cspNonce = nonce;
112
+ ctx.headers.set(headerName, buildCspHeader(config.directives, nonce));
113
+ }
114
+ };
115
+ }
116
+
117
+ //#endregion
118
+ export { buildCspHeader, cspMiddleware, useNonce };
119
+ //# sourceMappingURL=csp.js.map
package/lib/csp.js.map ADDED
@@ -0,0 +1 @@
1
+ {"version":3,"file":"csp.js","names":[],"sources":["../src/csp.ts"],"sourcesContent":["/**\n * Content Security Policy middleware.\n *\n * Generates a CSP header from a typed configuration object.\n * Supports all CSP directives, nonces for inline scripts,\n * and report-only mode for testing.\n *\n * @example\n * ```ts\n * import { cspMiddleware } from \"@pyreon/zero\"\n *\n * const csp = cspMiddleware({\n * directives: {\n * defaultSrc: [\"'self'\"],\n * scriptSrc: [\"'self'\", \"'nonce'\"],\n * styleSrc: [\"'self'\", \"'unsafe-inline'\"],\n * imgSrc: [\"'self'\", \"data:\", \"https:\"],\n * connectSrc: [\"'self'\", \"https://api.example.com\"],\n * },\n * reportOnly: false,\n * })\n * ```\n */\nimport type { Middleware, MiddlewareContext } from '@pyreon/server'\nimport { useRequestLocals } from '@pyreon/server'\n\n/** Client-side fallback nonce (dev server, SPA). */\nlet _clientNonce = ''\n\n/**\n * Read the current CSP nonce in a component.\n *\n * SSR: reads from per-request `ctx.locals.cspNonce` via Pyreon's context\n * system — fully isolated between concurrent requests via AsyncLocalStorage.\n * Client/dev: falls back to module-level variable set by middleware.\n *\n * @example\n * ```tsx\n * import { useNonce } from \"@pyreon/zero/csp\"\n *\n * function InlineScript() {\n * const nonce = useNonce()\n * return <script nonce={nonce}>console.log(\"safe\")</script>\n * }\n * ```\n */\nexport function useNonce(): string {\n const locals = useRequestLocals()\n if (locals.cspNonce) return locals.cspNonce as string\n return _clientNonce\n}\n\nexport interface CspDirectives {\n defaultSrc?: string[]\n scriptSrc?: string[]\n styleSrc?: string[]\n imgSrc?: string[]\n fontSrc?: string[]\n connectSrc?: string[]\n mediaSrc?: string[]\n objectSrc?: string[]\n frameSrc?: string[]\n childSrc?: string[]\n workerSrc?: string[]\n frameAncestors?: string[]\n formAction?: string[]\n baseUri?: string[]\n manifestSrc?: string[]\n /** Reporting endpoint URL. */\n reportUri?: string\n /** Reporting endpoint name (CSP Level 3). */\n reportTo?: string\n /** Upgrade insecure requests. */\n upgradeInsecureRequests?: boolean\n /** Block all mixed content. */\n blockAllMixedContent?: boolean\n}\n\nexport interface CspConfig {\n /** CSP directives. */\n directives: CspDirectives\n /**\n * Report-only mode — logs violations without blocking.\n * Uses Content-Security-Policy-Report-Only header instead.\n * Default: false\n */\n reportOnly?: boolean\n}\n\nconst DIRECTIVE_MAP: Record<string, string> = {\n defaultSrc: 'default-src',\n scriptSrc: 'script-src',\n styleSrc: 'style-src',\n imgSrc: 'img-src',\n fontSrc: 'font-src',\n connectSrc: 'connect-src',\n mediaSrc: 'media-src',\n objectSrc: 'object-src',\n frameSrc: 'frame-src',\n childSrc: 'child-src',\n workerSrc: 'worker-src',\n frameAncestors: 'frame-ancestors',\n formAction: 'form-action',\n baseUri: 'base-uri',\n manifestSrc: 'manifest-src',\n reportUri: 'report-uri',\n reportTo: 'report-to',\n}\n\n/**\n * Build a CSP header string from directives.\n * Exported for testing.\n */\nexport function buildCspHeader(directives: CspDirectives, nonce?: string): string {\n const parts: string[] = []\n\n for (const [key, cssProp] of Object.entries(DIRECTIVE_MAP)) {\n const value = (directives as Record<string, unknown>)[key]\n if (!value) continue\n\n if (Array.isArray(value)) {\n // Replace \"'nonce'\" placeholder with actual nonce\n const resolved = nonce\n ? value.map((v: string) => (v === \"'nonce'\" ? `'nonce-${nonce}'` : v))\n : value.filter((v: string) => v !== \"'nonce'\")\n parts.push(`${cssProp} ${resolved.join(' ')}`)\n } else if (typeof value === 'string') {\n parts.push(`${cssProp} ${value}`)\n }\n }\n\n if (directives.upgradeInsecureRequests) {\n parts.push('upgrade-insecure-requests')\n }\n if (directives.blockAllMixedContent) {\n parts.push('block-all-mixed-content')\n }\n\n return parts.join('; ')\n}\n\n/**\n * Generate a random nonce string (base64, 16 bytes).\n */\nfunction generateNonce(): string {\n if (typeof crypto !== 'undefined' && crypto.getRandomValues) {\n const bytes = new Uint8Array(16)\n crypto.getRandomValues(bytes)\n // Convert to base64 using btoa\n let binary = ''\n for (const byte of bytes) binary += String.fromCharCode(byte)\n return typeof btoa === 'function'\n ? btoa(binary)\n : Buffer.from(bytes).toString('base64')\n }\n // Fallback for environments without crypto\n return Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2)\n}\n\n/**\n * CSP middleware — sets Content-Security-Policy header.\n *\n * When directives contain `\"'nonce'\"`, a fresh nonce is generated per-request\n * and attached to `ctx.locals.cspNonce` for use in inline script tags.\n *\n * @example\n * ```ts\n * // Apply to all routes\n * export default defineConfig({\n * middleware: [\n * cspMiddleware({\n * directives: {\n * defaultSrc: [\"'self'\"],\n * scriptSrc: [\"'self'\", \"'nonce'\"],\n * styleSrc: [\"'self'\", \"'unsafe-inline'\"],\n * imgSrc: [\"'self'\", \"data:\", \"https:\"],\n * },\n * }),\n * ],\n * })\n * ```\n */\nexport function cspMiddleware(config: CspConfig): Middleware {\n const headerName = config.reportOnly\n ? 'Content-Security-Policy-Report-Only'\n : 'Content-Security-Policy'\n\n // Check if nonce is needed\n const needsNonce = Object.values(config.directives).some(\n (v) => Array.isArray(v) && v.includes(\"'nonce'\"),\n )\n\n // Pre-build header for static case (no nonce)\n const staticHeader = needsNonce ? null : buildCspHeader(config.directives)\n\n return (ctx: MiddlewareContext) => {\n if (staticHeader) {\n _clientNonce = ''\n ctx.headers.set(headerName, staticHeader)\n } else {\n const nonce = generateNonce()\n _clientNonce = nonce\n ;(ctx.locals as Record<string, unknown>).cspNonce = nonce\n ctx.headers.set(headerName, buildCspHeader(config.directives, nonce))\n }\n }\n}\n"],"mappings":";;;;AA2BA,IAAI,eAAe;;;;;;;;;;;;;;;;;;AAmBnB,SAAgB,WAAmB;CACjC,MAAM,SAAS,kBAAkB;AACjC,KAAI,OAAO,SAAU,QAAO,OAAO;AACnC,QAAO;;AAwCT,MAAM,gBAAwC;CAC5C,YAAY;CACZ,WAAW;CACX,UAAU;CACV,QAAQ;CACR,SAAS;CACT,YAAY;CACZ,UAAU;CACV,WAAW;CACX,UAAU;CACV,UAAU;CACV,WAAW;CACX,gBAAgB;CAChB,YAAY;CACZ,SAAS;CACT,aAAa;CACb,WAAW;CACX,UAAU;CACX;;;;;AAMD,SAAgB,eAAe,YAA2B,OAAwB;CAChF,MAAM,QAAkB,EAAE;AAE1B,MAAK,MAAM,CAAC,KAAK,YAAY,OAAO,QAAQ,cAAc,EAAE;EAC1D,MAAM,QAAS,WAAuC;AACtD,MAAI,CAAC,MAAO;AAEZ,MAAI,MAAM,QAAQ,MAAM,EAAE;GAExB,MAAM,WAAW,QACb,MAAM,KAAK,MAAe,MAAM,YAAY,UAAU,MAAM,KAAK,EAAG,GACpE,MAAM,QAAQ,MAAc,MAAM,UAAU;AAChD,SAAM,KAAK,GAAG,QAAQ,GAAG,SAAS,KAAK,IAAI,GAAG;aACrC,OAAO,UAAU,SAC1B,OAAM,KAAK,GAAG,QAAQ,GAAG,QAAQ;;AAIrC,KAAI,WAAW,wBACb,OAAM,KAAK,4BAA4B;AAEzC,KAAI,WAAW,qBACb,OAAM,KAAK,0BAA0B;AAGvC,QAAO,MAAM,KAAK,KAAK;;;;;AAMzB,SAAS,gBAAwB;AAC/B,KAAI,OAAO,WAAW,eAAe,OAAO,iBAAiB;EAC3D,MAAM,QAAQ,IAAI,WAAW,GAAG;AAChC,SAAO,gBAAgB,MAAM;EAE7B,IAAI,SAAS;AACb,OAAK,MAAM,QAAQ,MAAO,WAAU,OAAO,aAAa,KAAK;AAC7D,SAAO,OAAO,SAAS,aACnB,KAAK,OAAO,GACZ,OAAO,KAAK,MAAM,CAAC,SAAS,SAAS;;AAG3C,QAAO,KAAK,QAAQ,CAAC,SAAS,GAAG,CAAC,MAAM,EAAE,GAAG,KAAK,QAAQ,CAAC,SAAS,GAAG,CAAC,MAAM,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;AA0BlF,SAAgB,cAAc,QAA+B;CAC3D,MAAM,aAAa,OAAO,aACtB,wCACA;CAQJ,MAAM,eALa,OAAO,OAAO,OAAO,WAAW,CAAC,MACjD,MAAM,MAAM,QAAQ,EAAE,IAAI,EAAE,SAAS,UAAU,CACjD,GAGiC,OAAO,eAAe,OAAO,WAAW;AAE1E,SAAQ,QAA2B;AACjC,MAAI,cAAc;AAChB,kBAAe;AACf,OAAI,QAAQ,IAAI,YAAY,aAAa;SACpC;GACL,MAAM,QAAQ,eAAe;AAC7B,kBAAe;AACd,GAAC,IAAI,OAAmC,WAAW;AACpD,OAAI,QAAQ,IAAI,YAAY,eAAe,OAAO,YAAY,MAAM,CAAC"}