nukejs 0.0.10 → 0.0.11

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/router.ts"],
4
- "sourcesContent": ["/**\r\n * router.ts \u2014 File-System Based URL Router\r\n *\r\n * Maps incoming URL paths to handler files using Next.js-compatible conventions:\r\n *\r\n * server/users/index.ts \u2192 /users\r\n * server/users/[id].ts \u2192 /users/:id (dynamic segment)\r\n * server/blog/[...slug].ts \u2192 /blog/* (catch-all)\r\n * server/files/[[...path]].ts \u2192 /files or /files/* (optional catch-all)\r\n *\r\n * Route specificity (higher = wins over lower):\r\n * static segment +4 (e.g. 'about')\r\n * dynamic segment +3 (e.g. '[id]')\r\n * catch-all +2 (e.g. '[...slug]')\r\n * optional catch-all +1 (e.g. '[[...path]]')\r\n *\r\n * Path traversal protection:\r\n * matchRoute() rejects URL segments that contain '..' or '.' and verifies\r\n * that the resolved file path stays inside the base directory before\r\n * checking whether the file exists.\r\n */\r\n\r\nimport path from 'path';\r\nimport fs from 'fs';\r\n\r\n// \u2500\u2500\u2500 Route file discovery \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/**\r\n * Recursively collects all routable .ts/.tsx files in `dir`, returning paths\r\n * relative to `baseDir` without the file extension.\r\n *\r\n * layout.tsx files are excluded \u2014 they wrap pages but are never routes\r\n * themselves. This mirrors the filter in collectServerPages() so dev-mode\r\n * route matching behaves identically to the production build.\r\n *\r\n * Example output: ['index', 'users/index', 'users/[id]', 'blog/[...slug]']\r\n */\r\nexport function findAllRoutes(dir: string, baseDir: string = dir): string[] {\r\n if (!fs.existsSync(dir)) return [];\r\n\r\n const routes: string[] = [];\r\n for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {\r\n const fullPath = path.join(dir, entry.name);\r\n if (entry.isDirectory()) {\r\n routes.push(...findAllRoutes(fullPath, baseDir));\r\n } else if (entry.name.endsWith('.tsx') || entry.name.endsWith('.ts')) {\r\n const stem = entry.name.replace(/\\.(tsx|ts)$/, '');\r\n if (stem === 'layout') continue;\r\n routes.push(path.relative(baseDir, fullPath).replace(/\\.(tsx|ts)$/, ''));\r\n }\r\n }\r\n return routes;\r\n}\r\n\r\n// \u2500\u2500\u2500 Dynamic segment matching \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/**\r\n * Attempts to match `urlSegments` against a route that may contain dynamic\r\n * segments ([param]), catch-alls ([...slug]), and optional catch-alls ([[...path]]).\r\n *\r\n * Returns the captured params on success, or null if the route does not match.\r\n *\r\n * Param value types:\r\n * [param] \u2192 string\r\n * [...slug] \u2192 string[] (at least one segment required)\r\n * [[...path]] \u2192 string[] (zero or more segments)\r\n */\r\nexport function matchDynamicRoute(\r\n urlSegments: string[],\r\n routePath: string,\r\n): { params: Record<string, string | string[]> } | null {\r\n const routeSegments = routePath.split(path.sep);\r\n\r\n // 'index' at the end of a route path means the route handles the parent directory URL.\r\n if (routeSegments.at(-1) === 'index') routeSegments.pop();\r\n\r\n const params: Record<string, string | string[]> = {};\r\n let ri = 0; // route segment index\r\n let ui = 0; // URL segment index\r\n\r\n while (ri < routeSegments.length) {\r\n const seg = routeSegments[ri];\r\n\r\n // [[...name]] \u2014 optional catch-all: consumes zero or more remaining URL segments.\r\n const optCatchAll = seg.match(/^\\[\\[\\.\\.\\.(.+)\\]\\]$/);\r\n if (optCatchAll) {\r\n params[optCatchAll[1]] = urlSegments.slice(ui);\r\n return { params };\r\n }\r\n\r\n // [[name]] \u2014 optional single segment: consumes zero or one URL segment.\r\n const optDynamic = seg.match(/^\\[\\[([^.][^\\]]*)\\]\\]$/);\r\n if (optDynamic) {\r\n if (ui < urlSegments.length) {\r\n params[optDynamic[1]] = urlSegments[ui++];\r\n } else {\r\n params[optDynamic[1]] = '';\r\n }\r\n ri++;\r\n continue;\r\n }\r\n\r\n // [...name] \u2014 required catch-all: must consume at least one URL segment.\r\n const catchAll = seg.match(/^\\[\\.\\.\\.(.+)\\]$/);\r\n if (catchAll) {\r\n const remaining = urlSegments.slice(ui);\r\n if (!remaining.length) return null;\r\n params[catchAll[1]] = remaining;\r\n return { params };\r\n }\r\n\r\n // [name] \u2014 single dynamic segment: consumes exactly one URL segment.\r\n const dynamic = seg.match(/^\\[(.+)\\]$/);\r\n if (dynamic) {\r\n if (ui >= urlSegments.length) return null;\r\n params[dynamic[1]] = urlSegments[ui++];\r\n ri++;\r\n continue;\r\n }\r\n\r\n // Static segment \u2014 must match exactly.\r\n if (ui >= urlSegments.length || seg !== urlSegments[ui]) return null;\r\n ui++; ri++;\r\n }\r\n\r\n // All route segments consumed \u2014 URL must be fully consumed too.\r\n return ui < urlSegments.length ? null : { params };\r\n}\r\n\r\n// \u2500\u2500\u2500 Specificity scoring \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/**\r\n * Computes a specificity score for a route path.\r\n * Used to sort candidate routes so more specific routes shadow catch-alls.\r\n *\r\n * Higher score = more specific:\r\n * static segment 4\r\n * [dynamic] 3\r\n * [...catchAll] 2\r\n * [[...optCatchAll]] 1\r\n */\r\nexport function getRouteSpecificity(routePath: string): number {\r\n return routePath.split(path.sep).reduce((score, seg) => {\r\n if (seg.match(/^\\[\\[\\.\\.\\.(.+)\\]\\]$/)) return score + 1; // [[...a]] optional catch-all\r\n if (seg.match(/^\\[\\.\\.\\.(.+)\\]$/)) return score + 2; // [...a] required catch-all\r\n if (seg.match(/^\\[\\[([^.][^\\]]*)\\]\\]$/)) return score + 3; // [[a]] optional single\r\n if (seg.match(/^\\[(.+)\\]$/)) return score + 4; // [a] required single\r\n return score + 5; // static segment\r\n }, 0);\r\n}\r\n\r\n// \u2500\u2500\u2500 Route match result \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\nexport interface RouteMatch {\r\n filePath: string;\r\n params: Record<string, string | string[]>;\r\n routePattern: string;\r\n}\r\n\r\n// \u2500\u2500\u2500 Path traversal guard \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/**\r\n * Returns true only when `filePath` is a descendant of `baseDir`.\r\n * Used to prevent URL path traversal attacks (e.g. /../../etc/passwd).\r\n */\r\nfunction isWithinBase(baseDir: string, filePath: string): boolean {\r\n const rel = path.relative(baseDir, filePath);\r\n return Boolean(rel) && !rel.startsWith('..') && !path.isAbsolute(rel);\r\n}\r\n\r\n// \u2500\u2500\u2500 Route matching \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/**\r\n * Resolves a URL path to a route file inside `baseDir`.\r\n *\r\n * Steps:\r\n * 1. Reject '..' or '.' path segments (path traversal guard).\r\n * 2. Try an exact file match (e.g. /about \u2192 baseDir/about.tsx).\r\n * layout.tsx is explicitly excluded from exact matching.\r\n * 3. Sort all discovered routes by specificity (most specific first).\r\n * 4. Return the first dynamic route that matches.\r\n *\r\n * @param urlPath The URL path to match (e.g. '/users/42').\r\n * @param baseDir Absolute path to the directory containing route files.\r\n * @param extension File extension to look for ('.tsx' or '.ts').\r\n */\r\nexport function matchRoute(\r\n urlPath: string,\r\n baseDir: string,\r\n extension = '.tsx',\r\n): RouteMatch | null {\r\n // Normalise trailing slashes: /test/ \u2192 /test, but keep root / as-is.\r\n const normPath = urlPath.length > 1 ? urlPath.replace(/\\/+$/, '') : urlPath;\r\n // Split into segments, rejecting any that attempt path traversal.\r\n const rawSegments = normPath === '/' ? [] : normPath.slice(1).split('/');\r\n if (rawSegments.some(s => s === '..' || s === '.')) return null;\r\n\r\n // For the root URL, look for an index file.\r\n const segments = rawSegments.length === 0 ? ['index'] : rawSegments;\r\n\r\n // 1. Exact match: /about \u2192 about.tsx \u2014 never resolve to a layout file.\r\n const exactPath = path.join(baseDir, ...segments) + extension;\r\n const exactStem = path.basename(exactPath, extension);\r\n if (!isWithinBase(baseDir, exactPath)) return null;\r\n if (exactStem !== 'layout' && fs.existsSync(exactPath)) {\r\n return { filePath: exactPath, params: {}, routePattern: segments.join('/') };\r\n }\r\n\r\n // 2. Dynamic match \u2014 use rawSegments (not ['index']) so that [param] routes\r\n // do not accidentally match '/' by consuming the synthetic 'index' segment.\r\n const sortedRoutes = findAllRoutes(baseDir).sort(\r\n (a, b) => getRouteSpecificity(b) - getRouteSpecificity(a),\r\n );\r\n\r\n for (const route of sortedRoutes) {\r\n const match = matchDynamicRoute(rawSegments, route);\r\n if (!match) continue;\r\n const filePath = path.join(baseDir, route) + extension;\r\n if (!isWithinBase(baseDir, filePath)) continue;\r\n if (fs.existsSync(filePath)) {\r\n return { filePath, params: match.params, routePattern: route };\r\n }\r\n }\r\n\r\n return null;\r\n}\r\n\r\n// \u2500\u2500\u2500 Layout discovery \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/**\r\n * Returns every layout.tsx file that wraps a given route file, in\r\n * outermost-first order (root layout first, nearest layout last).\r\n *\r\n * Layout chain example for app/pages/blog/[slug]/page.tsx:\r\n * app/pages/layout.tsx \u2190 root layout\r\n * app/pages/blog/layout.tsx \u2190 blog section layout\r\n *\r\n * The outermost-first order matches how wrapWithLayouts() nests them:\r\n * the last layout in the array is the innermost wrapper.\r\n */\r\nexport function findLayoutsForRoute(routeFilePath: string, pagesDir: string): string[] {\r\n const layouts: string[] = [];\r\n\r\n // Root layout wraps everything.\r\n const rootLayout = path.join(pagesDir, 'layout.tsx');\r\n if (fs.existsSync(rootLayout)) layouts.push(rootLayout);\r\n\r\n // Walk the directory hierarchy from pagesDir to the file's parent.\r\n const relativePath = path.relative(pagesDir, path.dirname(routeFilePath));\r\n if (!relativePath || relativePath === '.') return layouts;\r\n\r\n const segments = relativePath.split(path.sep).filter(s => s !== '.');\r\n for (let i = 1; i <= segments.length; i++) {\r\n const layoutPath = path.join(pagesDir, ...segments.slice(0, i), 'layout.tsx');\r\n if (fs.existsSync(layoutPath)) layouts.push(layoutPath);\r\n }\r\n\r\n return layouts;\r\n}"],
5
- "mappings": "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",
4
+ "sourcesContent": ["/**\r\n * router.ts \u2014 File-System Based URL Router\r\n *\r\n * Maps incoming URL paths to handler files using Next.js-compatible conventions:\r\n *\r\n * server/users/index.ts \u2192 /users\r\n * server/users/[id].ts \u2192 /users/:id (dynamic segment)\r\n * server/blog/[...slug].ts \u2192 /blog/* (catch-all)\r\n * server/files/[[...path]].ts \u2192 /files or /files/* (optional catch-all)\r\n *\r\n * Route specificity (higher = wins over lower):\r\n * static segment +5 (e.g. 'about')\r\n * dynamic single +4 (e.g. '[id]')\r\n * optional single +3 (e.g. '[[id]]')\r\n * required catch-all +2 (e.g. '[...slug]')\r\n * optional catch-all +1 (e.g. '[[...path]]')\r\n * (scores match getRouteSpecificity in this file)\r\n *\r\n * Path traversal protection:\r\n * matchRoute() rejects URL segments that contain '..' or '.' and verifies\r\n * that the resolved file path stays inside the base directory before\r\n * checking whether the file exists.\r\n */\r\n\r\nimport path from 'path';\r\nimport fs from 'fs';\r\n\r\n// \u2500\u2500\u2500 Route file discovery \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/**\r\n * Recursively collects all routable .ts/.tsx files in `dir`, returning paths\r\n * relative to `baseDir` without the file extension.\r\n *\r\n * layout.tsx files are excluded \u2014 they wrap pages but are never routes\r\n * themselves. This mirrors the filter in collectServerPages() so dev-mode\r\n * route matching behaves identically to the production build.\r\n *\r\n * Example output: ['index', 'users/index', 'users/[id]', 'blog/[...slug]']\r\n */\r\nexport function findAllRoutes(dir: string, baseDir: string = dir): string[] {\r\n if (!fs.existsSync(dir)) return [];\r\n\r\n const routes: string[] = [];\r\n for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {\r\n const fullPath = path.join(dir, entry.name);\r\n if (entry.isDirectory()) {\r\n routes.push(...findAllRoutes(fullPath, baseDir));\r\n } else if (entry.name.endsWith('.tsx') || entry.name.endsWith('.ts')) {\r\n const stem = entry.name.replace(/\\.(tsx|ts)$/, '');\r\n if (stem === 'layout') continue;\r\n routes.push(path.relative(baseDir, fullPath).replace(/\\.(tsx|ts)$/, ''));\r\n }\r\n }\r\n return routes;\r\n}\r\n\r\n// \u2500\u2500\u2500 Dynamic segment matching \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/**\r\n * Attempts to match `urlSegments` against a route that may contain dynamic\r\n * segments ([param]), catch-alls ([...slug]), and optional catch-alls ([[...path]]).\r\n *\r\n * Returns the captured params on success, or null if the route does not match.\r\n *\r\n * Param value types:\r\n * [param] \u2192 string\r\n * [...slug] \u2192 string[] (at least one segment required)\r\n * [[...path]] \u2192 string[] (zero or more segments)\r\n */\r\nexport function matchDynamicRoute(\r\n urlSegments: string[],\r\n routePath: string,\r\n): { params: Record<string, string | string[]> } | null {\r\n const routeSegments = routePath.split(path.sep);\r\n\r\n // 'index' at the end of a route path means the route handles the parent directory URL.\r\n if (routeSegments.at(-1) === 'index') routeSegments.pop();\r\n\r\n const params: Record<string, string | string[]> = {};\r\n let ri = 0; // route segment index\r\n let ui = 0; // URL segment index\r\n\r\n while (ri < routeSegments.length) {\r\n const seg = routeSegments[ri];\r\n\r\n // [[...name]] \u2014 optional catch-all: consumes zero or more remaining URL segments.\r\n const optCatchAll = seg.match(/^\\[\\[\\.\\.\\.(.+)\\]\\]$/);\r\n if (optCatchAll) {\r\n params[optCatchAll[1]] = urlSegments.slice(ui);\r\n return { params };\r\n }\r\n\r\n // [[name]] \u2014 optional single segment: consumes zero or one URL segment.\r\n const optDynamic = seg.match(/^\\[\\[([^.][^\\]]*)\\]\\]$/);\r\n if (optDynamic) {\r\n if (ui < urlSegments.length) {\r\n params[optDynamic[1]] = urlSegments[ui++];\r\n } else {\r\n params[optDynamic[1]] = '';\r\n }\r\n ri++;\r\n continue;\r\n }\r\n\r\n // [...name] \u2014 required catch-all: must consume at least one URL segment.\r\n const catchAll = seg.match(/^\\[\\.\\.\\.(.+)\\]$/);\r\n if (catchAll) {\r\n const remaining = urlSegments.slice(ui);\r\n if (!remaining.length) return null;\r\n params[catchAll[1]] = remaining;\r\n return { params };\r\n }\r\n\r\n // [name] \u2014 single dynamic segment: consumes exactly one URL segment.\r\n const dynamic = seg.match(/^\\[(.+)\\]$/);\r\n if (dynamic) {\r\n if (ui >= urlSegments.length) return null;\r\n params[dynamic[1]] = urlSegments[ui++];\r\n ri++;\r\n continue;\r\n }\r\n\r\n // Static segment \u2014 must match exactly.\r\n if (ui >= urlSegments.length || seg !== urlSegments[ui]) return null;\r\n ui++; ri++;\r\n }\r\n\r\n // All route segments consumed \u2014 URL must be fully consumed too.\r\n return ui < urlSegments.length ? null : { params };\r\n}\r\n\r\n// \u2500\u2500\u2500 Specificity scoring \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/**\r\n * Computes a specificity score for a route path.\r\n * Used to sort candidate routes so more specific routes shadow catch-alls.\r\n *\r\n * Higher score = more specific:\r\n * static segment 5\r\n * [dynamic] 4\r\n * [[optSingle]] 3\r\n * [...catchAll] 2\r\n * [[...optCatchAll]] 1\r\n */\r\nexport function getRouteSpecificity(routePath: string): number {\r\n return routePath.split(path.sep).reduce((score, seg) => {\r\n if (seg.match(/^\\[\\[\\.\\.\\.(.+)\\]\\]$/)) return score + 1; // [[...a]] optional catch-all\r\n if (seg.match(/^\\[\\.\\.\\.(.+)\\]$/)) return score + 2; // [...a] required catch-all\r\n if (seg.match(/^\\[\\[([^.][^\\]]*)\\]\\]$/)) return score + 3; // [[a]] optional single\r\n if (seg.match(/^\\[(.+)\\]$/)) return score + 4; // [a] required single\r\n return score + 5; // static segment\r\n }, 0);\r\n}\r\n\r\n// \u2500\u2500\u2500 Route match result \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\nexport interface RouteMatch {\r\n filePath: string;\r\n params: Record<string, string | string[]>;\r\n routePattern: string;\r\n}\r\n\r\n// \u2500\u2500\u2500 Path traversal guard \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/**\r\n * Returns true only when `filePath` is a descendant of `baseDir`.\r\n * Used to prevent URL path traversal attacks (e.g. /../../etc/passwd).\r\n */\r\nfunction isWithinBase(baseDir: string, filePath: string): boolean {\r\n const rel = path.relative(baseDir, filePath);\r\n return Boolean(rel) && !rel.startsWith('..') && !path.isAbsolute(rel);\r\n}\r\n\r\n// \u2500\u2500\u2500 Route matching \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/**\r\n * Resolves a URL path to a route file inside `baseDir`.\r\n *\r\n * Steps:\r\n * 1. Reject '..' or '.' path segments (path traversal guard).\r\n * 2. Try an exact file match (e.g. /about \u2192 baseDir/about.tsx).\r\n * layout.tsx is explicitly excluded from exact matching.\r\n * 3. Sort all discovered routes by specificity (most specific first).\r\n * 4. Return the first dynamic route that matches.\r\n *\r\n * @param urlPath The URL path to match (e.g. '/users/42').\r\n * @param baseDir Absolute path to the directory containing route files.\r\n * @param extension File extension to look for ('.tsx' or '.ts').\r\n */\r\nexport function matchRoute(\r\n urlPath: string,\r\n baseDir: string,\r\n extension = '.tsx',\r\n): RouteMatch | null {\r\n // Normalise trailing slashes: /test/ \u2192 /test, but keep root / as-is.\r\n const normPath = urlPath.length > 1 ? urlPath.replace(/\\/+$/, '') : urlPath;\r\n // Split into segments, rejecting any that attempt path traversal.\r\n const rawSegments = normPath === '/' ? [] : normPath.slice(1).split('/');\r\n if (rawSegments.some(s => s === '..' || s === '.')) return null;\r\n\r\n // For the root URL, look for an index file.\r\n const segments = rawSegments.length === 0 ? ['index'] : rawSegments;\r\n\r\n // 1. Exact match: /about \u2192 about.tsx \u2014 never resolve to a layout file.\r\n const exactPath = path.join(baseDir, ...segments) + extension;\r\n const exactStem = path.basename(exactPath, extension);\r\n if (!isWithinBase(baseDir, exactPath)) return null;\r\n if (exactStem !== 'layout' && fs.existsSync(exactPath)) {\r\n return { filePath: exactPath, params: {}, routePattern: segments.join('/') };\r\n }\r\n\r\n // 2. Dynamic match \u2014 use rawSegments (not ['index']) so that [param] routes\r\n // do not accidentally match '/' by consuming the synthetic 'index' segment.\r\n const sortedRoutes = findAllRoutes(baseDir).sort(\r\n (a, b) => getRouteSpecificity(b) - getRouteSpecificity(a),\r\n );\r\n\r\n for (const route of sortedRoutes) {\r\n const match = matchDynamicRoute(rawSegments, route);\r\n if (!match) continue;\r\n const filePath = path.join(baseDir, route) + extension;\r\n if (!isWithinBase(baseDir, filePath)) continue;\r\n if (fs.existsSync(filePath)) {\r\n return { filePath, params: match.params, routePattern: route };\r\n }\r\n }\r\n\r\n return null;\r\n}\r\n\r\n// \u2500\u2500\u2500 Layout discovery \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/**\r\n * Returns every layout.tsx file that wraps a given route file, in\r\n * outermost-first order (root layout first, nearest layout last).\r\n *\r\n * Layout chain example for app/pages/blog/[slug]/page.tsx:\r\n * app/pages/layout.tsx \u2190 root layout\r\n * app/pages/blog/layout.tsx \u2190 blog section layout\r\n *\r\n * The outermost-first order matches how wrapWithLayouts() nests them:\r\n * the last layout in the array is the innermost wrapper.\r\n */\r\nexport function findLayoutsForRoute(routeFilePath: string, pagesDir: string): string[] {\r\n const layouts: string[] = [];\r\n\r\n // Root layout wraps everything.\r\n const rootLayout = path.join(pagesDir, 'layout.tsx');\r\n if (fs.existsSync(rootLayout)) layouts.push(rootLayout);\r\n\r\n // Walk the directory hierarchy from pagesDir to the file's parent.\r\n const relativePath = path.relative(pagesDir, path.dirname(routeFilePath));\r\n if (!relativePath || relativePath === '.') return layouts;\r\n\r\n const segments = relativePath.split(path.sep).filter(s => s !== '.');\r\n for (let i = 1; i <= segments.length; i++) {\r\n const layoutPath = path.join(pagesDir, ...segments.slice(0, i), 'layout.tsx');\r\n if (fs.existsSync(layoutPath)) layouts.push(layoutPath);\r\n }\r\n\r\n return layouts;\r\n}"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/dist/ssr.d.ts CHANGED
@@ -34,6 +34,7 @@
34
34
  * Pages with no useHtml head tags emit no sentinels.
35
35
  */
36
36
  import type { ServerResponse } from 'http';
37
+ import type { IncomingMessage } from 'http';
37
38
  /**
38
39
  * Renders a page for the given URL and writes the full HTML response.
39
40
  *
@@ -42,4 +43,4 @@ import type { ServerResponse } from 'http';
42
43
  * @param pagesDir Absolute path to the app/pages directory.
43
44
  * @param isDev When true, injects the HMR client script into the page.
44
45
  */
45
- export declare function serverSideRender(url: string, res: ServerResponse, pagesDir: string, isDev?: boolean): Promise<void>;
46
+ export declare function serverSideRender(url: string, res: ServerResponse, pagesDir: string, isDev?: boolean, req?: IncomingMessage): Promise<void>;
package/dist/ssr.js CHANGED
@@ -6,6 +6,7 @@ import { log, getDebugLevel } from "./logger.js";
6
6
  import { matchRoute, findLayoutsForRoute } from "./router.js";
7
7
  import { findClientComponentsInTree } from "./component-analyzer.js";
8
8
  import { renderElementToHtml } from "./renderer.js";
9
+ import { runWithRequestStore, normaliseHeaders, sanitiseHeaders } from "./request-store.js";
9
10
  import {
10
11
  runWithHtmlStore,
11
12
  resolveTitle
@@ -89,7 +90,7 @@ function renderManagedBodyScripts(store) {
89
90
  if (bodyScripts.length === 0) return [];
90
91
  return [" <!--n-body-scripts-->", ...bodyScripts.map(renderScriptTag), " <!--/n-body-scripts-->"];
91
92
  }
92
- async function serverSideRender(url, res, pagesDir, isDev = false) {
93
+ async function serverSideRender(url, res, pagesDir, isDev = false, req) {
93
94
  const skipClientSSR = url.includes("__hmr=1");
94
95
  const cleanUrl = url.split("?")[0];
95
96
  const routeMatch = matchRoute(cleanUrl, pagesDir);
@@ -110,6 +111,9 @@ async function serverSideRender(url, res, pagesDir, isDev = false) {
110
111
  }
111
112
  });
112
113
  const mergedParams = { ...queryParams, ...params };
114
+ const rawHeaders = req?.headers ?? {};
115
+ const normHeaders = normaliseHeaders(rawHeaders);
116
+ const safeHeaders = sanitiseHeaders(rawHeaders);
113
117
  const layoutPaths = findLayoutsForRoute(filePath, pagesDir);
114
118
  const { default: PageComponent } = await tsImport(
115
119
  pathToFileURL(filePath).href,
@@ -131,9 +135,18 @@ async function serverSideRender(url, res, pagesDir, isDev = false) {
131
135
  );
132
136
  const ctx = { registry, hydrated: /* @__PURE__ */ new Set(), skipClientSSR };
133
137
  let appHtml = "";
134
- const store = await runWithHtmlStore(async () => {
135
- appHtml = await renderElementToHtml(wrappedElement, ctx);
136
- });
138
+ const store = await runWithRequestStore(
139
+ {
140
+ url,
141
+ pathname: cleanUrl,
142
+ params,
143
+ query: queryParams,
144
+ headers: normHeaders
145
+ },
146
+ () => runWithHtmlStore(async () => {
147
+ appHtml = await renderElementToHtml(wrappedElement, ctx);
148
+ })
149
+ );
137
150
  const pageTitle = resolveTitle(store.titleOps, "NukeJS");
138
151
  const headLines = [
139
152
  ' <meta charset="utf-8" />',
@@ -146,6 +159,8 @@ async function serverSideRender(url, res, pagesDir, isDev = false) {
146
159
  allIds: [...registry.keys()],
147
160
  url,
148
161
  params,
162
+ query: queryParams,
163
+ headers: safeHeaders,
149
164
  debug: toClientDebugLevel(getDebugLevel())
150
165
  }).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026");
151
166
  const bodyScriptLines = renderManagedBodyScripts(store);
package/dist/ssr.js.map CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/ssr.ts"],
4
- "sourcesContent": ["/**\r\n * ssr.ts \u2014 Server-Side Rendering Pipeline (Dev Mode)\r\n *\r\n * Handles the full page render cycle for `nuke dev`:\r\n *\r\n * 1. Match the incoming URL to a page file in app/pages using the file-system\r\n * router.\r\n * 2. Discover the layout chain (layout.tsx files from root to the page dir).\r\n * 3. Dynamically import the page and layout modules (always fresh via tsImport).\r\n * 4. Walk the import tree to discover all \"use client\" components.\r\n * 5. Render the wrapped element tree with the async renderer.\r\n * 6. Flush the html-store (title, meta, link, script, style tags).\r\n * 7. Assemble and send the full HTML document including:\r\n * - The rendered app HTML\r\n * - An importmap pointing react/nukejs to the bundled versions\r\n * - A __n_data JSON blob with hydration IDs and runtime config\r\n * - An inline bootstrap <script> that calls initRuntime()\r\n * - (dev only) A /__hmr.js script for Hot Module Replacement\r\n *\r\n * In production, a pre-built standalone handler (generated by build-common.ts)\r\n * handles each page without dynamic imports or file-system access.\r\n *\r\n * HMR fast path:\r\n * When the request URL contains `__hmr=1` (added by the HMR client during\r\n * soft navigation), the renderer skips client-component renderToString\r\n * (ctx.skipClientSSR = true). This speeds up HMR reloads because the\r\n * client already has the DOM in place and only needs fresh server markup.\r\n *\r\n * Head tag sentinels:\r\n * Every useHtml()-generated <meta>, <link>, <style>, and <script> tag is\r\n * wrapped in <!--n-head-->\u2026<!--/n-head--> comment sentinels. The client\r\n * runtime uses these to diff and sync head tags on SPA navigation without\r\n * touching permanent tags (charset, viewport, importmap, runtime script).\r\n * Pages with no useHtml head tags emit no sentinels.\r\n */\r\n\r\nimport path from 'path';\r\nimport { createElement } from 'react';\r\nimport { pathToFileURL } from 'url';\r\nimport { tsImport } from 'tsx/esm/api';\r\nimport type { ServerResponse } from 'http';\r\nimport { log, getDebugLevel, type DebugLevel } from './logger';\r\nimport { matchRoute, findLayoutsForRoute } from './router';\r\nimport { findClientComponentsInTree } from './component-analyzer';\r\nimport { renderElementToHtml, type RenderContext } from './renderer';\r\nimport {\r\n runWithHtmlStore,\r\n resolveTitle,\r\n type HtmlStore,\r\n type HtmlAttrs,\r\n type BodyAttrs,\r\n type MetaTag,\r\n type LinkTag,\r\n type ScriptTag,\r\n type StyleTag,\r\n} from './html-store';\r\n\r\n// \u2500\u2500\u2500 Layout wrapping \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/**\r\n * Wraps a page element in its layout chain, outermost-first.\r\n * Each layout receives `{ children: innerElement }` as props.\r\n *\r\n * tsImport creates a fresh isolated module namespace per call so edits to\r\n * layouts are reflected immediately without a server restart.\r\n */\r\nasync function wrapWithLayouts(pageElement: any, layoutPaths: string[]): Promise<any> {\r\n let element = pageElement;\r\n // Iterate in reverse so the outermost layout wraps last (becomes the root).\r\n for (let i = layoutPaths.length - 1; i >= 0; i--) {\r\n const { default: LayoutComponent } = await tsImport(\r\n pathToFileURL(layoutPaths[i]).href,\r\n { parentURL: import.meta.url },\r\n );\r\n element = createElement(LayoutComponent, { children: element });\r\n }\r\n return element;\r\n}\r\n\r\n// \u2500\u2500\u2500 Debug level conversion \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/**\r\n * Converts the server-side DebugLevel to the string format expected by the\r\n * browser's makeLogger() function in bundle.ts.\r\n */\r\nfunction toClientDebugLevel(level: DebugLevel): string {\r\n if (level === true) return 'verbose';\r\n if (level === 'info') return 'info';\r\n if (level === 'error') return 'error';\r\n return 'silent';\r\n}\r\n\r\n// \u2500\u2500\u2500 HTML serialization helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\nfunction escapeAttr(str: string): string {\r\n return str.replace(/&/g, '&amp;').replace(/\"/g, '&quot;');\r\n}\r\n\r\n/** Serializes an attribute map to `key=\"value\" \u2026`, omitting undefined/false values. */\r\nfunction renderAttrs(attrs: Record<string, string | boolean | undefined>): string {\r\n return Object.entries(attrs)\r\n .filter(([, v]) => v !== undefined && v !== false)\r\n .map(([k, v]) => v === true ? k : `${k}=\"${escapeAttr(String(v))}\"`)\r\n .join(' ');\r\n}\r\n\r\n/** Returns `<tag attrs>` or `<tag>` depending on whether attrs are non-empty. */\r\nfunction openTag(tag: string, attrs: Record<string, string | undefined>): string {\r\n const str = renderAttrs(attrs);\r\n return str ? `<${tag} ${str}>` : `<${tag}>`;\r\n}\r\n\r\n// \u2500\u2500\u2500 Head tag renderers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\nfunction metaKey(k: string): string {\r\n return k === 'httpEquiv' ? 'http-equiv' : k;\r\n}\r\n\r\nfunction linkKey(k: string): string {\r\n if (k === 'hrefLang') return 'hreflang';\r\n if (k === 'crossOrigin') return 'crossorigin';\r\n return k;\r\n}\r\n\r\nfunction renderMetaTag(tag: MetaTag): string {\r\n const attrs: Record<string, string | undefined> = {};\r\n for (const [k, v] of Object.entries(tag)) if (v !== undefined) attrs[metaKey(k)] = v;\r\n return ` <meta ${renderAttrs(attrs)} />`;\r\n}\r\n\r\nfunction renderLinkTag(tag: LinkTag): string {\r\n const attrs: Record<string, string | undefined> = {};\r\n for (const [k, v] of Object.entries(tag)) if (v !== undefined) attrs[linkKey(k)] = v;\r\n return ` <link ${renderAttrs(attrs)} />`;\r\n}\r\n\r\nfunction renderScriptTag(tag: ScriptTag): string {\r\n const attrs: Record<string, string | boolean | undefined> = {\r\n src: tag.src,\r\n type: tag.type,\r\n crossorigin: tag.crossOrigin,\r\n integrity: tag.integrity,\r\n defer: tag.defer,\r\n async: tag.async,\r\n nomodule: tag.noModule,\r\n };\r\n const attrStr = renderAttrs(attrs);\r\n const open = attrStr ? `<script ${attrStr}>` : '<script>';\r\n return ` ${open}${tag.src ? '' : (tag.content ?? '')}</script>`;\r\n}\r\n\r\nfunction renderStyleTag(tag: StyleTag): string {\r\n const media = tag.media ? ` media=\"${escapeAttr(tag.media)}\"` : '';\r\n return ` <style${media}>${tag.content ?? ''}</style>`;\r\n}\r\n\r\n/**\r\n * Renders all useHtml()-sourced head tags wrapped in <!--n-head--> sentinels.\r\n * Scripts with position='body' are excluded here \u2014 they go in renderManagedBodyScripts().\r\n * Returns an empty array when none of the stores have any tags (no sentinels\r\n * emitted for pages that don't call useHtml).\r\n */\r\nfunction renderManagedHeadTags(store: HtmlStore): string[] {\r\n const headScripts = store.script.filter(s => (s.position ?? 'head') === 'head');\r\n const tags = [\r\n ...store.meta.map(renderMetaTag),\r\n ...store.link.map(renderLinkTag),\r\n ...store.style.map(renderStyleTag),\r\n ...headScripts.map(renderScriptTag),\r\n ];\r\n if (tags.length === 0) return [];\r\n return [' <!--n-head-->', ...tags, ' <!--/n-head-->'];\r\n}\r\n\r\n/**\r\n * Renders all useHtml()-sourced scripts with position='body', wrapped in\r\n * <!--n-body-scripts-->\u2026<!--/n-body-scripts--> sentinels.\r\n * Injected just before </body> so scripts execute after page content is in the DOM.\r\n * Returns an empty array when there are no body-position scripts.\r\n */\r\nfunction renderManagedBodyScripts(store: HtmlStore): string[] {\r\n const bodyScripts = store.script.filter(s => s.position === 'body');\r\n if (bodyScripts.length === 0) return [];\r\n return [' <!--n-body-scripts-->', ...bodyScripts.map(renderScriptTag), ' <!--/n-body-scripts-->'];\r\n}\r\n\r\n// \u2500\u2500\u2500 Main SSR handler \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/**\r\n * Renders a page for the given URL and writes the full HTML response.\r\n *\r\n * @param url The raw request URL (may include query string).\r\n * @param res Node ServerResponse to write to.\r\n * @param pagesDir Absolute path to the app/pages directory.\r\n * @param isDev When true, injects the HMR client script into the page.\r\n */\r\nexport async function serverSideRender(\r\n url: string,\r\n res: ServerResponse,\r\n pagesDir: string,\r\n isDev = false,\r\n): Promise<void> {\r\n const skipClientSSR = url.includes('__hmr=1');\r\n const cleanUrl = url.split('?')[0];\r\n\r\n // \u2500\u2500 Route resolution \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n const routeMatch = matchRoute(cleanUrl, pagesDir);\r\n if (!routeMatch) {\r\n log.verbose(`No route found for: ${url}`);\r\n res.statusCode = 404;\r\n res.end('Page not found');\r\n return;\r\n }\r\n\r\n const { filePath, params, routePattern } = routeMatch;\r\n log.verbose(`SSR ${cleanUrl} -> ${path.relative(process.cwd(), filePath)}`);\r\n\r\n // Merge query string params into props so page components can access them\r\n // the same way in dev and production. Route params take precedence so a\r\n // ?slug=x in the query string cannot shadow a [slug] dynamic segment.\r\n const searchParams = new URL(url, 'http://localhost').searchParams;\r\n const queryParams: Record<string, string | string[]> = {};\r\n searchParams.forEach((_, k) => {\r\n if (!(k in params)) {\r\n const all = searchParams.getAll(k);\r\n queryParams[k] = all.length > 1 ? all : all[0];\r\n }\r\n });\r\n const mergedParams = { ...queryParams, ...params };\r\n\r\n // \u2500\u2500 Module import \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n // tsImport bypasses Node's ESM module cache entirely so edits are reflected\r\n // immediately on every request in dev.\r\n const layoutPaths = findLayoutsForRoute(filePath, pagesDir);\r\n const { default: PageComponent } = await tsImport(\r\n pathToFileURL(filePath).href,\r\n { parentURL: import.meta.url },\r\n );\r\n const wrappedElement = await wrapWithLayouts(\r\n createElement(PageComponent, mergedParams),\r\n layoutPaths,\r\n );\r\n\r\n // \u2500\u2500 Client component discovery \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n // Walk the import tree for both the page and its layout chain.\r\n const registry = new Map<string, string>();\r\n for (const [id, p] of findClientComponentsInTree(filePath, pagesDir))\r\n registry.set(id, p);\r\n for (const layoutPath of layoutPaths)\r\n for (const [id, p] of findClientComponentsInTree(layoutPath, pagesDir))\r\n registry.set(id, p);\r\n\r\n log.verbose(\r\n `Page ${routePattern}: found ${registry.size} client component(s)`,\r\n `[${[...registry.keys()].join(', ')}]`,\r\n );\r\n\r\n // \u2500\u2500 Rendering \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n const ctx: RenderContext = { registry, hydrated: new Set(), skipClientSSR };\r\n\r\n let appHtml = '';\r\n const store: HtmlStore = await runWithHtmlStore(async () => {\r\n appHtml = await renderElementToHtml(wrappedElement, ctx);\r\n });\r\n\r\n // \u2500\u2500 Head assembly \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n const pageTitle = resolveTitle(store.titleOps, 'NukeJS');\r\n\r\n const headLines: string[] = [\r\n ' <meta charset=\"utf-8\" />',\r\n ' <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />',\r\n ` <title>${escapeAttr(pageTitle)}</title>`,\r\n ...renderManagedHeadTags(store),\r\n ];\r\n\r\n // \u2500\u2500 Runtime data blob \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n // Escape </script> sequences so the JSON cannot break out of the script tag.\r\n const runtimeData = JSON.stringify({\r\n hydrateIds: [...ctx.hydrated],\r\n allIds: [...registry.keys()],\r\n url,\r\n params,\r\n debug: toClientDebugLevel(getDebugLevel()),\r\n })\r\n .replace(/</g, '\\\\u003c')\r\n .replace(/>/g, '\\\\u003e')\r\n .replace(/&/g, '\\\\u0026');\r\n\r\n // \u2500\u2500 Body scripts (position='body') \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n const bodyScriptLines = renderManagedBodyScripts(store);\r\n const bodyScriptsHtml = bodyScriptLines.length > 0\r\n ? '\\n' + bodyScriptLines.join('\\n') + '\\n'\r\n : '';\r\n\r\n // \u2500\u2500 Full document \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n const html = `<!DOCTYPE html>\r\n${openTag('html', store.htmlAttrs)}\r\n<head>\r\n${headLines.join('\\n')}\r\n</head>\r\n${openTag('body', store.bodyAttrs)}\r\n <div id=\"app\">${appHtml}</div>\r\n\r\n <script id=\"__n_data\" type=\"application/json\">${runtimeData}</script>\r\n\r\n <script type=\"importmap\">\r\n{\r\n \"imports\": {\r\n \"react\": \"/__react.js\",\r\n \"react-dom/client\": \"/__react.js\",\r\n \"react/jsx-runtime\": \"/__react.js\",\r\n \"nukejs\": \"/__n.js\"\r\n }\r\n}\r\n </script>\r\n\r\n <script type=\"module\">\r\n await import('react');\r\n const { initRuntime } = await import('nukejs');\r\n const data = JSON.parse(document.getElementById('__n_data').textContent);\r\n initRuntime(data);\r\n </script>\r\n\r\n ${isDev ? '<script type=\"module\" src=\"/__hmr.js\"></script>' : ''}\r\n${bodyScriptsHtml}</body>\r\n</html>`;\r\n\r\n res.setHeader('Content-Type', 'text/html');\r\n res.end(html);\r\n}"],
5
- "mappings": "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",
4
+ "sourcesContent": ["/**\r\n * ssr.ts \u2014 Server-Side Rendering Pipeline (Dev Mode)\r\n *\r\n * Handles the full page render cycle for `nuke dev`:\r\n *\r\n * 1. Match the incoming URL to a page file in app/pages using the file-system\r\n * router.\r\n * 2. Discover the layout chain (layout.tsx files from root to the page dir).\r\n * 3. Dynamically import the page and layout modules (always fresh via tsImport).\r\n * 4. Walk the import tree to discover all \"use client\" components.\r\n * 5. Render the wrapped element tree with the async renderer.\r\n * 6. Flush the html-store (title, meta, link, script, style tags).\r\n * 7. Assemble and send the full HTML document including:\r\n * - The rendered app HTML\r\n * - An importmap pointing react/nukejs to the bundled versions\r\n * - A __n_data JSON blob with hydration IDs and runtime config\r\n * - An inline bootstrap <script> that calls initRuntime()\r\n * - (dev only) A /__hmr.js script for Hot Module Replacement\r\n *\r\n * In production, a pre-built standalone handler (generated by build-common.ts)\r\n * handles each page without dynamic imports or file-system access.\r\n *\r\n * HMR fast path:\r\n * When the request URL contains `__hmr=1` (added by the HMR client during\r\n * soft navigation), the renderer skips client-component renderToString\r\n * (ctx.skipClientSSR = true). This speeds up HMR reloads because the\r\n * client already has the DOM in place and only needs fresh server markup.\r\n *\r\n * Head tag sentinels:\r\n * Every useHtml()-generated <meta>, <link>, <style>, and <script> tag is\r\n * wrapped in <!--n-head-->\u2026<!--/n-head--> comment sentinels. The client\r\n * runtime uses these to diff and sync head tags on SPA navigation without\r\n * touching permanent tags (charset, viewport, importmap, runtime script).\r\n * Pages with no useHtml head tags emit no sentinels.\r\n */\r\n\r\nimport path from 'path';\r\nimport { createElement } from 'react';\r\nimport { pathToFileURL } from 'url';\r\nimport { tsImport } from 'tsx/esm/api';\r\nimport type { ServerResponse } from 'http';\r\nimport { log, getDebugLevel, type DebugLevel } from './logger';\r\nimport { matchRoute, findLayoutsForRoute } from './router';\r\nimport { findClientComponentsInTree } from './component-analyzer';\r\nimport { renderElementToHtml, type RenderContext } from './renderer';\r\nimport { runWithRequestStore, normaliseHeaders, sanitiseHeaders } from './request-store';\r\nimport type { IncomingMessage } from 'http';\r\nimport {\r\n runWithHtmlStore,\r\n resolveTitle,\r\n type HtmlStore,\r\n type HtmlAttrs,\r\n type BodyAttrs,\r\n type MetaTag,\r\n type LinkTag,\r\n type ScriptTag,\r\n type StyleTag,\r\n} from './html-store';\r\n\r\n// \u2500\u2500\u2500 Layout wrapping \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/**\r\n * Wraps a page element in its layout chain, outermost-first.\r\n * Each layout receives `{ children: innerElement }` as props.\r\n *\r\n * tsImport creates a fresh isolated module namespace per call so edits to\r\n * layouts are reflected immediately without a server restart.\r\n */\r\nasync function wrapWithLayouts(pageElement: any, layoutPaths: string[]): Promise<any> {\r\n let element = pageElement;\r\n // Iterate in reverse so the outermost layout wraps last (becomes the root).\r\n for (let i = layoutPaths.length - 1; i >= 0; i--) {\r\n const { default: LayoutComponent } = await tsImport(\r\n pathToFileURL(layoutPaths[i]).href,\r\n { parentURL: import.meta.url },\r\n );\r\n element = createElement(LayoutComponent, { children: element });\r\n }\r\n return element;\r\n}\r\n\r\n// \u2500\u2500\u2500 Debug level conversion \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/**\r\n * Converts the server-side DebugLevel to the string format expected by the\r\n * browser's makeLogger() function in bundle.ts.\r\n */\r\nfunction toClientDebugLevel(level: DebugLevel): string {\r\n if (level === true) return 'verbose';\r\n if (level === 'info') return 'info';\r\n if (level === 'error') return 'error';\r\n return 'silent';\r\n}\r\n\r\n// \u2500\u2500\u2500 HTML serialization helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\nfunction escapeAttr(str: string): string {\r\n return str.replace(/&/g, '&amp;').replace(/\"/g, '&quot;');\r\n}\r\n\r\n/** Serializes an attribute map to `key=\"value\" \u2026`, omitting undefined/false values. */\r\nfunction renderAttrs(attrs: Record<string, string | boolean | undefined>): string {\r\n return Object.entries(attrs)\r\n .filter(([, v]) => v !== undefined && v !== false)\r\n .map(([k, v]) => v === true ? k : `${k}=\"${escapeAttr(String(v))}\"`)\r\n .join(' ');\r\n}\r\n\r\n/** Returns `<tag attrs>` or `<tag>` depending on whether attrs are non-empty. */\r\nfunction openTag(tag: string, attrs: Record<string, string | undefined>): string {\r\n const str = renderAttrs(attrs);\r\n return str ? `<${tag} ${str}>` : `<${tag}>`;\r\n}\r\n\r\n// \u2500\u2500\u2500 Head tag renderers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\nfunction metaKey(k: string): string {\r\n return k === 'httpEquiv' ? 'http-equiv' : k;\r\n}\r\n\r\nfunction linkKey(k: string): string {\r\n if (k === 'hrefLang') return 'hreflang';\r\n if (k === 'crossOrigin') return 'crossorigin';\r\n return k;\r\n}\r\n\r\nfunction renderMetaTag(tag: MetaTag): string {\r\n const attrs: Record<string, string | undefined> = {};\r\n for (const [k, v] of Object.entries(tag)) if (v !== undefined) attrs[metaKey(k)] = v;\r\n return ` <meta ${renderAttrs(attrs)} />`;\r\n}\r\n\r\nfunction renderLinkTag(tag: LinkTag): string {\r\n const attrs: Record<string, string | undefined> = {};\r\n for (const [k, v] of Object.entries(tag)) if (v !== undefined) attrs[linkKey(k)] = v;\r\n return ` <link ${renderAttrs(attrs)} />`;\r\n}\r\n\r\nfunction renderScriptTag(tag: ScriptTag): string {\r\n const attrs: Record<string, string | boolean | undefined> = {\r\n src: tag.src,\r\n type: tag.type,\r\n crossorigin: tag.crossOrigin,\r\n integrity: tag.integrity,\r\n defer: tag.defer,\r\n async: tag.async,\r\n nomodule: tag.noModule,\r\n };\r\n const attrStr = renderAttrs(attrs);\r\n const open = attrStr ? `<script ${attrStr}>` : '<script>';\r\n return ` ${open}${tag.src ? '' : (tag.content ?? '')}</script>`;\r\n}\r\n\r\nfunction renderStyleTag(tag: StyleTag): string {\r\n const media = tag.media ? ` media=\"${escapeAttr(tag.media)}\"` : '';\r\n return ` <style${media}>${tag.content ?? ''}</style>`;\r\n}\r\n\r\n/**\r\n * Renders all useHtml()-sourced head tags wrapped in <!--n-head--> sentinels.\r\n * Scripts with position='body' are excluded here \u2014 they go in renderManagedBodyScripts().\r\n * Returns an empty array when none of the stores have any tags (no sentinels\r\n * emitted for pages that don't call useHtml).\r\n */\r\nfunction renderManagedHeadTags(store: HtmlStore): string[] {\r\n const headScripts = store.script.filter(s => (s.position ?? 'head') === 'head');\r\n const tags = [\r\n ...store.meta.map(renderMetaTag),\r\n ...store.link.map(renderLinkTag),\r\n ...store.style.map(renderStyleTag),\r\n ...headScripts.map(renderScriptTag),\r\n ];\r\n if (tags.length === 0) return [];\r\n return [' <!--n-head-->', ...tags, ' <!--/n-head-->'];\r\n}\r\n\r\n/**\r\n * Renders all useHtml()-sourced scripts with position='body', wrapped in\r\n * <!--n-body-scripts-->\u2026<!--/n-body-scripts--> sentinels.\r\n * Injected just before </body> so scripts execute after page content is in the DOM.\r\n * Returns an empty array when there are no body-position scripts.\r\n */\r\nfunction renderManagedBodyScripts(store: HtmlStore): string[] {\r\n const bodyScripts = store.script.filter(s => s.position === 'body');\r\n if (bodyScripts.length === 0) return [];\r\n return [' <!--n-body-scripts-->', ...bodyScripts.map(renderScriptTag), ' <!--/n-body-scripts-->'];\r\n}\r\n\r\n// \u2500\u2500\u2500 Main SSR handler \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/**\r\n * Renders a page for the given URL and writes the full HTML response.\r\n *\r\n * @param url The raw request URL (may include query string).\r\n * @param res Node ServerResponse to write to.\r\n * @param pagesDir Absolute path to the app/pages directory.\r\n * @param isDev When true, injects the HMR client script into the page.\r\n */\r\nexport async function serverSideRender(\r\n url: string,\r\n res: ServerResponse,\r\n pagesDir: string,\r\n isDev = false,\r\n req?: IncomingMessage,\r\n): Promise<void> {\r\n const skipClientSSR = url.includes('__hmr=1');\r\n const cleanUrl = url.split('?')[0];\r\n\r\n // \u2500\u2500 Route resolution \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n const routeMatch = matchRoute(cleanUrl, pagesDir);\r\n if (!routeMatch) {\r\n log.verbose(`No route found for: ${url}`);\r\n res.statusCode = 404;\r\n res.end('Page not found');\r\n return;\r\n }\r\n\r\n const { filePath, params, routePattern } = routeMatch;\r\n log.verbose(`SSR ${cleanUrl} -> ${path.relative(process.cwd(), filePath)}`);\r\n\r\n // Merge query string params into props so page components can access them\r\n // the same way in dev and production. Route params take precedence so a\r\n // ?slug=x in the query string cannot shadow a [slug] dynamic segment.\r\n const searchParams = new URL(url, 'http://localhost').searchParams;\r\n const queryParams: Record<string, string | string[]> = {};\r\n searchParams.forEach((_, k) => {\r\n if (!(k in params)) {\r\n const all = searchParams.getAll(k);\r\n queryParams[k] = all.length > 1 ? all : all[0];\r\n }\r\n });\r\n const mergedParams = { ...queryParams, ...params };\r\n\r\n // normaliseHeaders keeps every header (including cookies) for server components.\r\n // sanitiseHeaders additionally strips credentials before embedding in HTML.\r\n const rawHeaders = req?.headers ?? {};\r\n const normHeaders = normaliseHeaders(rawHeaders as Record<string, string | string[] | undefined>);\r\n const safeHeaders = sanitiseHeaders(rawHeaders as Record<string, string | string[] | undefined>);\r\n\r\n // \u2500\u2500 Module import \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n // tsImport bypasses Node's ESM module cache entirely so edits are reflected\r\n // immediately on every request in dev.\r\n const layoutPaths = findLayoutsForRoute(filePath, pagesDir);\r\n const { default: PageComponent } = await tsImport(\r\n pathToFileURL(filePath).href,\r\n { parentURL: import.meta.url },\r\n );\r\n const wrappedElement = await wrapWithLayouts(\r\n createElement(PageComponent, mergedParams),\r\n layoutPaths,\r\n );\r\n\r\n // \u2500\u2500 Client component discovery \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n // Walk the import tree for both the page and its layout chain.\r\n const registry = new Map<string, string>();\r\n for (const [id, p] of findClientComponentsInTree(filePath, pagesDir))\r\n registry.set(id, p);\r\n for (const layoutPath of layoutPaths)\r\n for (const [id, p] of findClientComponentsInTree(layoutPath, pagesDir))\r\n registry.set(id, p);\r\n\r\n log.verbose(\r\n `Page ${routePattern}: found ${registry.size} client component(s)`,\r\n `[${[...registry.keys()].join(', ')}]`,\r\n );\r\n\r\n // \u2500\u2500 Rendering \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n const ctx: RenderContext = { registry, hydrated: new Set(), skipClientSSR };\r\n\r\n let appHtml = '';\r\n // runWithRequestStore makes { params, query, headers } available to any server\r\n // component that calls useRequest() during this render.\r\n const store: HtmlStore = await runWithRequestStore(\r\n {\r\n url: url,\r\n pathname: cleanUrl,\r\n params,\r\n query: queryParams,\r\n headers: normHeaders,\r\n },\r\n () => runWithHtmlStore(async () => {\r\n appHtml = await renderElementToHtml(wrappedElement, ctx);\r\n }),\r\n );\r\n\r\n // \u2500\u2500 Head assembly \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n const pageTitle = resolveTitle(store.titleOps, 'NukeJS');\r\n\r\n const headLines: string[] = [\r\n ' <meta charset=\"utf-8\" />',\r\n ' <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />',\r\n ` <title>${escapeAttr(pageTitle)}</title>`,\r\n ...renderManagedHeadTags(store),\r\n ];\r\n\r\n // \u2500\u2500 Runtime data blob \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n // Escape </script> sequences so the JSON cannot break out of the script tag.\r\n // `query` and `headers` (sanitised) are included so useRequest() can read\r\n // them from the client without an extra network round-trip.\r\n const runtimeData = JSON.stringify({\r\n hydrateIds: [...ctx.hydrated],\r\n allIds: [...registry.keys()],\r\n url,\r\n params,\r\n query: queryParams,\r\n headers: safeHeaders,\r\n debug: toClientDebugLevel(getDebugLevel()),\r\n })\r\n .replace(/</g, '\\\\u003c')\r\n .replace(/>/g, '\\\\u003e')\r\n .replace(/&/g, '\\\\u0026');\r\n\r\n // \u2500\u2500 Body scripts (position='body') \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n const bodyScriptLines = renderManagedBodyScripts(store);\r\n const bodyScriptsHtml = bodyScriptLines.length > 0\r\n ? '\\n' + bodyScriptLines.join('\\n') + '\\n'\r\n : '';\r\n\r\n // \u2500\u2500 Full document \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n const html = `<!DOCTYPE html>\r\n${openTag('html', store.htmlAttrs)}\r\n<head>\r\n${headLines.join('\\n')}\r\n</head>\r\n${openTag('body', store.bodyAttrs)}\r\n <div id=\"app\">${appHtml}</div>\r\n\r\n <script id=\"__n_data\" type=\"application/json\">${runtimeData}</script>\r\n\r\n <script type=\"importmap\">\r\n{\r\n \"imports\": {\r\n \"react\": \"/__react.js\",\r\n \"react-dom/client\": \"/__react.js\",\r\n \"react/jsx-runtime\": \"/__react.js\",\r\n \"nukejs\": \"/__n.js\"\r\n }\r\n}\r\n </script>\r\n\r\n <script type=\"module\">\r\n await import('react');\r\n const { initRuntime } = await import('nukejs');\r\n const data = JSON.parse(document.getElementById('__n_data').textContent);\r\n initRuntime(data);\r\n </script>\r\n\r\n ${isDev ? '<script type=\"module\" src=\"/__hmr.js\"></script>' : ''}\r\n${bodyScriptsHtml}</body>\r\n</html>`;\r\n\r\n res.setHeader('Content-Type', 'text/html');\r\n res.end(html);\r\n}"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * use-request.ts — useRequest() Hook
3
+ *
4
+ * Universal hook that exposes the current request's URL parameters, query
5
+ * string, and headers to any React component — server or client, dev or prod.
6
+ *
7
+ * ┌───────────────────────────────────────────────────────────────────────────┐
8
+ * │ Environment │ Data source │
9
+ * ├─────────────────┼─────────────────────────────────────────────────────────┤
10
+ * │ SSR (server) │ request-store, populated by ssr.ts before rendering │
11
+ * │ Client │ __n_data JSON blob + window.location (reactive) │
12
+ * └───────────────────────────────────────────────────────────────────────────┘
13
+ *
14
+ * The hook stays reactive on the client: it listens to 'locationchange' events
15
+ * fired by NukeJS's SPA router so values update on soft navigation without a
16
+ * full page reload.
17
+ *
18
+ * --- Usage ---
19
+ *
20
+ * Basic:
21
+ * ```tsx
22
+ * // Works in server components (SSR) and client components ("use client")
23
+ * const { params, query, headers, pathname } = useRequest();
24
+ * const slug = params.slug as string;
25
+ * const lang = query.lang as string;
26
+ * const locale = headers['accept-language'];
27
+ * ```
28
+ *
29
+ * Building useI18n on top:
30
+ * ```tsx
31
+ * // hooks/useI18n.ts
32
+ * import { useRequest } from 'nukejs';
33
+ *
34
+ * const translations = {
35
+ * en: { welcome: 'Welcome' },
36
+ * fr: { welcome: 'Bienvenue' },
37
+ * } as const;
38
+ * type Locale = keyof typeof translations;
39
+ *
40
+ * function parseLocale(header = ''): Locale {
41
+ * const tag = header.split(',')[0]?.split('-')[0]?.trim().toLowerCase();
42
+ * return (tag in translations ? tag : 'en') as Locale;
43
+ * }
44
+ *
45
+ * export function useI18n() {
46
+ * const { query, headers } = useRequest();
47
+ * // ?lang=fr wins over Accept-Language header
48
+ * const locale = ((query.lang as string) ?? parseLocale(headers['accept-language'])) as Locale;
49
+ * return { t: translations[locale] ?? translations.en, locale };
50
+ * }
51
+ *
52
+ * // Page.tsx
53
+ * const { t } = useI18n();
54
+ * return <h1>{t.welcome}</h1>;
55
+ * ```
56
+ *
57
+ * --- Notes ---
58
+ * - `headers` on the client never contains `cookie`, `authorization`, or
59
+ * `proxy-authorization` — these are stripped by the SSR pipeline before
60
+ * embedding in __n_data. See request-store.ts for the full exclusion list.
61
+ * - In a "use client" component, `params` always reflects the __n_data blob
62
+ * written at the time of the most recent SSR/navigation. For the freshest
63
+ * pathname use `useRouter().path` instead.
64
+ */
65
+ import type { RequestContext } from './request-store';
66
+ export type { RequestContext };
67
+ /**
68
+ * Returns the current request context: URL params, query string, and headers.
69
+ *
70
+ * Automatically detects SSR vs browser and returns the correct data for
71
+ * each environment. On the client it is reactive — values update on SPA
72
+ * navigation without a page reload.
73
+ */
74
+ export declare function useRequest(): RequestContext;
@@ -0,0 +1,49 @@
1
+ import { useState, useEffect } from "react";
2
+ import { getRequestStore } from "./request-store.js";
3
+ const EMPTY_CTX = {
4
+ url: "",
5
+ pathname: "",
6
+ params: {},
7
+ query: {},
8
+ headers: {}
9
+ };
10
+ function readClientContext() {
11
+ try {
12
+ const raw = document.getElementById("__n_data")?.textContent ?? "{}";
13
+ const data = JSON.parse(raw);
14
+ const search = window.location.search;
15
+ const query = {};
16
+ if (search) {
17
+ const sp = new URLSearchParams(search);
18
+ sp.forEach((_, k) => {
19
+ const all = sp.getAll(k);
20
+ query[k] = all.length > 1 ? all : all[0];
21
+ });
22
+ }
23
+ return {
24
+ url: window.location.pathname + window.location.search,
25
+ pathname: window.location.pathname,
26
+ params: data.params ?? {},
27
+ query,
28
+ headers: data.headers ?? {}
29
+ };
30
+ } catch {
31
+ return EMPTY_CTX;
32
+ }
33
+ }
34
+ function useRequest() {
35
+ if (typeof document === "undefined") {
36
+ return getRequestStore() ?? EMPTY_CTX;
37
+ }
38
+ const [ctx, setCtx] = useState(readClientContext);
39
+ useEffect(() => {
40
+ const handleLocationChange = () => setCtx(readClientContext());
41
+ window.addEventListener("locationchange", handleLocationChange);
42
+ return () => window.removeEventListener("locationchange", handleLocationChange);
43
+ }, []);
44
+ return ctx;
45
+ }
46
+ export {
47
+ useRequest
48
+ };
49
+ //# sourceMappingURL=use-request.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/use-request.ts"],
4
+ "sourcesContent": ["/**\r\n * use-request.ts \u2014 useRequest() Hook\r\n *\r\n * Universal hook that exposes the current request's URL parameters, query\r\n * string, and headers to any React component \u2014 server or client, dev or prod.\r\n *\r\n * \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\r\n * \u2502 Environment \u2502 Data source \u2502\r\n * \u251C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\r\n * \u2502 SSR (server) \u2502 request-store, populated by ssr.ts before rendering \u2502\r\n * \u2502 Client \u2502 __n_data JSON blob + window.location (reactive) \u2502\r\n * \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\r\n *\r\n * The hook stays reactive on the client: it listens to 'locationchange' events\r\n * fired by NukeJS's SPA router so values update on soft navigation without a\r\n * full page reload.\r\n *\r\n * --- Usage ---\r\n *\r\n * Basic:\r\n * ```tsx\r\n * // Works in server components (SSR) and client components (\"use client\")\r\n * const { params, query, headers, pathname } = useRequest();\r\n * const slug = params.slug as string;\r\n * const lang = query.lang as string;\r\n * const locale = headers['accept-language'];\r\n * ```\r\n *\r\n * Building useI18n on top:\r\n * ```tsx\r\n * // hooks/useI18n.ts\r\n * import { useRequest } from 'nukejs';\r\n *\r\n * const translations = {\r\n * en: { welcome: 'Welcome' },\r\n * fr: { welcome: 'Bienvenue' },\r\n * } as const;\r\n * type Locale = keyof typeof translations;\r\n *\r\n * function parseLocale(header = ''): Locale {\r\n * const tag = header.split(',')[0]?.split('-')[0]?.trim().toLowerCase();\r\n * return (tag in translations ? tag : 'en') as Locale;\r\n * }\r\n *\r\n * export function useI18n() {\r\n * const { query, headers } = useRequest();\r\n * // ?lang=fr wins over Accept-Language header\r\n * const locale = ((query.lang as string) ?? parseLocale(headers['accept-language'])) as Locale;\r\n * return { t: translations[locale] ?? translations.en, locale };\r\n * }\r\n *\r\n * // Page.tsx\r\n * const { t } = useI18n();\r\n * return <h1>{t.welcome}</h1>;\r\n * ```\r\n *\r\n * --- Notes ---\r\n * - `headers` on the client never contains `cookie`, `authorization`, or\r\n * `proxy-authorization` \u2014 these are stripped by the SSR pipeline before\r\n * embedding in __n_data. See request-store.ts for the full exclusion list.\r\n * - In a \"use client\" component, `params` always reflects the __n_data blob\r\n * written at the time of the most recent SSR/navigation. For the freshest\r\n * pathname use `useRouter().path` instead.\r\n */\r\n\r\nimport { useState, useEffect } from 'react';\r\nimport { getRequestStore } from './request-store';\r\nimport type { RequestContext } from './request-store';\r\n\r\nexport type { RequestContext };\r\n\r\n// \u2500\u2500\u2500 Helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/** Fallback context used when data is unavailable. */\r\nconst EMPTY_CTX: RequestContext = {\r\n url: '',\r\n pathname: '',\r\n params: {},\r\n query: {},\r\n headers: {},\r\n};\r\n\r\n/**\r\n * Reads the current request context from the `__n_data` script tag embedded\r\n * by the SSR renderer, merged with `window.location` for live accuracy.\r\n *\r\n * Called on initial render and on every 'locationchange' event so the hook\r\n * stays fresh across SPA navigation.\r\n */\r\nfunction readClientContext(): RequestContext {\r\n try {\r\n // __n_data is a JSON blob with { url, params, query, headers, \u2026 }.\r\n const raw = document.getElementById('__n_data')?.textContent ?? '{}';\r\n const data = JSON.parse(raw) as Partial<RequestContext & { params: Record<string, any> }>;\r\n\r\n // Always re-parse the query string from the live URL so navigation\r\n // to ?lang=fr is reflected immediately without waiting for a new SSR.\r\n const search = window.location.search;\r\n const query: Record<string, string | string[]> = {};\r\n if (search) {\r\n const sp = new URLSearchParams(search);\r\n sp.forEach((_, k) => {\r\n const all = sp.getAll(k);\r\n query[k] = all.length > 1 ? all : all[0];\r\n });\r\n }\r\n\r\n return {\r\n url: window.location.pathname + window.location.search,\r\n pathname: window.location.pathname,\r\n params: data.params ?? {},\r\n query,\r\n headers: data.headers ?? {},\r\n };\r\n } catch {\r\n return EMPTY_CTX;\r\n }\r\n}\r\n\r\n// \u2500\u2500\u2500 Universal hook \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\n/**\r\n * Returns the current request context: URL params, query string, and headers.\r\n *\r\n * Automatically detects SSR vs browser and returns the correct data for\r\n * each environment. On the client it is reactive \u2014 values update on SPA\r\n * navigation without a page reload.\r\n */\r\nexport function useRequest(): RequestContext {\r\n // \u2500\u2500 Server path (SSR) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n // typeof document === 'undefined' is the standard SSR guard in NukeJS\r\n // (mirrors the pattern used in use-html.ts and use-router.ts).\r\n if (typeof document === 'undefined') {\r\n return getRequestStore() ?? EMPTY_CTX;\r\n }\r\n\r\n // \u2500\u2500 Client path (browser) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n // eslint-disable-next-line react-hooks/rules-of-hooks\r\n const [ctx, setCtx] = useState<RequestContext>(readClientContext);\r\n\r\n // eslint-disable-next-line react-hooks/rules-of-hooks\r\n useEffect(() => {\r\n // 'locationchange' is fired by NukeJS's history patch (setupLocationChangeMonitor)\r\n // on every pushState / replaceState / popstate so this handler covers both\r\n // Link-driven navigation and programmatic useRouter().push() calls.\r\n const handleLocationChange = () => setCtx(readClientContext());\r\n\r\n window.addEventListener('locationchange', handleLocationChange);\r\n return () => window.removeEventListener('locationchange', handleLocationChange);\r\n }, []);\r\n\r\n return ctx;\r\n}"],
5
+ "mappings": "AAiEA,SAAS,UAAU,iBAAiB;AACpC,SAAS,uBAAuB;AAQhC,MAAM,YAA4B;AAAA,EAChC,KAAU;AAAA,EACV,UAAU;AAAA,EACV,QAAU,CAAC;AAAA,EACX,OAAU,CAAC;AAAA,EACX,SAAU,CAAC;AACb;AASA,SAAS,oBAAoC;AAC3C,MAAI;AAEF,UAAM,MAAQ,SAAS,eAAe,UAAU,GAAG,eAAe;AAClE,UAAM,OAAQ,KAAK,MAAM,GAAG;AAI5B,UAAM,SAAS,OAAO,SAAS;AAC/B,UAAM,QAA2C,CAAC;AAClD,QAAI,QAAQ;AACV,YAAM,KAAK,IAAI,gBAAgB,MAAM;AACrC,SAAG,QAAQ,CAAC,GAAG,MAAM;AACnB,cAAM,MAAM,GAAG,OAAO,CAAC;AACvB,cAAM,CAAC,IAAI,IAAI,SAAS,IAAI,MAAM,IAAI,CAAC;AAAA,MACzC,CAAC;AAAA,IACH;AAEA,WAAO;AAAA,MACL,KAAU,OAAO,SAAS,WAAW,OAAO,SAAS;AAAA,MACrD,UAAU,OAAO,SAAS;AAAA,MAC1B,QAAU,KAAK,UAAW,CAAC;AAAA,MAC3B;AAAA,MACA,SAAU,KAAK,WAAW,CAAC;AAAA,IAC7B;AAAA,EACF,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAWO,SAAS,aAA6B;AAI3C,MAAI,OAAO,aAAa,aAAa;AACnC,WAAO,gBAAgB,KAAK;AAAA,EAC9B;AAIA,QAAM,CAAC,KAAK,MAAM,IAAI,SAAyB,iBAAiB;AAGhE,YAAU,MAAM;AAId,UAAM,uBAAuB,MAAM,OAAO,kBAAkB,CAAC;AAE7D,WAAO,iBAAiB,kBAAkB,oBAAoB;AAC9D,WAAO,MAAM,OAAO,oBAAoB,kBAAkB,oBAAoB;AAAA,EAChF,GAAG,CAAC,CAAC;AAEL,SAAO;AACT;",
6
+ "names": []
7
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nukejs",
3
- "version": "0.0.10",
3
+ "version": "0.0.11",
4
4
  "description": "A minimal, opinionated full-stack React framework on Node.js that server-renders everything and hydrates only interactive parts.",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",