nukejs 0.0.1 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +529 -0
- package/bin/index.mjs +126 -0
- package/dist/app.d.ts +18 -0
- package/dist/app.js +124 -0
- package/dist/app.js.map +7 -0
- package/dist/as-is/Link.d.ts +6 -0
- package/dist/as-is/Link.tsx +20 -0
- package/dist/as-is/useRouter.d.ts +7 -0
- package/dist/as-is/useRouter.ts +33 -0
- package/dist/build-common.d.ts +192 -0
- package/dist/build-common.js +737 -0
- package/dist/build-common.js.map +7 -0
- package/dist/build-node.d.ts +1 -0
- package/dist/build-node.js +170 -0
- package/dist/build-node.js.map +7 -0
- package/dist/build-vercel.d.ts +1 -0
- package/dist/build-vercel.js +65 -0
- package/dist/build-vercel.js.map +7 -0
- package/dist/builder.d.ts +1 -0
- package/dist/builder.js +97 -0
- package/dist/builder.js.map +7 -0
- package/dist/bundle.d.ts +68 -0
- package/dist/bundle.js +166 -0
- package/dist/bundle.js.map +7 -0
- package/dist/bundler.d.ts +58 -0
- package/dist/bundler.js +98 -0
- package/dist/bundler.js.map +7 -0
- package/dist/component-analyzer.d.ts +72 -0
- package/dist/component-analyzer.js +102 -0
- package/dist/component-analyzer.js.map +7 -0
- package/dist/config.d.ts +35 -0
- package/dist/config.js +30 -0
- package/dist/config.js.map +7 -0
- package/dist/hmr-bundle.d.ts +25 -0
- package/dist/hmr-bundle.js +76 -0
- package/dist/hmr-bundle.js.map +7 -0
- package/dist/hmr.d.ts +55 -0
- package/dist/hmr.js +62 -0
- package/dist/hmr.js.map +7 -0
- package/dist/html-store.d.ts +121 -0
- package/dist/html-store.js +42 -0
- package/dist/html-store.js.map +7 -0
- package/dist/http-server.d.ts +99 -0
- package/dist/http-server.js +166 -0
- package/dist/http-server.js.map +7 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +7 -0
- package/dist/logger.d.ts +58 -0
- package/dist/logger.js +53 -0
- package/dist/logger.js.map +7 -0
- package/dist/metadata.d.ts +50 -0
- package/dist/metadata.js +43 -0
- package/dist/metadata.js.map +7 -0
- package/dist/middleware-loader.d.ts +50 -0
- package/dist/middleware-loader.js +50 -0
- package/dist/middleware-loader.js.map +7 -0
- package/dist/middleware.d.ts +22 -0
- package/dist/middleware.example.d.ts +8 -0
- package/dist/middleware.example.js +58 -0
- package/dist/middleware.example.js.map +7 -0
- package/dist/middleware.js +59 -0
- package/dist/middleware.js.map +7 -0
- package/dist/renderer.d.ts +44 -0
- package/dist/renderer.js +130 -0
- package/dist/renderer.js.map +7 -0
- package/dist/router.d.ts +84 -0
- package/dist/router.js +104 -0
- package/dist/router.js.map +7 -0
- package/dist/ssr.d.ts +39 -0
- package/dist/ssr.js +168 -0
- package/dist/ssr.js.map +7 -0
- package/dist/use-html.d.ts +64 -0
- package/dist/use-html.js +125 -0
- package/dist/use-html.js.map +7 -0
- package/dist/utils.d.ts +26 -0
- package/dist/utils.js +62 -0
- package/dist/utils.js.map +7 -0
- package/package.json +64 -12
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/component-analyzer.ts"],
|
|
4
|
+
"sourcesContent": ["/**\r\n * component-analyzer.ts \u2014 Static Import Analyzer & Client Component Registry\r\n *\r\n * This module solves a core problem in NukeJS's partial hydration model:\r\n * the server needs to know *at render time* which components in a page's\r\n * import tree are \"use client\" boundaries so it can:\r\n *\r\n * 1. Emit <span data-hydrate-id=\"\u2026\"> markers instead of rendering them.\r\n * 2. Inject the matching bundle URLs into the page's runtime data blob.\r\n * 3. Serialize the props passed to those components so the browser can\r\n * reconstruct them after loading the bundle.\r\n *\r\n * How it works:\r\n * - analyzeComponent() checks whether a file starts with \"use client\"\r\n * and assigns a stable content-hash ID if it does.\r\n * - extractImports() parses `import \u2026 from '\u2026'` statements with a\r\n * regex and resolves relative/absolute paths.\r\n * - findClientComponentsInTree() recursively walks the import graph, stopping\r\n * at client boundaries (they own their subtree).\r\n *\r\n * Results are memoised in `componentCache` (process-lifetime) so repeated SSR\r\n * renders don't re-read and re-hash files they've already seen.\r\n *\r\n * ID scheme:\r\n * The ID for a client component is `cc_` + the first 8 hex chars of the MD5\r\n * hash of its path relative to pagesDir. This is stable across restarts and\r\n * matches what the browser will request from /__client-component/<id>.js.\r\n */\r\n\r\nimport path from 'path';\r\nimport fs from 'fs';\r\nimport crypto from 'crypto';\r\nimport { fileURLToPath } from 'url';\r\n\r\n// \u2500\u2500\u2500 Types \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 ComponentInfo {\r\n filePath: string;\r\n /** True when the file's first non-comment line is \"use client\". */\r\n isClientComponent: boolean;\r\n /** Stable hash-based ID, present only for client components. */\r\n clientComponentId?: string;\r\n}\r\n\r\n// \u2500\u2500\u2500 In-process cache \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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// Memoises analyze results for the lifetime of the dev server process.\r\n// In production builds the analysis runs once per build, so no cache is needed.\r\nconst componentCache = new Map<string, ComponentInfo>();\r\n\r\n// \u2500\u2500\u2500 Client boundary detection \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 when a file begins with a `\"use client\"` or `'use client'`\r\n * directive (ignoring blank lines and line/block comment prefixes).\r\n *\r\n * Only the first five lines are checked \u2014 the directive must appear before\r\n * any executable code.\r\n */\r\nfunction isClientComponent(filePath: string): boolean {\r\n const content = fs.readFileSync(filePath, 'utf-8');\r\n for (const line of content.split('\\n').slice(0, 5)) {\r\n const trimmed = line.trim();\r\n if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('/*')) continue;\r\n if (/^[\"']use client[\"'];?$/.test(trimmed)) return true;\r\n break; // First substantive line is not \"use client\"\r\n }\r\n return false;\r\n}\r\n\r\n/**\r\n * Generates a deterministic, short ID for a client component.\r\n * The path is made relative to pagesDir before hashing so the ID is\r\n * portable across machines (absolute paths differ per developer).\r\n */\r\nfunction getClientComponentId(filePath: string, pagesDir: string): string {\r\n const hash = crypto\r\n .createHash('md5')\r\n .update(path.relative(pagesDir, filePath))\r\n .digest('hex')\r\n .substring(0, 8);\r\n return `cc_${hash}`;\r\n}\r\n\r\n// \u2500\u2500\u2500 Analysis \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 * Analyses a component file and returns cached results on subsequent calls.\r\n *\r\n * @param filePath Absolute path to the source file.\r\n * @param pagesDir Absolute path to the pages root (used for ID generation).\r\n */\r\nexport function analyzeComponent(filePath: string, pagesDir: string): ComponentInfo {\r\n if (componentCache.has(filePath)) return componentCache.get(filePath)!;\r\n\r\n const isClient = isClientComponent(filePath);\r\n const info: ComponentInfo = {\r\n filePath,\r\n isClientComponent: isClient,\r\n clientComponentId: isClient ? getClientComponentId(filePath, pagesDir) : undefined,\r\n };\r\n\r\n componentCache.set(filePath, info);\r\n return info;\r\n}\r\n\r\n// \u2500\u2500\u2500 Import extraction \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 * Parses `import \u2026 from '\u2026'` statements in a file and returns a list of\r\n * resolved absolute paths for all *local* imports (relative or absolute paths).\r\n *\r\n * Non-local specifiers (npm packages) are skipped, except `nukejs` itself \u2014\r\n * which is resolved to our own index file so built-in \"use client\" components\r\n * like `<Link>` are included in the client component discovery walk.\r\n *\r\n * Extensions are tried in priority order if the specifier has none.\r\n */\r\nfunction extractImports(filePath: string): string[] {\r\n const content = fs.readFileSync(filePath, 'utf-8');\r\n const dir = path.dirname(filePath);\r\n const imports: string[] = [];\r\n\r\n const importRegex =\r\n /import\\s+(?:(?:\\{[^}]*\\}|\\*\\s+as\\s+\\w+|\\w+)\\s+from\\s+)?['\"]([^'\"]+)['\"]/g;\r\n let match: RegExpExecArray | null;\r\n\r\n while ((match = importRegex.exec(content)) !== null) {\r\n const importPath = match[1];\r\n\r\n // Special case: resolve the 'nukejs' package import to our own source so\r\n // built-in \"use client\" exports (Link, useRouter, etc.) are discovered.\r\n if (importPath === 'nukejs') {\r\n const selfDir = path.dirname(fileURLToPath(import.meta.url));\r\n for (const candidate of [\r\n path.join(selfDir, 'index.ts'),\r\n path.join(selfDir, 'index.js'),\r\n ]) {\r\n if (fs.existsSync(candidate)) { imports.push(candidate); break; }\r\n }\r\n continue;\r\n }\r\n\r\n // Skip npm packages and other non-local specifiers.\r\n if (!importPath.startsWith('.') && !importPath.startsWith('/')) continue;\r\n\r\n // Resolve to an absolute path and add common extensions if needed.\r\n let resolved = path.resolve(dir, importPath);\r\n if (!fs.existsSync(resolved)) {\r\n for (const ext of ['.tsx', '.ts', '.jsx', '.js']) {\r\n const candidate = resolved + ext;\r\n if (fs.existsSync(candidate)) { resolved = candidate; break; }\r\n }\r\n }\r\n if (fs.existsSync(resolved)) imports.push(resolved);\r\n }\r\n\r\n return imports;\r\n}\r\n\r\n// \u2500\u2500\u2500 Tree walk \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 walks the import graph from `filePath`, collecting every\r\n * \"use client\" file encountered.\r\n *\r\n * The walk stops at client boundaries: a \"use client\" file is collected and\r\n * its own imports are NOT walked (the client runtime handles their subtree).\r\n *\r\n * The `visited` set prevents infinite loops from circular imports.\r\n *\r\n * @returns Map<id, absoluteFilePath> for every client component reachable\r\n * from `filePath` (including `filePath` itself if it's a client).\r\n */\r\nexport function findClientComponentsInTree(\r\n filePath: string,\r\n pagesDir: string,\r\n visited = new Set<string>(),\r\n): Map<string, string> {\r\n const clientComponents = new Map<string, string>();\r\n if (visited.has(filePath)) return clientComponents;\r\n visited.add(filePath);\r\n\r\n const info = analyzeComponent(filePath, pagesDir);\r\n\r\n // This file is a client boundary \u2014 record it and stop descending.\r\n if (info.isClientComponent && info.clientComponentId) {\r\n clientComponents.set(info.clientComponentId, filePath);\r\n return clientComponents;\r\n }\r\n\r\n // Server component \u2014 recurse into its imports.\r\n for (const importPath of extractImports(filePath)) {\r\n for (const [id, p] of findClientComponentsInTree(importPath, pagesDir, visited)) {\r\n clientComponents.set(id, p);\r\n }\r\n }\r\n\r\n return clientComponents;\r\n}\r\n\r\n// \u2500\u2500\u2500 Cache access \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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/**\r\n * Looks up the absolute file path for a client component by its ID.\r\n * O(1) reverse lookup \u2014 avoids the O(n) linear scan in bundler.ts.\r\n *\r\n * Returns undefined when the ID is not in the cache (page not yet visited\r\n * in dev, or stale ID after a file change).\r\n */\r\nexport function getComponentById(id: string): string | undefined {\r\n for (const [filePath, info] of componentCache) {\r\n if (info.clientComponentId === id) return filePath;\r\n }\r\n return undefined;\r\n}\r\n\r\n/** Returns the live component cache (used by bundler.ts for ID\u2192path lookup). */\r\nexport function getComponentCache(): Map<string, ComponentInfo> {\r\n return componentCache;\r\n}\r\n\r\n/**\r\n * Removes a single file's analysis entry from the cache.\r\n * Call this whenever a source file changes in dev mode so the next render\r\n * re-analyses the file (picks up added/removed \"use client\" directives and\r\n * changed import graphs).\r\n */\r\nexport function invalidateComponentCache(filePath: string): void {\r\n componentCache.delete(filePath);\r\n}"],
|
|
5
|
+
"mappings": "AA6BA,OAAO,UAAU;AACjB,OAAO,QAAQ;AACf,OAAO,YAAY;AACnB,SAAS,qBAAqB;AAgB9B,MAAM,iBAAiB,oBAAI,IAA2B;AAWtD,SAAS,kBAAkB,UAA2B;AACpD,QAAM,UAAU,GAAG,aAAa,UAAU,OAAO;AACjD,aAAW,QAAQ,QAAQ,MAAM,IAAI,EAAE,MAAM,GAAG,CAAC,GAAG;AAClD,UAAM,UAAU,KAAK,KAAK;AAC1B,QAAI,CAAC,WAAW,QAAQ,WAAW,IAAI,KAAK,QAAQ,WAAW,IAAI,EAAG;AACtE,QAAI,yBAAyB,KAAK,OAAO,EAAG,QAAO;AACnD;AAAA,EACF;AACA,SAAO;AACT;AAOA,SAAS,qBAAqB,UAAkB,UAA0B;AACxE,QAAM,OAAO,OACV,WAAW,KAAK,EAChB,OAAO,KAAK,SAAS,UAAU,QAAQ,CAAC,EACxC,OAAO,KAAK,EACZ,UAAU,GAAG,CAAC;AACjB,SAAO,MAAM,IAAI;AACnB;AAUO,SAAS,iBAAiB,UAAkB,UAAiC;AAClF,MAAI,eAAe,IAAI,QAAQ,EAAG,QAAO,eAAe,IAAI,QAAQ;AAEpE,QAAM,WAAW,kBAAkB,QAAQ;AAC3C,QAAM,OAAsB;AAAA,IAC1B;AAAA,IACA,mBAAoB;AAAA,IACpB,mBAAmB,WAAW,qBAAqB,UAAU,QAAQ,IAAI;AAAA,EAC3E;AAEA,iBAAe,IAAI,UAAU,IAAI;AACjC,SAAO;AACT;AAcA,SAAS,eAAe,UAA4B;AAClD,QAAM,UAAU,GAAG,aAAa,UAAU,OAAO;AACjD,QAAM,MAAU,KAAK,QAAQ,QAAQ;AACrC,QAAM,UAAoB,CAAC;AAE3B,QAAM,cACJ;AACF,MAAI;AAEJ,UAAQ,QAAQ,YAAY,KAAK,OAAO,OAAO,MAAM;AACnD,UAAM,aAAa,MAAM,CAAC;AAI1B,QAAI,eAAe,UAAU;AAC3B,YAAM,UAAU,KAAK,QAAQ,cAAc,YAAY,GAAG,CAAC;AAC3D,iBAAW,aAAa;AAAA,QACtB,KAAK,KAAK,SAAS,UAAU;AAAA,QAC7B,KAAK,KAAK,SAAS,UAAU;AAAA,MAC/B,GAAG;AACD,YAAI,GAAG,WAAW,SAAS,GAAG;AAAE,kBAAQ,KAAK,SAAS;AAAG;AAAA,QAAO;AAAA,MAClE;AACA;AAAA,IACF;AAGA,QAAI,CAAC,WAAW,WAAW,GAAG,KAAK,CAAC,WAAW,WAAW,GAAG,EAAG;AAGhE,QAAI,WAAW,KAAK,QAAQ,KAAK,UAAU;AAC3C,QAAI,CAAC,GAAG,WAAW,QAAQ,GAAG;AAC5B,iBAAW,OAAO,CAAC,QAAQ,OAAO,QAAQ,KAAK,GAAG;AAChD,cAAM,YAAY,WAAW;AAC7B,YAAI,GAAG,WAAW,SAAS,GAAG;AAAE,qBAAW;AAAW;AAAA,QAAO;AAAA,MAC/D;AAAA,IACF;AACA,QAAI,GAAG,WAAW,QAAQ,EAAG,SAAQ,KAAK,QAAQ;AAAA,EACpD;AAEA,SAAO;AACT;AAgBO,SAAS,2BACd,UACA,UACA,UAAW,oBAAI,IAAY,GACN;AACrB,QAAM,mBAAmB,oBAAI,IAAoB;AACjD,MAAI,QAAQ,IAAI,QAAQ,EAAG,QAAO;AAClC,UAAQ,IAAI,QAAQ;AAEpB,QAAM,OAAO,iBAAiB,UAAU,QAAQ;AAGhD,MAAI,KAAK,qBAAqB,KAAK,mBAAmB;AACpD,qBAAiB,IAAI,KAAK,mBAAmB,QAAQ;AACrD,WAAO;AAAA,EACT;AAGA,aAAW,cAAc,eAAe,QAAQ,GAAG;AACjD,eAAW,CAAC,IAAI,CAAC,KAAK,2BAA2B,YAAY,UAAU,OAAO,GAAG;AAC/E,uBAAiB,IAAI,IAAI,CAAC;AAAA,IAC5B;AAAA,EACF;AAEA,SAAO;AACT;AAYO,SAAS,iBAAiB,IAAgC;AAC/D,aAAW,CAAC,UAAU,IAAI,KAAK,gBAAgB;AAC7C,QAAI,KAAK,sBAAsB,GAAI,QAAO;AAAA,EAC5C;AACA,SAAO;AACT;AAGO,SAAS,oBAAgD;AAC9D,SAAO;AACT;AAQO,SAAS,yBAAyB,UAAwB;AAC/D,iBAAe,OAAO,QAAQ;AAChC;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* config.ts — NukeJS Configuration Loader
|
|
3
|
+
*
|
|
4
|
+
* Loads `nuke.config.ts` from the project root at startup.
|
|
5
|
+
* If no config file exists, sensible defaults are returned so most projects
|
|
6
|
+
* work with zero configuration.
|
|
7
|
+
*
|
|
8
|
+
* Config file example (nuke.config.ts):
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* export default {
|
|
12
|
+
* serverDir: './server', // where API route files live
|
|
13
|
+
* port: 3000, // HTTP port for the dev server
|
|
14
|
+
* debug: 'info', // false | 'error' | 'info' | true (verbose)
|
|
15
|
+
* };
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
import type { DebugLevel } from './logger';
|
|
19
|
+
export interface Config {
|
|
20
|
+
/** Path to the API server directory, relative to project root. */
|
|
21
|
+
serverDir: string;
|
|
22
|
+
/** TCP port for the dev server. Increments automatically if in use. */
|
|
23
|
+
port: number;
|
|
24
|
+
/** Logging verbosity. false = silent, true = verbose. */
|
|
25
|
+
debug?: DebugLevel;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Dynamically imports `nuke.config.ts` from process.cwd() and merges it with
|
|
29
|
+
* the defaults. Falls back to defaults silently when no config file exists.
|
|
30
|
+
*
|
|
31
|
+
* Throws if the config file exists but cannot be imported (syntax error, etc.)
|
|
32
|
+
* so the developer sees the problem immediately rather than running on stale
|
|
33
|
+
* defaults.
|
|
34
|
+
*/
|
|
35
|
+
export declare function loadConfig(): Promise<Config>;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import { pathToFileURL } from "url";
|
|
4
|
+
import { log } from "./logger.js";
|
|
5
|
+
async function loadConfig() {
|
|
6
|
+
const configPath = path.join(process.cwd(), "nuke.config.ts");
|
|
7
|
+
if (!fs.existsSync(configPath)) {
|
|
8
|
+
return {
|
|
9
|
+
serverDir: "./server",
|
|
10
|
+
port: 3e3,
|
|
11
|
+
debug: false
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
try {
|
|
15
|
+
const mod = await import(pathToFileURL(configPath).href);
|
|
16
|
+
const config = mod.default;
|
|
17
|
+
return {
|
|
18
|
+
serverDir: config.serverDir || "./server",
|
|
19
|
+
port: config.port || 3e3,
|
|
20
|
+
debug: config.debug ?? false
|
|
21
|
+
};
|
|
22
|
+
} catch (error) {
|
|
23
|
+
log.error("Error loading config:", error);
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export {
|
|
28
|
+
loadConfig
|
|
29
|
+
};
|
|
30
|
+
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/config.ts"],
|
|
4
|
+
"sourcesContent": ["/**\r\n * config.ts \u2014 NukeJS Configuration Loader\r\n *\r\n * Loads `nuke.config.ts` from the project root at startup.\r\n * If no config file exists, sensible defaults are returned so most projects\r\n * work with zero configuration.\r\n *\r\n * Config file example (nuke.config.ts):\r\n *\r\n * ```ts\r\n * export default {\r\n * serverDir: './server', // where API route files live\r\n * port: 3000, // HTTP port for the dev server\r\n * debug: 'info', // false | 'error' | 'info' | true (verbose)\r\n * };\r\n * ```\r\n */\r\n\r\nimport path from 'path';\r\nimport fs from 'fs';\r\nimport { pathToFileURL } from 'url';\r\nimport { log } from './logger';\r\nimport type { DebugLevel } from './logger';\r\n\r\n// \u2500\u2500\u2500 Public types \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 Config {\r\n /** Path to the API server directory, relative to project root. */\r\n serverDir: string;\r\n /** TCP port for the dev server. Increments automatically if in use. */\r\n port: number;\r\n /** Logging verbosity. false = silent, true = verbose. */\r\n debug?: DebugLevel;\r\n}\r\n\r\n// \u2500\u2500\u2500 Loader \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 * Dynamically imports `nuke.config.ts` from process.cwd() and merges it with\r\n * the defaults. Falls back to defaults silently when no config file exists.\r\n *\r\n * Throws if the config file exists but cannot be imported (syntax error, etc.)\r\n * so the developer sees the problem immediately rather than running on stale\r\n * defaults.\r\n */\r\nexport async function loadConfig(): Promise<Config> {\r\n const configPath = path.join(process.cwd(), 'nuke.config.ts');\r\n\r\n if (!fs.existsSync(configPath)) {\r\n // No config file \u2014 use defaults. This is expected for new projects.\r\n return {\r\n serverDir: './server',\r\n port: 3000,\r\n debug: false,\r\n };\r\n }\r\n\r\n try {\r\n // pathToFileURL ensures the import works correctly on Windows (absolute\r\n // paths with drive letters are not valid ESM specifiers without file://).\r\n const mod = await import(pathToFileURL(configPath).href);\r\n const config = mod.default;\r\n\r\n return {\r\n serverDir: config.serverDir || './server',\r\n port: config.port || 3000,\r\n debug: config.debug ?? false,\r\n };\r\n } catch (error) {\r\n log.error('Error loading config:', error);\r\n throw error;\r\n }\r\n}\r\n"],
|
|
5
|
+
"mappings": "AAkBA,OAAO,UAAU;AACjB,OAAO,QAAQ;AACf,SAAS,qBAAqB;AAC9B,SAAS,WAAW;AAwBpB,eAAsB,aAA8B;AAClD,QAAM,aAAa,KAAK,KAAK,QAAQ,IAAI,GAAG,gBAAgB;AAE5D,MAAI,CAAC,GAAG,WAAW,UAAU,GAAG;AAE9B,WAAO;AAAA,MACL,WAAW;AAAA,MACX,MAAW;AAAA,MACX,OAAW;AAAA,IACb;AAAA,EACF;AAEA,MAAI;AAGF,UAAM,MAAS,MAAM,OAAO,cAAc,UAAU,EAAE;AACtD,UAAM,SAAS,IAAI;AAEnB,WAAO;AAAA,MACL,WAAW,OAAO,aAAa;AAAA,MAC/B,MAAW,OAAO,QAAa;AAAA,MAC/B,OAAW,OAAO,SAAS;AAAA,IAC7B;AAAA,EACF,SAAS,OAAO;AACd,QAAI,MAAM,yBAAyB,KAAK;AACxC,UAAM;AAAA,EACR;AACF;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hmr-bundle.ts — HMR Client Script
|
|
3
|
+
*
|
|
4
|
+
* This file is compiled on-demand by middleware.ts and served to the browser
|
|
5
|
+
* as /__hmr.js (injected into every dev-mode page as a module script).
|
|
6
|
+
*
|
|
7
|
+
* It opens an EventSource connection to /__hmr and reacts to three message
|
|
8
|
+
* types from the server:
|
|
9
|
+
*
|
|
10
|
+
* 'reload' — A page or stylesheet changed.
|
|
11
|
+
* url === '*' → reload stylesheets in-place (no flicker)
|
|
12
|
+
* url === window.location.pathname → soft-navigate the current page
|
|
13
|
+
*
|
|
14
|
+
* 'replace' — A component/utility changed.
|
|
15
|
+
* Re-navigate the current page so SSR picks up the new code.
|
|
16
|
+
*
|
|
17
|
+
* 'restart' — The server is restarting (config or middleware changed).
|
|
18
|
+
* Close the SSE connection and poll /__hmr_ping until the
|
|
19
|
+
* server is back, then hard-reload the page.
|
|
20
|
+
*
|
|
21
|
+
* The same reconnect polling is used when the SSE connection drops unexpectedly
|
|
22
|
+
* (e.g. the dev server crashed).
|
|
23
|
+
*/
|
|
24
|
+
/** Opens the SSE connection and starts listening for HMR events. */
|
|
25
|
+
export default function hmr(): void;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { log } from "./logger.js";
|
|
2
|
+
function hmr() {
|
|
3
|
+
const es = new EventSource("/__hmr");
|
|
4
|
+
es.onopen = () => {
|
|
5
|
+
log.info("[HMR] Connected");
|
|
6
|
+
};
|
|
7
|
+
es.onerror = () => {
|
|
8
|
+
es.close();
|
|
9
|
+
waitForReconnect();
|
|
10
|
+
};
|
|
11
|
+
es.onmessage = async (event) => {
|
|
12
|
+
try {
|
|
13
|
+
const msg = JSON.parse(event.data);
|
|
14
|
+
if (msg.type === "restart") {
|
|
15
|
+
log.info("[HMR] Server restarting \u2014 waiting to reconnect...");
|
|
16
|
+
es.close();
|
|
17
|
+
waitForReconnect();
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
if (msg.type === "reload") {
|
|
21
|
+
if (msg.url === "*") {
|
|
22
|
+
reloadStylesheets();
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (msg.url === window.location.pathname) {
|
|
26
|
+
log.info("[HMR] Page changed:", msg.url);
|
|
27
|
+
navigate(window.location.pathname);
|
|
28
|
+
}
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (msg.type === "replace") {
|
|
32
|
+
log.info("[HMR] Component changed:", msg.component);
|
|
33
|
+
navigate(window.location.pathname);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
} catch (err) {
|
|
37
|
+
log.error("[HMR] Message parse error:", err);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function navigate(href) {
|
|
42
|
+
window.dispatchEvent(new CustomEvent("locationchange", { detail: { href, hmr: true } }));
|
|
43
|
+
}
|
|
44
|
+
function waitForReconnect(intervalMs = 500, maxAttempts = 30) {
|
|
45
|
+
let attempts = 0;
|
|
46
|
+
const id = setInterval(async () => {
|
|
47
|
+
attempts++;
|
|
48
|
+
try {
|
|
49
|
+
const res = await fetch("/__hmr_ping", { cache: "no-store" });
|
|
50
|
+
if (res.ok) {
|
|
51
|
+
clearInterval(id);
|
|
52
|
+
log.info("[HMR] Server back \u2014 reloading");
|
|
53
|
+
window.location.reload();
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
}
|
|
57
|
+
if (attempts >= maxAttempts) {
|
|
58
|
+
clearInterval(id);
|
|
59
|
+
log.error("[HMR] Server did not come back after restart");
|
|
60
|
+
}
|
|
61
|
+
}, intervalMs);
|
|
62
|
+
}
|
|
63
|
+
function reloadStylesheets() {
|
|
64
|
+
const links = document.querySelectorAll('link[rel="stylesheet"]');
|
|
65
|
+
log.info(`[HMR] CSS changed \u2014 reloading ${links.length} stylesheet(s)`);
|
|
66
|
+
links.forEach((link) => {
|
|
67
|
+
const url = new URL(link.href);
|
|
68
|
+
url.searchParams.set("t", String(Date.now()));
|
|
69
|
+
link.href = url.toString();
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
hmr();
|
|
73
|
+
export {
|
|
74
|
+
hmr as default
|
|
75
|
+
};
|
|
76
|
+
//# sourceMappingURL=hmr-bundle.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/hmr-bundle.ts"],
|
|
4
|
+
"sourcesContent": ["/**\r\n * hmr-bundle.ts \u2014 HMR Client Script\r\n *\r\n * This file is compiled on-demand by middleware.ts and served to the browser\r\n * as /__hmr.js (injected into every dev-mode page as a module script).\r\n *\r\n * It opens an EventSource connection to /__hmr and reacts to three message\r\n * types from the server:\r\n *\r\n * 'reload' \u2014 A page or stylesheet changed.\r\n * url === '*' \u2192 reload stylesheets in-place (no flicker)\r\n * url === window.location.pathname \u2192 soft-navigate the current page\r\n *\r\n * 'replace' \u2014 A component/utility changed.\r\n * Re-navigate the current page so SSR picks up the new code.\r\n *\r\n * 'restart' \u2014 The server is restarting (config or middleware changed).\r\n * Close the SSE connection and poll /__hmr_ping until the\r\n * server is back, then hard-reload the page.\r\n *\r\n * The same reconnect polling is used when the SSE connection drops unexpectedly\r\n * (e.g. the dev server crashed).\r\n */\r\n\r\nimport { log } from './logger';\r\n\r\n// \u2500\u2500\u2500 Entry point \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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/** Opens the SSE connection and starts listening for HMR events. */\r\nexport default function hmr(): void {\r\n const es = new EventSource('/__hmr');\r\n\r\n es.onopen = () => {\r\n log.info('[HMR] Connected');\r\n };\r\n\r\n es.onerror = () => {\r\n // Connection dropped without a restart message (e.g. crash or network\r\n // blip). Close cleanly and poll until the server is back.\r\n es.close();\r\n waitForReconnect();\r\n };\r\n\r\n es.onmessage = async (event) => {\r\n try {\r\n const msg = JSON.parse(event.data);\r\n\r\n if (msg.type === 'restart') {\r\n log.info('[HMR] Server restarting \u2014 waiting to reconnect...');\r\n es.close();\r\n waitForReconnect();\r\n return;\r\n }\r\n\r\n if (msg.type === 'reload') {\r\n if (msg.url === '*') {\r\n // CSS / global style change \u2014 bust stylesheet hrefs in-place.\r\n // This avoids a full page reload and its associated FOUC.\r\n reloadStylesheets();\r\n return;\r\n }\r\n // A specific page changed \u2014 only navigate if we're on that page.\r\n if (msg.url === window.location.pathname) {\r\n log.info('[HMR] Page changed:', msg.url);\r\n navigate(window.location.pathname);\r\n }\r\n return;\r\n }\r\n\r\n if (msg.type === 'replace') {\r\n // A shared component or utility changed. The current page might use\r\n // it, so we re-navigate to pick up the latest server render.\r\n log.info('[HMR] Component changed:', msg.component);\r\n navigate(window.location.pathname);\r\n return;\r\n }\r\n } catch (err) {\r\n log.error('[HMR] Message parse error:', err);\r\n }\r\n };\r\n}\r\n\r\n// \u2500\u2500\u2500 Soft navigation helper \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 * Triggers a soft (SPA-style) navigation via the locationchange event that\r\n * bundle.ts listens to. Adds `hmr: true` in the detail so the navigation\r\n * handler appends `?__hmr=1`, which tells SSR to skip client-component\r\n * renderToString (faster HMR round-trips).\r\n */\r\nfunction navigate(href: string): void {\r\n window.dispatchEvent(new CustomEvent('locationchange', { detail: { href, hmr: true } }));\r\n}\r\n\r\n// \u2500\u2500\u2500 Reconnect polling \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 * Polls /__hmr_ping at `intervalMs` until the server responds with a 200\r\n * (meaning it's back up), then triggers a full page reload to pick up any\r\n * changes that happened during the downtime.\r\n *\r\n * Gives up after `maxAttempts` (default ~15 seconds at 500 ms intervals).\r\n */\r\nfunction waitForReconnect(intervalMs = 500, maxAttempts = 30): void {\r\n let attempts = 0;\r\n\r\n const id = setInterval(async () => {\r\n attempts++;\r\n try {\r\n const res = await fetch('/__hmr_ping', { cache: 'no-store' });\r\n if (res.ok) {\r\n clearInterval(id);\r\n log.info('[HMR] Server back \u2014 reloading');\r\n window.location.reload();\r\n }\r\n } catch {\r\n // Server still down \u2014 keep polling silently.\r\n }\r\n\r\n if (attempts >= maxAttempts) {\r\n clearInterval(id);\r\n log.error('[HMR] Server did not come back after restart');\r\n }\r\n }, intervalMs);\r\n}\r\n\r\n// \u2500\u2500\u2500 Stylesheet cache-buster \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 * Appends a `?t=<timestamp>` query to every `<link rel=\"stylesheet\">` href.\r\n * The browser treats the new URL as a different resource and re-fetches it,\r\n * updating styles without a page reload or visible flash.\r\n */\r\nfunction reloadStylesheets(): void {\r\n const links = document.querySelectorAll<HTMLLinkElement>('link[rel=\"stylesheet\"]');\r\n log.info(`[HMR] CSS changed \u2014 reloading ${links.length} stylesheet(s)`);\r\n links.forEach(link => {\r\n const url = new URL(link.href);\r\n url.searchParams.set('t', String(Date.now()));\r\n link.href = url.toString();\r\n });\r\n}\r\n\r\n// Auto-start when this module is loaded.\r\nhmr();\r\n"],
|
|
5
|
+
"mappings": "AAwBA,SAAS,WAAW;AAKL,SAAR,MAA6B;AAClC,QAAM,KAAK,IAAI,YAAY,QAAQ;AAEnC,KAAG,SAAS,MAAM;AAChB,QAAI,KAAK,iBAAiB;AAAA,EAC5B;AAEA,KAAG,UAAU,MAAM;AAGjB,OAAG,MAAM;AACT,qBAAiB;AAAA,EACnB;AAEA,KAAG,YAAY,OAAO,UAAU;AAC9B,QAAI;AACF,YAAM,MAAM,KAAK,MAAM,MAAM,IAAI;AAEjC,UAAI,IAAI,SAAS,WAAW;AAC1B,YAAI,KAAK,wDAAmD;AAC5D,WAAG,MAAM;AACT,yBAAiB;AACjB;AAAA,MACF;AAEA,UAAI,IAAI,SAAS,UAAU;AACzB,YAAI,IAAI,QAAQ,KAAK;AAGnB,4BAAkB;AAClB;AAAA,QACF;AAEA,YAAI,IAAI,QAAQ,OAAO,SAAS,UAAU;AACxC,cAAI,KAAK,uBAAuB,IAAI,GAAG;AACvC,mBAAS,OAAO,SAAS,QAAQ;AAAA,QACnC;AACA;AAAA,MACF;AAEA,UAAI,IAAI,SAAS,WAAW;AAG1B,YAAI,KAAK,4BAA4B,IAAI,SAAS;AAClD,iBAAS,OAAO,SAAS,QAAQ;AACjC;AAAA,MACF;AAAA,IACF,SAAS,KAAK;AACZ,UAAI,MAAM,8BAA8B,GAAG;AAAA,IAC7C;AAAA,EACF;AACF;AAUA,SAAS,SAAS,MAAoB;AACpC,SAAO,cAAc,IAAI,YAAY,kBAAkB,EAAE,QAAQ,EAAE,MAAM,KAAK,KAAK,EAAE,CAAC,CAAC;AACzF;AAWA,SAAS,iBAAiB,aAAa,KAAK,cAAc,IAAU;AAClE,MAAI,WAAW;AAEf,QAAM,KAAK,YAAY,YAAY;AACjC;AACA,QAAI;AACF,YAAM,MAAM,MAAM,MAAM,eAAe,EAAE,OAAO,WAAW,CAAC;AAC5D,UAAI,IAAI,IAAI;AACV,sBAAc,EAAE;AAChB,YAAI,KAAK,oCAA+B;AACxC,eAAO,SAAS,OAAO;AAAA,MACzB;AAAA,IACF,QAAQ;AAAA,IAER;AAEA,QAAI,YAAY,aAAa;AAC3B,oBAAc,EAAE;AAChB,UAAI,MAAM,8CAA8C;AAAA,IAC1D;AAAA,EACF,GAAG,UAAU;AACf;AASA,SAAS,oBAA0B;AACjC,QAAM,QAAQ,SAAS,iBAAkC,wBAAwB;AACjF,MAAI,KAAK,sCAAiC,MAAM,MAAM,gBAAgB;AACtE,QAAM,QAAQ,UAAQ;AACpB,UAAM,MAAM,IAAI,IAAI,KAAK,IAAI;AAC7B,QAAI,aAAa,IAAI,KAAK,OAAO,KAAK,IAAI,CAAC,CAAC;AAC5C,SAAK,OAAO,IAAI,SAAS;AAAA,EAC3B,CAAC;AACH;AAGA,IAAI;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/dist/hmr.d.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hmr.ts — Server-Side HMR (Hot Module Replacement) Engine
|
|
3
|
+
*
|
|
4
|
+
* Manages the set of connected SSE clients and broadcasts change payloads to
|
|
5
|
+
* them when source files are modified.
|
|
6
|
+
*
|
|
7
|
+
* The HMR protocol uses three message types:
|
|
8
|
+
*
|
|
9
|
+
* { type: 'reload', url: '/path' } — A page file changed. The browser
|
|
10
|
+
* re-fetches and swaps that URL if it
|
|
11
|
+
* is currently active.
|
|
12
|
+
* url: '*' means CSS changed — only
|
|
13
|
+
* stylesheets are reloaded in-place.
|
|
14
|
+
*
|
|
15
|
+
* { type: 'replace', component: 'X' } — A non-page file changed (shared
|
|
16
|
+
* component, util, etc.). The browser
|
|
17
|
+
* re-fetches the current page to pick
|
|
18
|
+
* up the new version.
|
|
19
|
+
*
|
|
20
|
+
* { type: 'restart' } — The server is about to restart
|
|
21
|
+
* (middleware.ts or nuke.config.ts
|
|
22
|
+
* changed). The client polls
|
|
23
|
+
* /__hmr_ping until it gets a 200.
|
|
24
|
+
*
|
|
25
|
+
* File change → payload mapping:
|
|
26
|
+
* pages/** → reload (URL derived from the file path)
|
|
27
|
+
* *.css / *.scss … → reload (url: '*' triggers stylesheet cache-bust)
|
|
28
|
+
* anything else → replace (component name used for logging only)
|
|
29
|
+
*
|
|
30
|
+
* Debouncing:
|
|
31
|
+
* Editors often emit multiple fs events for a single save. Changes are
|
|
32
|
+
* debounced per filename with a 100 ms window so each save produces exactly
|
|
33
|
+
* one broadcast.
|
|
34
|
+
*/
|
|
35
|
+
import { ServerResponse } from 'http';
|
|
36
|
+
/**
|
|
37
|
+
* All currently connected SSE clients (long-lived ServerResponse objects).
|
|
38
|
+
* Exported so middleware.ts can register new connections.
|
|
39
|
+
*/
|
|
40
|
+
export declare const hmrClients: Set<ServerResponse<import("node:http").IncomingMessage>>;
|
|
41
|
+
/**
|
|
42
|
+
* Recursively watches `dir` and broadcasts an HMR message whenever a file
|
|
43
|
+
* changes. Changes are debounced per file with a 100 ms window.
|
|
44
|
+
*
|
|
45
|
+
* @param dir Absolute path to watch.
|
|
46
|
+
* @param label Short label for log messages (e.g. 'App', 'Server').
|
|
47
|
+
*/
|
|
48
|
+
export declare function watchDir(dir: string, label: string): void;
|
|
49
|
+
/**
|
|
50
|
+
* Sends a 'restart' message to all SSE clients, then waits 120 ms to give
|
|
51
|
+
* them time to receive it before the process exits.
|
|
52
|
+
*
|
|
53
|
+
* Called by app.ts before `process.exit(75)` when a config file changes.
|
|
54
|
+
*/
|
|
55
|
+
export declare function broadcastRestart(): Promise<void>;
|
package/dist/hmr.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { existsSync, watch } from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { log } from "./logger.js";
|
|
4
|
+
import { invalidateComponentCache } from "./component-analyzer.js";
|
|
5
|
+
const hmrClients = /* @__PURE__ */ new Set();
|
|
6
|
+
function broadcastHmr(payload) {
|
|
7
|
+
const data = `data: ${JSON.stringify(payload)}
|
|
8
|
+
|
|
9
|
+
`;
|
|
10
|
+
for (const client of hmrClients) {
|
|
11
|
+
try {
|
|
12
|
+
client.write(data);
|
|
13
|
+
} catch {
|
|
14
|
+
hmrClients.delete(client);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function pageFileToUrl(filename) {
|
|
19
|
+
const withoutPages = filename.slice("pages/".length);
|
|
20
|
+
const withoutExt = withoutPages.replace(/\.(tsx|ts)$/, "");
|
|
21
|
+
const url = withoutExt === "index" || withoutExt === "layout" ? "/" : "/" + withoutExt.replace(/\/index$/, "").replace(/\/layout$/, "").replace(/\\/g, "/");
|
|
22
|
+
return url;
|
|
23
|
+
}
|
|
24
|
+
function buildPayload(filename) {
|
|
25
|
+
const normalized = filename.replace(/\\/g, "/");
|
|
26
|
+
if (normalized.startsWith("pages/")) {
|
|
27
|
+
const url = pageFileToUrl(normalized);
|
|
28
|
+
return { type: "reload", url };
|
|
29
|
+
}
|
|
30
|
+
const ext = path.extname(filename).toLowerCase();
|
|
31
|
+
if (ext === ".css" || ext === ".scss" || ext === ".sass" || ext === ".less") {
|
|
32
|
+
return { type: "reload", url: "*" };
|
|
33
|
+
}
|
|
34
|
+
const componentName = path.basename(filename, path.extname(filename));
|
|
35
|
+
return { type: "replace", component: componentName };
|
|
36
|
+
}
|
|
37
|
+
const pending = /* @__PURE__ */ new Map();
|
|
38
|
+
function watchDir(dir, label) {
|
|
39
|
+
if (!existsSync(dir)) return;
|
|
40
|
+
watch(dir, { recursive: true }, (_event, filename) => {
|
|
41
|
+
if (!filename) return;
|
|
42
|
+
if (pending.has(filename)) clearTimeout(pending.get(filename));
|
|
43
|
+
const timeout = setTimeout(() => {
|
|
44
|
+
const payload = buildPayload(filename);
|
|
45
|
+
log.info(`[HMR] ${label} changed: ${filename}`, JSON.stringify(payload));
|
|
46
|
+
if (dir) invalidateComponentCache(path.resolve(dir, filename));
|
|
47
|
+
broadcastHmr(payload);
|
|
48
|
+
pending.delete(filename);
|
|
49
|
+
}, 100);
|
|
50
|
+
pending.set(filename, timeout);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
function broadcastRestart() {
|
|
54
|
+
broadcastHmr({ type: "restart" });
|
|
55
|
+
return new Promise((resolve) => setTimeout(resolve, 120));
|
|
56
|
+
}
|
|
57
|
+
export {
|
|
58
|
+
broadcastRestart,
|
|
59
|
+
hmrClients,
|
|
60
|
+
watchDir
|
|
61
|
+
};
|
|
62
|
+
//# sourceMappingURL=hmr.js.map
|
package/dist/hmr.js.map
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/hmr.ts"],
|
|
4
|
+
"sourcesContent": ["/**\r\n * hmr.ts \u2014 Server-Side HMR (Hot Module Replacement) Engine\r\n *\r\n * Manages the set of connected SSE clients and broadcasts change payloads to\r\n * them when source files are modified.\r\n *\r\n * The HMR protocol uses three message types:\r\n *\r\n * { type: 'reload', url: '/path' } \u2014 A page file changed. The browser\r\n * re-fetches and swaps that URL if it\r\n * is currently active.\r\n * url: '*' means CSS changed \u2014 only\r\n * stylesheets are reloaded in-place.\r\n *\r\n * { type: 'replace', component: 'X' } \u2014 A non-page file changed (shared\r\n * component, util, etc.). The browser\r\n * re-fetches the current page to pick\r\n * up the new version.\r\n *\r\n * { type: 'restart' } \u2014 The server is about to restart\r\n * (middleware.ts or nuke.config.ts\r\n * changed). The client polls\r\n * /__hmr_ping until it gets a 200.\r\n *\r\n * File change \u2192 payload mapping:\r\n * pages/** \u2192 reload (URL derived from the file path)\r\n * *.css / *.scss \u2026 \u2192 reload (url: '*' triggers stylesheet cache-bust)\r\n * anything else \u2192 replace (component name used for logging only)\r\n *\r\n * Debouncing:\r\n * Editors often emit multiple fs events for a single save. Changes are\r\n * debounced per filename with a 100 ms window so each save produces exactly\r\n * one broadcast.\r\n */\r\n\r\nimport { ServerResponse } from 'http';\r\nimport { existsSync, watch } from 'fs';\r\nimport path from 'path';\r\nimport { log } from './logger';\r\nimport { invalidateComponentCache } from './component-analyzer';\r\n\r\n// \u2500\u2500\u2500 SSE client registry \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 * All currently connected SSE clients (long-lived ServerResponse objects).\r\n * Exported so middleware.ts can register new connections.\r\n */\r\nexport const hmrClients = new Set<ServerResponse>();\r\n\r\n// \u2500\u2500\u2500 Broadcast \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 * Sends a JSON payload to every connected SSE client.\r\n * Clients that have disconnected are silently removed from the set.\r\n */\r\nfunction broadcastHmr(payload: object): void {\r\n const data = `data: ${JSON.stringify(payload)}\\n\\n`;\r\n for (const client of hmrClients) {\r\n try {\r\n client.write(data);\r\n } catch {\r\n // Write failed \u2014 client disconnected without closing cleanly.\r\n hmrClients.delete(client);\r\n }\r\n }\r\n}\r\n\r\n// \u2500\u2500\u2500 Payload builder \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 a relative filename (as emitted by fs.watch) to the URL the browser\r\n * should re-fetch.\r\n *\r\n * Input examples:\r\n * 'pages/index.tsx' \u2192 '/'\r\n * 'pages/about/index.tsx' \u2192 '/about'\r\n * 'pages/blog/[slug].tsx' \u2192 '/blog/[slug]' (dynamic segment preserved)\r\n */\r\nfunction pageFileToUrl(filename: string): string {\r\n // Strip the leading 'pages/' prefix that watchDir was called with.\r\n const withoutPages = filename.slice('pages/'.length);\r\n const withoutExt = withoutPages.replace(/\\.(tsx|ts)$/, '');\r\n\r\n // 'index' and 'layout' at any level map to the directory URL.\r\n // e.g. 'users/layout' \u2192 '/users', 'layout' \u2192 '/', 'users/index' \u2192 '/users'\r\n const url = withoutExt === 'index' || withoutExt === 'layout'\r\n ? '/'\r\n : '/' + withoutExt\r\n .replace(/\\/index$/, '')\r\n .replace(/\\/layout$/, '')\r\n .replace(/\\\\/g, '/');\r\n\r\n return url;\r\n}\r\n\r\n/**\r\n * Determines the appropriate HMR message for a changed file.\r\n *\r\n * Routing logic:\r\n * - Paths under pages/ \u2192 `reload` with the derived URL\r\n * - CSS/Sass/Less files \u2192 `reload` with url='*' (stylesheet cache-bust)\r\n * - Everything else \u2192 `replace` with the component base name\r\n */\r\nfunction buildPayload(filename: string): object {\r\n const normalized = filename.replace(/\\\\/g, '/');\r\n\r\n if (normalized.startsWith('pages/')) {\r\n const url = pageFileToUrl(normalized);\r\n return { type: 'reload', url };\r\n }\r\n\r\n const ext = path.extname(filename).toLowerCase();\r\n if (ext === '.css' || ext === '.scss' || ext === '.sass' || ext === '.less') {\r\n return { type: 'reload', url: '*' };\r\n }\r\n\r\n // Generic component/util change \u2014 browser re-renders the current page.\r\n const componentName = path.basename(filename, path.extname(filename));\r\n return { type: 'replace', component: componentName };\r\n}\r\n\r\n// \u2500\u2500\u2500 File watcher \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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/** Per-filename debounce timers. */\r\nconst pending = new Map<string, NodeJS.Timeout>();\r\n\r\n/**\r\n * Recursively watches `dir` and broadcasts an HMR message whenever a file\r\n * changes. Changes are debounced per file with a 100 ms window.\r\n *\r\n * @param dir Absolute path to watch.\r\n * @param label Short label for log messages (e.g. 'App', 'Server').\r\n */\r\nexport function watchDir(dir: string, label: string): void {\r\n if (!existsSync(dir)) return;\r\n\r\n watch(dir, { recursive: true }, (_event, filename) => {\r\n if (!filename) return;\r\n\r\n // Debounce: cancel any pending timer for this file and restart it.\r\n if (pending.has(filename)) clearTimeout(pending.get(filename)!);\r\n\r\n const timeout = setTimeout(() => {\r\n const payload = buildPayload(filename);\r\n log.info(`[HMR] ${label} changed: ${filename}`, JSON.stringify(payload));\r\n\r\n // Evict this file from the component-analysis cache so the next SSR\r\n // render re-analyses it (catches \"use client\" or import graph changes).\r\n if (dir) invalidateComponentCache(path.resolve(dir, filename));\r\n\r\n broadcastHmr(payload);\r\n pending.delete(filename);\r\n }, 100);\r\n\r\n pending.set(filename, timeout);\r\n });\r\n}\r\n\r\n// \u2500\u2500\u2500 Restart broadcast \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 * Sends a 'restart' message to all SSE clients, then waits 120 ms to give\r\n * them time to receive it before the process exits.\r\n *\r\n * Called by app.ts before `process.exit(75)` when a config file changes.\r\n */\r\nexport function broadcastRestart(): Promise<void> {\r\n broadcastHmr({ type: 'restart' });\r\n return new Promise<void>(resolve => setTimeout(resolve, 120));\r\n}"],
|
|
5
|
+
"mappings": "AAoCA,SAAS,YAAY,aAAa;AAClC,OAAO,UAAU;AACjB,SAAS,WAAW;AACpB,SAAS,gCAAgC;AAQlC,MAAM,aAAa,oBAAI,IAAoB;AAQlD,SAAS,aAAa,SAAuB;AAC3C,QAAM,OAAO,SAAS,KAAK,UAAU,OAAO,CAAC;AAAA;AAAA;AAC7C,aAAW,UAAU,YAAY;AAC/B,QAAI;AACF,aAAO,MAAM,IAAI;AAAA,IACnB,QAAQ;AAEN,iBAAW,OAAO,MAAM;AAAA,IAC1B;AAAA,EACF;AACF;AAaA,SAAS,cAAc,UAA0B;AAE/C,QAAM,eAAe,SAAS,MAAM,SAAS,MAAM;AACnD,QAAM,aAAe,aAAa,QAAQ,eAAe,EAAE;AAI3D,QAAM,MAAM,eAAe,WAAW,eAAe,WACjD,MACA,MAAM,WACH,QAAQ,YAAY,EAAE,EACtB,QAAQ,aAAa,EAAE,EACvB,QAAQ,OAAO,GAAG;AAEzB,SAAO;AACT;AAUA,SAAS,aAAa,UAA0B;AAC9C,QAAM,aAAa,SAAS,QAAQ,OAAO,GAAG;AAE9C,MAAI,WAAW,WAAW,QAAQ,GAAG;AACnC,UAAM,MAAM,cAAc,UAAU;AACpC,WAAO,EAAE,MAAM,UAAU,IAAI;AAAA,EAC/B;AAEA,QAAM,MAAM,KAAK,QAAQ,QAAQ,EAAE,YAAY;AAC/C,MAAI,QAAQ,UAAU,QAAQ,WAAW,QAAQ,WAAW,QAAQ,SAAS;AAC3E,WAAO,EAAE,MAAM,UAAU,KAAK,IAAI;AAAA,EACpC;AAGA,QAAM,gBAAgB,KAAK,SAAS,UAAU,KAAK,QAAQ,QAAQ,CAAC;AACpE,SAAO,EAAE,MAAM,WAAW,WAAW,cAAc;AACrD;AAKA,MAAM,UAAU,oBAAI,IAA4B;AASzC,SAAS,SAAS,KAAa,OAAqB;AACzD,MAAI,CAAC,WAAW,GAAG,EAAG;AAEtB,QAAM,KAAK,EAAE,WAAW,KAAK,GAAG,CAAC,QAAQ,aAAa;AACpD,QAAI,CAAC,SAAU;AAGf,QAAI,QAAQ,IAAI,QAAQ,EAAG,cAAa,QAAQ,IAAI,QAAQ,CAAE;AAE9D,UAAM,UAAU,WAAW,MAAM;AAC/B,YAAM,UAAU,aAAa,QAAQ;AACrC,UAAI,KAAK,SAAS,KAAK,aAAa,QAAQ,IAAI,KAAK,UAAU,OAAO,CAAC;AAIvE,UAAI,IAAK,0BAAyB,KAAK,QAAQ,KAAK,QAAQ,CAAC;AAE7D,mBAAa,OAAO;AACpB,cAAQ,OAAO,QAAQ;AAAA,IACzB,GAAG,GAAG;AAEN,YAAQ,IAAI,UAAU,OAAO;AAAA,EAC/B,CAAC;AACH;AAUO,SAAS,mBAAkC;AAChD,eAAa,EAAE,MAAM,UAAU,CAAC;AAChC,SAAO,IAAI,QAAc,aAAW,WAAW,SAAS,GAAG,CAAC;AAC9D;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* html-store.ts — Per-Request HTML Head Store
|
|
3
|
+
*
|
|
4
|
+
* Provides a request-scoped store that server components can write to via
|
|
5
|
+
* `useHtml()` during SSR. The accumulated values are flushed into the
|
|
6
|
+
* rendered HTML document after the component tree is fully rendered.
|
|
7
|
+
*
|
|
8
|
+
* Why globalThis?
|
|
9
|
+
* Node's module system may import this file multiple times if the page
|
|
10
|
+
* module and the nukejs package resolve to different copies (e.g. when
|
|
11
|
+
* running from source in dev with tsx). Using a well-known Symbol on
|
|
12
|
+
* globalThis guarantees all copies share the same store instance.
|
|
13
|
+
*
|
|
14
|
+
* Request isolation:
|
|
15
|
+
* runWithHtmlStore() creates a fresh store before rendering and clears it
|
|
16
|
+
* in the `finally` block, so concurrent requests cannot bleed into each other.
|
|
17
|
+
*
|
|
18
|
+
* Title resolution:
|
|
19
|
+
* Layouts and pages can both call useHtml({ title: … }). Layouts typically
|
|
20
|
+
* pass a template function:
|
|
21
|
+
*
|
|
22
|
+
* useHtml({ title: (prev) => `${prev} | Acme` })
|
|
23
|
+
*
|
|
24
|
+
* Operations are collected in render order (outermost layout first, page
|
|
25
|
+
* last) then resolved *in reverse* so the page's string value is the base
|
|
26
|
+
* and layout template functions wrap outward.
|
|
27
|
+
*/
|
|
28
|
+
/** A page sets a literal string; a layout wraps with a template function. */
|
|
29
|
+
export type TitleValue = string | ((prev: string) => string);
|
|
30
|
+
export interface HtmlAttrs {
|
|
31
|
+
lang?: string;
|
|
32
|
+
class?: string;
|
|
33
|
+
style?: string;
|
|
34
|
+
dir?: string;
|
|
35
|
+
[attr: string]: string | undefined;
|
|
36
|
+
}
|
|
37
|
+
export interface BodyAttrs {
|
|
38
|
+
class?: string;
|
|
39
|
+
style?: string;
|
|
40
|
+
[attr: string]: string | undefined;
|
|
41
|
+
}
|
|
42
|
+
export interface MetaTag {
|
|
43
|
+
name?: string;
|
|
44
|
+
property?: string;
|
|
45
|
+
httpEquiv?: string;
|
|
46
|
+
charset?: string;
|
|
47
|
+
content?: string;
|
|
48
|
+
[attr: string]: string | undefined;
|
|
49
|
+
}
|
|
50
|
+
export interface LinkTag {
|
|
51
|
+
rel?: string;
|
|
52
|
+
href?: string;
|
|
53
|
+
type?: string;
|
|
54
|
+
media?: string;
|
|
55
|
+
as?: string;
|
|
56
|
+
crossOrigin?: string;
|
|
57
|
+
integrity?: string;
|
|
58
|
+
hrefLang?: string;
|
|
59
|
+
sizes?: string;
|
|
60
|
+
[attr: string]: string | undefined;
|
|
61
|
+
}
|
|
62
|
+
export interface ScriptTag {
|
|
63
|
+
src?: string;
|
|
64
|
+
content?: string;
|
|
65
|
+
type?: string;
|
|
66
|
+
defer?: boolean;
|
|
67
|
+
async?: boolean;
|
|
68
|
+
crossOrigin?: string;
|
|
69
|
+
integrity?: string;
|
|
70
|
+
noModule?: boolean;
|
|
71
|
+
}
|
|
72
|
+
export interface StyleTag {
|
|
73
|
+
content?: string;
|
|
74
|
+
media?: string;
|
|
75
|
+
}
|
|
76
|
+
export interface HtmlStore {
|
|
77
|
+
/** Collected in render order; resolved in reverse so the page title wins. */
|
|
78
|
+
titleOps: TitleValue[];
|
|
79
|
+
/** Attributes merged onto <html>; last write wins per attribute. */
|
|
80
|
+
htmlAttrs: HtmlAttrs;
|
|
81
|
+
/** Attributes merged onto <body>; last write wins per attribute. */
|
|
82
|
+
bodyAttrs: BodyAttrs;
|
|
83
|
+
/** Accumulated in render order: layouts first, page last. */
|
|
84
|
+
meta: MetaTag[];
|
|
85
|
+
link: LinkTag[];
|
|
86
|
+
script: ScriptTag[];
|
|
87
|
+
style: StyleTag[];
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Runs `fn` inside a fresh HTML store and returns the collected values.
|
|
91
|
+
*
|
|
92
|
+
* Usage in SSR:
|
|
93
|
+
* ```ts
|
|
94
|
+
* const store = await runWithHtmlStore(async () => {
|
|
95
|
+
* appHtml = await renderElementToHtml(element, ctx);
|
|
96
|
+
* });
|
|
97
|
+
* // store.titleOps, store.meta, etc. are now populated
|
|
98
|
+
* ```
|
|
99
|
+
*/
|
|
100
|
+
export declare function runWithHtmlStore(fn: () => Promise<void>): Promise<HtmlStore>;
|
|
101
|
+
/**
|
|
102
|
+
* Returns the current request's store, or `undefined` if called outside of
|
|
103
|
+
* a `runWithHtmlStore` context (e.g. in the browser or in a test).
|
|
104
|
+
*/
|
|
105
|
+
export declare function getHtmlStore(): HtmlStore | undefined;
|
|
106
|
+
/**
|
|
107
|
+
* Resolves the final page title from a list of title operations.
|
|
108
|
+
*
|
|
109
|
+
* Operations are walked in *reverse* so the page's value is the starting
|
|
110
|
+
* point and layout template functions wrap it outward:
|
|
111
|
+
*
|
|
112
|
+
* ```
|
|
113
|
+
* ops = [ (p) => `${p} | Acme`, 'About' ] ← layout pushed first, page last
|
|
114
|
+
* Walk in reverse:
|
|
115
|
+
* i=1: op = 'About' → title = 'About'
|
|
116
|
+
* i=0: op = (p) => … → title = 'About | Acme'
|
|
117
|
+
* ```
|
|
118
|
+
*
|
|
119
|
+
* @param fallback Used when ops is empty (e.g. a page that didn't call useHtml).
|
|
120
|
+
*/
|
|
121
|
+
export declare function resolveTitle(ops: TitleValue[], fallback?: string): string;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
const KEY = /* @__PURE__ */ Symbol.for("__nukejs_html_store__");
|
|
2
|
+
const getGlobal = () => globalThis[KEY] ?? null;
|
|
3
|
+
const setGlobal = (store) => {
|
|
4
|
+
globalThis[KEY] = store;
|
|
5
|
+
};
|
|
6
|
+
function emptyStore() {
|
|
7
|
+
return {
|
|
8
|
+
titleOps: [],
|
|
9
|
+
htmlAttrs: {},
|
|
10
|
+
bodyAttrs: {},
|
|
11
|
+
meta: [],
|
|
12
|
+
link: [],
|
|
13
|
+
script: [],
|
|
14
|
+
style: []
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
async function runWithHtmlStore(fn) {
|
|
18
|
+
setGlobal(emptyStore());
|
|
19
|
+
try {
|
|
20
|
+
await fn();
|
|
21
|
+
return { ...getGlobal() ?? emptyStore() };
|
|
22
|
+
} finally {
|
|
23
|
+
setGlobal(null);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function getHtmlStore() {
|
|
27
|
+
return getGlobal() ?? void 0;
|
|
28
|
+
}
|
|
29
|
+
function resolveTitle(ops, fallback = "") {
|
|
30
|
+
let title = fallback;
|
|
31
|
+
for (let i = ops.length - 1; i >= 0; i--) {
|
|
32
|
+
const op = ops[i];
|
|
33
|
+
title = typeof op === "string" ? op : op(title);
|
|
34
|
+
}
|
|
35
|
+
return title;
|
|
36
|
+
}
|
|
37
|
+
export {
|
|
38
|
+
getHtmlStore,
|
|
39
|
+
resolveTitle,
|
|
40
|
+
runWithHtmlStore
|
|
41
|
+
};
|
|
42
|
+
//# sourceMappingURL=html-store.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/html-store.ts"],
|
|
4
|
+
"sourcesContent": ["/**\r\n * html-store.ts \u2014 Per-Request HTML Head Store\r\n *\r\n * Provides a request-scoped store that server components can write to via\r\n * `useHtml()` during SSR. The accumulated values are flushed into the\r\n * rendered HTML document after the component tree is fully rendered.\r\n *\r\n * Why globalThis?\r\n * Node's module system may import this file multiple times if the page\r\n * module and the nukejs package resolve to different copies (e.g. when\r\n * running from source in dev with tsx). Using a well-known Symbol on\r\n * globalThis guarantees all copies share the same store instance.\r\n *\r\n * Request isolation:\r\n * runWithHtmlStore() creates a fresh store before rendering and clears it\r\n * in the `finally` block, so concurrent requests cannot bleed into each other.\r\n *\r\n * Title resolution:\r\n * Layouts and pages can both call useHtml({ title: \u2026 }). Layouts typically\r\n * pass a template function:\r\n *\r\n * useHtml({ title: (prev) => `${prev} | Acme` })\r\n *\r\n * Operations are collected in render order (outermost layout first, page\r\n * last) then resolved *in reverse* so the page's string value is the base\r\n * and layout template functions wrap outward.\r\n */\r\n\r\n// \u2500\u2500\u2500 Public types \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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/** A page sets a literal string; a layout wraps with a template function. */\r\nexport type TitleValue = string | ((prev: string) => string);\r\n\r\nexport interface HtmlAttrs {\r\n lang?: string;\r\n class?: string;\r\n style?: string;\r\n dir?: string;\r\n [attr: string]: string | undefined;\r\n}\r\n\r\nexport interface BodyAttrs {\r\n class?: string;\r\n style?: string;\r\n [attr: string]: string | undefined;\r\n}\r\n\r\nexport interface MetaTag {\r\n name?: string;\r\n property?: string;\r\n httpEquiv?: string;\r\n charset?: string;\r\n content?: string;\r\n [attr: string]: string | undefined;\r\n}\r\n\r\nexport interface LinkTag {\r\n rel?: string;\r\n href?: string;\r\n type?: string;\r\n media?: string;\r\n as?: string;\r\n crossOrigin?: string;\r\n integrity?: string;\r\n hrefLang?: string;\r\n sizes?: string;\r\n [attr: string]: string | undefined;\r\n}\r\n\r\nexport interface ScriptTag {\r\n src?: string;\r\n content?: string;\r\n type?: string;\r\n defer?: boolean;\r\n async?: boolean;\r\n crossOrigin?: string;\r\n integrity?: string;\r\n noModule?: boolean;\r\n}\r\n\r\nexport interface StyleTag {\r\n content?: string;\r\n media?: string;\r\n}\r\n\r\nexport interface HtmlStore {\r\n /** Collected in render order; resolved in reverse so the page title wins. */\r\n titleOps: TitleValue[];\r\n /** Attributes merged onto <html>; last write wins per attribute. */\r\n htmlAttrs: HtmlAttrs;\r\n /** Attributes merged onto <body>; last write wins per attribute. */\r\n bodyAttrs: BodyAttrs;\r\n /** Accumulated in render order: layouts first, page last. */\r\n meta: MetaTag[];\r\n link: LinkTag[];\r\n script: ScriptTag[];\r\n style: StyleTag[];\r\n}\r\n\r\n// \u2500\u2500\u2500 GlobalThis storage \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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/** Well-known Symbol used to share the store across duplicate module copies. */\r\nconst KEY = Symbol.for('__nukejs_html_store__');\r\n\r\nconst getGlobal = (): HtmlStore | null => (globalThis as any)[KEY] ?? null;\r\nconst setGlobal = (store: HtmlStore | null): void => { (globalThis as any)[KEY] = store; };\r\n\r\nfunction emptyStore(): HtmlStore {\r\n return {\r\n titleOps: [],\r\n htmlAttrs: {},\r\n bodyAttrs: {},\r\n meta: [],\r\n link: [],\r\n script: [],\r\n style: [],\r\n };\r\n}\r\n\r\n// \u2500\u2500\u2500 Public API \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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 * Runs `fn` inside a fresh HTML store and returns the collected values.\r\n *\r\n * Usage in SSR:\r\n * ```ts\r\n * const store = await runWithHtmlStore(async () => {\r\n * appHtml = await renderElementToHtml(element, ctx);\r\n * });\r\n * // store.titleOps, store.meta, etc. are now populated\r\n * ```\r\n */\r\nexport async function runWithHtmlStore(fn: () => Promise<void>): Promise<HtmlStore> {\r\n setGlobal(emptyStore());\r\n try {\r\n await fn();\r\n return { ...(getGlobal() ?? emptyStore()) } as HtmlStore;\r\n } finally {\r\n // Always clear the store, even if rendering throws, to prevent leakage\r\n // into the next request on the same event-loop tick.\r\n setGlobal(null);\r\n }\r\n}\r\n\r\n/**\r\n * Returns the current request's store, or `undefined` if called outside of\r\n * a `runWithHtmlStore` context (e.g. in the browser or in a test).\r\n */\r\nexport function getHtmlStore(): HtmlStore | undefined {\r\n return getGlobal() ?? undefined;\r\n}\r\n\r\n/**\r\n * Resolves the final page title from a list of title operations.\r\n *\r\n * Operations are walked in *reverse* so the page's value is the starting\r\n * point and layout template functions wrap it outward:\r\n *\r\n * ```\r\n * ops = [ (p) => `${p} | Acme`, 'About' ] \u2190 layout pushed first, page last\r\n * Walk in reverse:\r\n * i=1: op = 'About' \u2192 title = 'About'\r\n * i=0: op = (p) => \u2026 \u2192 title = 'About | Acme'\r\n * ```\r\n *\r\n * @param fallback Used when ops is empty (e.g. a page that didn't call useHtml).\r\n */\r\nexport function resolveTitle(ops: TitleValue[], fallback = ''): string {\r\n let title = fallback;\r\n for (let i = ops.length - 1; i >= 0; i--) {\r\n const op = ops[i];\r\n title = typeof op === 'string' ? op : op(title);\r\n }\r\n return title;\r\n}\r\n"],
|
|
5
|
+
"mappings": "AAsGA,MAAM,MAAM,uBAAO,IAAI,uBAAuB;AAE9C,MAAM,YAAY,MAAyB,WAAmB,GAAG,KAAK;AACtE,MAAM,YAAY,CAAC,UAAkC;AAAE,EAAC,WAAmB,GAAG,IAAI;AAAO;AAEzF,SAAS,aAAwB;AAC/B,SAAO;AAAA,IACL,UAAW,CAAC;AAAA,IACZ,WAAW,CAAC;AAAA,IACZ,WAAW,CAAC;AAAA,IACZ,MAAW,CAAC;AAAA,IACZ,MAAW,CAAC;AAAA,IACZ,QAAW,CAAC;AAAA,IACZ,OAAW,CAAC;AAAA,EACd;AACF;AAeA,eAAsB,iBAAiB,IAA6C;AAClF,YAAU,WAAW,CAAC;AACtB,MAAI;AACF,UAAM,GAAG;AACT,WAAO,EAAE,GAAI,UAAU,KAAK,WAAW,EAAG;AAAA,EAC5C,UAAE;AAGA,cAAU,IAAI;AAAA,EAChB;AACF;AAMO,SAAS,eAAsC;AACpD,SAAO,UAAU,KAAK;AACxB;AAiBO,SAAS,aAAa,KAAmB,WAAW,IAAY;AACrE,MAAI,QAAQ;AACZ,WAAS,IAAI,IAAI,SAAS,GAAG,KAAK,GAAG,KAAK;AACxC,UAAM,KAAK,IAAI,CAAC;AAChB,YAAQ,OAAO,OAAO,WAAW,KAAK,GAAG,KAAK;AAAA,EAChD;AACA,SAAO;AACT;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|