@elurjs/kit 2.4.10 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/CHANGELOG.md +298 -0
  2. package/README.md +195 -9
  3. package/dist/lib/action/index.cjs +1 -1
  4. package/dist/lib/action/index.js +1 -1
  5. package/dist/lib/{action-DZHihU3Z.cjs → action-BiE4tEty.cjs} +2 -2
  6. package/dist/lib/{action-DZHihU3Z.cjs.map → action-BiE4tEty.cjs.map} +1 -1
  7. package/dist/lib/{action-BB6N5yD-.js → action-CXVk8rWt.js} +3 -3
  8. package/dist/lib/{action-BB6N5yD-.js.map → action-CXVk8rWt.js.map} +1 -1
  9. package/dist/lib/adapter-DNsjXvq5.js +160 -0
  10. package/dist/lib/adapter-DNsjXvq5.js.map +1 -0
  11. package/dist/lib/adapter-Dz2hzR36.cjs +3 -0
  12. package/dist/lib/adapter-Dz2hzR36.cjs.map +1 -0
  13. package/dist/lib/adapters/bun.cjs +2 -2
  14. package/dist/lib/adapters/bun.cjs.map +1 -1
  15. package/dist/lib/adapters/bun.js +17 -9
  16. package/dist/lib/adapters/bun.js.map +1 -1
  17. package/dist/lib/adapters/index.d.cts +34 -0
  18. package/dist/lib/adapters/index.d.ts +34 -0
  19. package/dist/lib/adapters/netlify.cjs +1 -1
  20. package/dist/lib/adapters/netlify.js +7 -7
  21. package/dist/lib/adapters/node.cjs +5 -5
  22. package/dist/lib/adapters/node.cjs.map +1 -1
  23. package/dist/lib/adapters/node.js +20 -12
  24. package/dist/lib/adapters/node.js.map +1 -1
  25. package/dist/lib/adapters/vercel.cjs +1 -1
  26. package/dist/lib/adapters/vercel.js +8 -8
  27. package/dist/lib/build/build.d.cts +40 -0
  28. package/dist/lib/build/build.d.ts +40 -0
  29. package/dist/lib/build/document-shell.d.cts +47 -1
  30. package/dist/lib/build/document-shell.d.ts +47 -1
  31. package/dist/lib/build/vite-build.d.cts +25 -2
  32. package/dist/lib/build/vite-build.d.ts +25 -2
  33. package/dist/lib/cache/adapter.d.cts +41 -0
  34. package/dist/lib/cache/adapter.d.ts +41 -0
  35. package/dist/lib/cache/index.cjs +2 -0
  36. package/dist/lib/cache/index.cjs.map +1 -0
  37. package/dist/lib/cache/index.d.cts +5 -0
  38. package/dist/lib/cache/index.d.ts +5 -0
  39. package/dist/lib/cache/index.js +113 -0
  40. package/dist/lib/cache/index.js.map +1 -0
  41. package/dist/lib/cache/invalidation.d.cts +36 -0
  42. package/dist/lib/cache/invalidation.d.ts +36 -0
  43. package/dist/lib/cache/redis-adapter.d.cts +49 -0
  44. package/dist/lib/cache/redis-adapter.d.ts +49 -0
  45. package/dist/lib/cache-DGhTYyqh.js +50 -0
  46. package/dist/lib/cache-DGhTYyqh.js.map +1 -0
  47. package/dist/lib/cache-X1n4Ossu.cjs +2 -0
  48. package/dist/lib/cache-X1n4Ossu.cjs.map +1 -0
  49. package/dist/lib/cli/output.d.cts +58 -0
  50. package/dist/lib/cli/output.d.ts +58 -0
  51. package/dist/lib/cli/ports.d.cts +20 -0
  52. package/dist/lib/cli/ports.d.ts +20 -0
  53. package/dist/lib/cli.cjs +2568 -1489
  54. package/dist/lib/cli.cjs.map +1 -1
  55. package/dist/lib/cli.d.cts +21 -0
  56. package/dist/lib/cli.d.ts +21 -0
  57. package/dist/lib/cli.js +2567 -1489
  58. package/dist/lib/cli.js.map +1 -1
  59. package/dist/lib/config/index.cjs +1 -1
  60. package/dist/lib/config/index.cjs.map +1 -1
  61. package/dist/lib/config/index.d.cts +79 -0
  62. package/dist/lib/config/index.d.ts +79 -0
  63. package/dist/lib/config/index.js +21 -14
  64. package/dist/lib/config/index.js.map +1 -1
  65. package/dist/lib/{errors-ePYxDlhU.js → errors-j4cg9VN3.js} +24 -30
  66. package/dist/lib/{errors-ePYxDlhU.js.map → errors-j4cg9VN3.js.map} +1 -1
  67. package/dist/lib/errors-kSl9yLkg.cjs +2 -0
  68. package/dist/lib/{errors-0X7pCFNU.cjs.map → errors-kSl9yLkg.cjs.map} +1 -1
  69. package/dist/lib/hydrate-BSLuI0Nh.cjs +2 -0
  70. package/dist/lib/hydrate-BSLuI0Nh.cjs.map +1 -0
  71. package/dist/lib/hydrate-BaS7-d8C.js +133 -0
  72. package/dist/lib/hydrate-BaS7-d8C.js.map +1 -0
  73. package/dist/lib/idiomorph.esm-BOOTOg1r.cjs +2 -0
  74. package/dist/lib/idiomorph.esm-BOOTOg1r.cjs.map +1 -0
  75. package/dist/lib/idiomorph.esm-CXuY-EHB.js +371 -0
  76. package/dist/lib/idiomorph.esm-CXuY-EHB.js.map +1 -0
  77. package/dist/lib/index.cjs +1 -1
  78. package/dist/lib/index.cjs.map +1 -1
  79. package/dist/lib/index.d.cts +8 -6
  80. package/dist/lib/index.d.ts +8 -6
  81. package/dist/lib/index.js +1 -1
  82. package/dist/lib/index.js.map +1 -1
  83. package/dist/lib/interpolation-plugin-C0dTd69H.cjs +58 -0
  84. package/dist/lib/interpolation-plugin-C0dTd69H.cjs.map +1 -0
  85. package/dist/lib/{interpolation-plugin-Wgb1j4pT.js → interpolation-plugin-K2qE69tF.js} +95 -126
  86. package/dist/lib/interpolation-plugin-K2qE69tF.js.map +1 -0
  87. package/dist/lib/invalidation-CFpNQUv6.cjs +2 -0
  88. package/dist/lib/invalidation-CFpNQUv6.cjs.map +1 -0
  89. package/dist/lib/invalidation-DXhdE9wj.js +64 -0
  90. package/dist/lib/invalidation-DXhdE9wj.js.map +1 -0
  91. package/dist/lib/island/generate-entry.d.cts +40 -2
  92. package/dist/lib/island/generate-entry.d.ts +40 -2
  93. package/dist/lib/island/hydrate.d.cts +19 -6
  94. package/dist/lib/island/hydrate.d.ts +19 -6
  95. package/dist/lib/island/index.cjs +1 -1
  96. package/dist/lib/island/index.d.cts +2 -1
  97. package/dist/lib/island/index.d.ts +2 -1
  98. package/dist/lib/island/index.js +1 -1
  99. package/dist/lib/island/island.d.cts +15 -0
  100. package/dist/lib/island/island.d.ts +15 -0
  101. package/dist/lib/island-CTBnsOhI.js +59 -0
  102. package/dist/lib/island-CTBnsOhI.js.map +1 -0
  103. package/dist/lib/island-kfQEKRe_.cjs +2 -0
  104. package/dist/lib/island-kfQEKRe_.cjs.map +1 -0
  105. package/dist/lib/node-http-DVFcYeBS.js +723 -0
  106. package/dist/lib/node-http-DVFcYeBS.js.map +1 -0
  107. package/dist/lib/node-http-Uct7DhmN.cjs +13 -0
  108. package/dist/lib/node-http-Uct7DhmN.cjs.map +1 -0
  109. package/dist/lib/rolldown-runtime-BIInC43l.cjs +1 -0
  110. package/dist/lib/rolldown-runtime-Dy4uBu1J.js +11 -0
  111. package/dist/lib/router/client.cjs +1 -1
  112. package/dist/lib/router/client.cjs.map +1 -1
  113. package/dist/lib/router/client.d.cts +55 -8
  114. package/dist/lib/router/client.d.ts +55 -8
  115. package/dist/lib/router/client.js +298 -97
  116. package/dist/lib/router/client.js.map +1 -1
  117. package/dist/lib/router/redirects.d.cts +34 -0
  118. package/dist/lib/router/redirects.d.ts +34 -0
  119. package/dist/lib/runtime/handler.d.cts +59 -0
  120. package/dist/lib/runtime/handler.d.ts +59 -0
  121. package/dist/lib/runtime/index.cjs +1 -1
  122. package/dist/lib/runtime/index.d.cts +2 -1
  123. package/dist/lib/runtime/index.d.ts +2 -1
  124. package/dist/lib/runtime/index.js +4 -4
  125. package/dist/lib/runtime/logger.d.cts +47 -0
  126. package/dist/lib/runtime/logger.d.ts +47 -0
  127. package/dist/lib/runtime/node-http.d.cts +11 -1
  128. package/dist/lib/runtime/node-http.d.ts +11 -1
  129. package/dist/lib/runtime-ChE4flX3.cjs +2 -0
  130. package/dist/lib/runtime-ChE4flX3.cjs.map +1 -0
  131. package/dist/lib/runtime-OMyz5yE-.js +905 -0
  132. package/dist/lib/runtime-OMyz5yE-.js.map +1 -0
  133. package/dist/lib/seo/index.cjs +1 -10
  134. package/dist/lib/seo/index.d.cts +1 -0
  135. package/dist/lib/seo/index.d.ts +1 -0
  136. package/dist/lib/seo/index.js +2 -41
  137. package/dist/lib/seo/sitemap-from-routes.d.cts +24 -0
  138. package/dist/lib/seo/sitemap-from-routes.d.ts +24 -0
  139. package/dist/lib/{shared-CfqOLsKj.cjs → shared-CP0Dc3cp.cjs} +72 -47
  140. package/dist/lib/shared-CP0Dc3cp.cjs.map +1 -0
  141. package/dist/lib/{shared-DU3iQgr7.js → shared-D8w-neq2.js} +87 -58
  142. package/dist/lib/shared-D8w-neq2.js.map +1 -0
  143. package/dist/lib/sitemap-from-routes-CaBoiGvE.js +77 -0
  144. package/dist/lib/sitemap-from-routes-CaBoiGvE.js.map +1 -0
  145. package/dist/lib/sitemap-from-routes-DbcLLL4G.cjs +13 -0
  146. package/dist/lib/sitemap-from-routes-DbcLLL4G.cjs.map +1 -0
  147. package/dist/lib/ssr/render.d.cts +8 -2
  148. package/dist/lib/ssr/render.d.ts +8 -2
  149. package/dist/lib/ssr/server.d.cts +7 -0
  150. package/dist/lib/ssr/server.d.ts +7 -0
  151. package/dist/lib/ssr/stream-response.d.cts +30 -0
  152. package/dist/lib/ssr/stream-response.d.ts +30 -0
  153. package/dist/lib/ssr/stream.d.cts +16 -1
  154. package/dist/lib/ssr/stream.d.ts +16 -1
  155. package/dist/lib/{stream-DmZjYqHm.cjs → stream-Cg-xYG82.cjs} +3 -3
  156. package/dist/lib/stream-Cg-xYG82.cjs.map +1 -0
  157. package/dist/lib/stream-D-ytogdu.js +106 -0
  158. package/dist/lib/stream-D-ytogdu.js.map +1 -0
  159. package/dist/lib/vite/index.cjs +1 -1
  160. package/dist/lib/vite/index.cjs.map +1 -1
  161. package/dist/lib/vite/index.d.cts +11 -0
  162. package/dist/lib/vite/index.d.ts +11 -0
  163. package/dist/lib/vite/index.js +101 -94
  164. package/dist/lib/vite/index.js.map +1 -1
  165. package/package.json +26 -9
  166. package/dist/lib/errors-0X7pCFNU.cjs +0 -2
  167. package/dist/lib/hydrate-CJbUWdXu.js +0 -126
  168. package/dist/lib/hydrate-CJbUWdXu.js.map +0 -1
  169. package/dist/lib/hydrate-D6MR24mz.cjs +0 -2
  170. package/dist/lib/hydrate-D6MR24mz.cjs.map +0 -1
  171. package/dist/lib/interpolation-plugin-TWDch8nn.cjs +0 -39
  172. package/dist/lib/interpolation-plugin-TWDch8nn.cjs.map +0 -1
  173. package/dist/lib/interpolation-plugin-Wgb1j4pT.js.map +0 -1
  174. package/dist/lib/node-http-DRAUhO0c.js +0 -617
  175. package/dist/lib/node-http-DRAUhO0c.js.map +0 -1
  176. package/dist/lib/node-http-DhxguYyz.cjs +0 -13
  177. package/dist/lib/node-http-DhxguYyz.cjs.map +0 -1
  178. package/dist/lib/runtime-CrH5X-Es.cjs +0 -2
  179. package/dist/lib/runtime-CrH5X-Es.cjs.map +0 -1
  180. package/dist/lib/runtime-Ds2-wH6P.js +0 -520
  181. package/dist/lib/runtime-Ds2-wH6P.js.map +0 -1
  182. package/dist/lib/seo/index.cjs.map +0 -1
  183. package/dist/lib/seo/index.js.map +0 -1
  184. package/dist/lib/shared-CfqOLsKj.cjs.map +0 -1
  185. package/dist/lib/shared-DU3iQgr7.js.map +0 -1
  186. package/dist/lib/stream-CpOG052V.js +0 -104
  187. package/dist/lib/stream-CpOG052V.js.map +0 -1
  188. package/dist/lib/stream-DmZjYqHm.cjs.map +0 -1
package/dist/lib/cli.cjs CHANGED
@@ -25,10 +25,13 @@ let node_path = require("node:path");
25
25
  let node_fs = require("node:fs");
26
26
  let node_child_process = require("node:child_process");
27
27
  let node_url = require("node:url");
28
+ let node_module = require("node:module");
28
29
  let _elurjs_core_server = require("@elurjs/core/server");
30
+ require("@elurjs/core");
29
31
  let node_crypto = require("node:crypto");
30
- let node_module = require("node:module");
31
32
  let vite = require("vite");
33
+ let node_os = require("node:os");
34
+ let node_async_hooks = require("node:async_hooks");
32
35
  let __image_registry_js = require("./image/registry.js");
33
36
  //#region src/router/route-scanner.ts
34
37
  function isRouteGroup(segment) {
@@ -201,8 +204,35 @@ function toIdentifier(name, index) {
201
204
  const cleaned = name.replace(/[^a-zA-Z0-9_$]/g, "_");
202
205
  return /^[a-zA-Z_$]/.test(cleaned) ? `${cleaned}_${index}` : `_${cleaned}_${index}`;
203
206
  }
207
+ /**
208
+ * Source for the standalone router module emitted in split builds. Router
209
+ * options are baked in so the public module takes no arguments.
210
+ */
211
+ function buildRouterEntrySource(routerImport = "@elurjs/kit/router", options = {}) {
212
+ const opts = {};
213
+ if (options.prefetch === false) opts.prefetch = false;
214
+ if (options.morph === true) opts.morph = true;
215
+ if (options.loadingIndicator === true) opts.loadingIndicator = true;
216
+ const args = Object.keys(opts).length > 0 ? JSON.stringify(opts) : "";
217
+ return `// AUTO-GENERATED by @elurjs/kit. Do not edit.
218
+ import { startClientRouter } from ${JSON.stringify(routerImport)};
219
+
220
+ startClientRouter(${args});
221
+ `;
222
+ }
223
+ /**
224
+ * Serializes the startClientRouter options embedded in a combined entry.
225
+ * Only non-default flags are emitted to keep the generated code minimal.
226
+ */
227
+ function routerCallArgs(router) {
228
+ const opts = {};
229
+ if (router?.prefetch === false) opts.prefetch = false;
230
+ if (router?.morph === true) opts.morph = true;
231
+ if (router?.loadingIndicator === true) opts.loadingIndicator = true;
232
+ return Object.keys(opts).length > 0 ? JSON.stringify(opts) : "";
233
+ }
204
234
  /** Builds the source code of the client entry module. */
205
- function buildEntrySource(islands, outFile, hydrateImport = "@elurjs/kit/island", routerImport = "@elurjs/kit/router") {
235
+ function buildEntrySource(islands, outFile, hydrateImport = "@elurjs/kit/island", routerImport = "@elurjs/kit/router", router) {
206
236
  const registryLines = islands.map((island, i) => ({
207
237
  ident: toIdentifier(island.name, i),
208
238
  name: island.name,
@@ -211,18 +241,36 @@ function buildEntrySource(islands, outFile, hydrateImport = "@elurjs/kit/island"
211
241
  const islandHydration = registryLines ? `const registry = {
212
242
  ${registryLines}
213
243
  };
214
- // Defer hydration until the browser is idle so the first paint is not
215
- // blocked. Islands with directive "load" already have SSR-rendered DOM,
216
- // so the user sees content immediately — hydration only adds interactivity.
217
244
  const hydrate = () => hydrateIslands(registry);
218
- if ("requestIdleCallback" in window) {
219
- requestIdleCallback(hydrate, { timeout: 2000 });
220
- } else {
221
- setTimeout(hydrate, 0);
222
- }
245
+ // Islands inside persisted nodes must not be disposed across navigations.
246
+ // The router announces the survivors in the elur:before-render detail; the
247
+ // DOM query fallback covers hosts that dispatch it without detail.
248
+ const cleanup = (persisted) =>
249
+ cleanupHydratedIslands({
250
+ except: persisted ?? document.querySelectorAll("[data-elur-persist]"),
251
+ });
252
+ let sawBeforeRender = false;
253
+
254
+ // Hydrate right after parse: module scripts are deferred, so this already
255
+ // runs once the DOM is ready. Directives schedule themselves inside
256
+ // hydrateIslands — "load"/"only" run immediately (real Astro semantics),
257
+ // "idle"/"visible" keep their deferred scheduling.
258
+ hydrate();
259
+
260
+ // The router dispatches elur:before-render BEFORE swapping #app: islands are
261
+ // disposed while still attached (Fase A2), except persisted subtrees.
262
+ document.addEventListener("elur:before-render", (event) => {
263
+ sawBeforeRender = true;
264
+ cleanup(event.detail?.persisted);
265
+ });
266
+
267
+ // Re-hydrate after SPA navigations.
223
268
  document.addEventListener("elur:rendered", () => {
224
- cleanupHydratedIslands();
225
- hydrateIslands(registry);
269
+ // Compat fallback: hosts that only dispatch elur:rendered (streaming swap
270
+ // script, hand-rolled integrations) still need the cleanup pass.
271
+ if (!sawBeforeRender) cleanup();
272
+ sawBeforeRender = false;
273
+ hydrate();
226
274
  });
227
275
 
228
276
  // Vite HMR: when an island module (or the entry itself) updates, dispose the
@@ -230,20 +278,15 @@ document.addEventListener("elur:rendered", () => {
230
278
  // dynamic import() resolves to the fresh modules, so no full page reload is
231
279
  // needed (progressive enhancement, audit §10.2 / §12.2).
232
280
  if (import.meta.hot) {
233
- import.meta.hot.accept((newModule) => {
281
+ import.meta.hot.accept(() => {
234
282
  cleanupHydratedIslands();
235
283
  hydrateIslands(registry);
236
- if (newModule) {
237
- // Re-run the module so its side effects (router, listeners) apply.
238
- }
239
284
  });
240
285
  }` : "";
286
+ const embedRouter = router ? router.enabled !== false && !router.separate : true;
241
287
  return `// AUTO-GENERATED by @elurjs/kit. Do not edit.
242
- import { startClientRouter } from ${JSON.stringify(routerImport)};
243
- import { hydrateIslands, cleanupHydratedIslands } from ${JSON.stringify(hydrateImport)};
244
-
245
- startClientRouter();
246
- ${islandHydration}
288
+ ${embedRouter ? `import { startClientRouter } from ${JSON.stringify(routerImport)};\n` : ""}import { hydrateIslands, cleanupHydratedIslands } from ${JSON.stringify(hydrateImport)};
289
+ ${embedRouter ? `\nstartClientRouter(${routerCallArgs(router)});\n` : ""}${islandHydration}
247
290
  `;
248
291
  }
249
292
  /** Computes a POSIX-style relative import specifier between two files. */
@@ -253,15 +296,23 @@ function toImportSpecifier(fromFile, toFile) {
253
296
  return spec;
254
297
  }
255
298
  /**
256
- * Generates and writes the client entry module for the given islands.
299
+ * Generates and writes the client entry module for the given islands. When
300
+ * `options.router.separate` is set (and the router is enabled), a standalone
301
+ * router module is written alongside it (default: `router.ts` next to the
302
+ * entry — bundle it as a second input to emit `/_elur/router.js`).
257
303
  *
258
304
  * @param options Generation options.
259
305
  * @returns The absolute path of the written entry file.
260
306
  */
261
307
  async function generateClientEntry(options) {
262
- const source = buildEntrySource(options.islands, options.outFile, options.hydrateImport, options.routerImport);
308
+ const source = buildEntrySource(options.islands, options.outFile, options.hydrateImport, options.routerImport, options.router);
263
309
  await (0, node_fs_promises.mkdir)((0, node_path.dirname)(options.outFile), { recursive: true });
264
310
  await (0, node_fs_promises.writeFile)(options.outFile, source, "utf8");
311
+ if (options.router) {
312
+ const routerFile = options.router.outFile ?? (0, node_path.join)((0, node_path.dirname)(options.outFile), "router.ts");
313
+ const routerSource = options.router.enabled === false ? `// AUTO-GENERATED by @elurjs/kit. Do not edit.\n// router.enabled: false — intentionally empty.\nexport {};\n` : buildRouterEntrySource(options.routerImport ?? "@elurjs/kit/router", options.router);
314
+ await (0, node_fs_promises.writeFile)(routerFile, routerSource, "utf8");
315
+ }
265
316
  return options.outFile;
266
317
  }
267
318
  //#endregion
@@ -301,6 +352,18 @@ var init_render_to_string = __esmMin((() => {
301
352
  }));
302
353
  //#endregion
303
354
  //#region src/build/document-shell.ts
355
+ /**
356
+ * Extracts the inner HTML of `#app` from a full document produced by
357
+ * `documentShell`. Returns `undefined` when the markers are missing (e.g. a
358
+ * hand-written document).
359
+ */
360
+ function extractAppBody(html) {
361
+ const start = html.indexOf(APP_START_MARKER);
362
+ if (start < 0) return void 0;
363
+ const end = html.indexOf(APP_END_MARKER, start + 21);
364
+ if (end < 0) return void 0;
365
+ return html.slice(start + 21, end);
366
+ }
304
367
  function escapeHtml(value) {
305
368
  return value.replace(/[&<>"']/g, (c) => HTML_ESCAPES[c]);
306
369
  }
@@ -348,12 +411,33 @@ function buildHeadTags(metadata, fallbackTitle) {
348
411
  if (metadata.other) for (const [name, content] of Object.entries(metadata.other)) tags.push(`<meta data-elur-head name="${escapeHtml(name)}" content="${escapeHtml(content)}" />`);
349
412
  return tags.map((t) => `\n ${t}`).join("");
350
413
  }
414
+ /**
415
+ * Document-level Speculation Rules (Chromium-only, ignored elsewhere).
416
+ *
417
+ * `href_matches: "/*"` scopes the rule to same-origin path links; the
418
+ * `selector_matches` exclusions keep downloads, new-tab links, router-opt-outs
419
+ * and explicit `data-no-speculation` links out of speculation. Actions are
420
+ * POST endpoints reached through forms/`callAction`, never through document
421
+ * links, so they are not speculated. `eagerness: "moderate"` speculates on
422
+ * hover — the same trigger as the client router's prefetch.
423
+ */
424
+ function speculationRulesScript(mode) {
425
+ return `\n <script type="speculationrules">${JSON.stringify({ [mode]: [{
426
+ source: "document",
427
+ where: { and: [{ href_matches: "/*" }, { not: { selector_matches: "a[download], a[target], a[data-no-router], a[data-no-speculation]" } }] },
428
+ eagerness: "moderate"
429
+ }] })}<\/script>`;
430
+ }
351
431
  /** Wraps rendered body HTML into a full HTML document. */
352
432
  function documentShell(opts) {
353
- const { body, title = "Elur Kit App", lang = "es", data, actions, clientEntry, htmlAttributes, headScripts, headLinks, metadata } = opts;
433
+ const { body, title = "Elur Kit App", lang = "es", data, actions, clientEntry, routerEntry, htmlAttributes, headScripts, headLinks, metadata } = opts;
354
434
  const dataScript = data !== void 0 ? `\n <script type="application/json" id="elur-data">${serializeData(data)}<\/script>` : "";
355
435
  const actionsScript = actions && Object.keys(actions).length > 0 ? `\n <script type="application/json" id="elur-actions">${serializeData(actions)}<\/script>` : "";
436
+ const modulePreload = (src) => `\n <link rel="modulepreload" href="${escapeHtml(src)}" />`;
437
+ const preloads = (clientEntry ? modulePreload(clientEntry) : "") + (routerEntry ? modulePreload(routerEntry) : "");
356
438
  const entryScript = clientEntry ? `\n <script type="module" src="${escapeHtml(clientEntry)}"><\/script>` : "";
439
+ const routerScript = routerEntry ? `\n <script type="module" src="${escapeHtml(routerEntry)}"><\/script>` : "";
440
+ const speculationScript = opts.speculation ? speculationRulesScript(opts.speculation) : "";
357
441
  const htmlAttrs = htmlAttributes ? Object.entries(htmlAttributes).filter(([, value]) => value !== void 0 && value !== null && value !== "").map(([key, value]) => ` ${escapeHtml(key)}="${escapeHtml(String(value))}"`).join("") : "";
358
442
  const headScriptsHtml = headScripts ? headScripts.filter((script) => typeof script === "string" && script.trim().length > 0).map((script) => {
359
443
  if (script.trimStart().startsWith("<script")) return `\n ${script}`;
@@ -362,20 +446,20 @@ function documentShell(opts) {
362
446
  const headTags = metadata ? buildHeadTags(metadata, title) : "";
363
447
  const titleTag = metadata?.title ? "" : `\n <title>${escapeHtml(title)}</title>`;
364
448
  const headLinksHtml = headLinks ? headLinks.filter((link) => typeof link === "string" && link.trim().length > 0).map((link) => `\n ${link}`).join("") : "";
365
- const renderEndpointMeta = opts.renderEndpoint === false ? "\n <meta name=\"elur:render-endpoint\" content=\"off\" />" : "";
449
+ const renderEndpointMeta = opts.renderEndpoint === false && opts.routerEnabled !== false ? "\n <meta name=\"elur:render-endpoint\" content=\"off\" />" : "";
366
450
  return `<!DOCTYPE html>
367
451
  <html lang="${escapeHtml(lang)}"${htmlAttrs}>
368
452
  <head>
369
453
  <meta charset="utf-8" />
370
- <meta name="viewport" content="width=device-width, initial-scale=1" />${renderEndpointMeta}${titleTag}${headTags}${headLinksHtml}${headScriptsHtml}
454
+ <meta name="viewport" content="width=device-width, initial-scale=1" />${renderEndpointMeta}${titleTag}${headTags}${headLinksHtml}${headScriptsHtml}${preloads}${speculationScript}
371
455
  </head>
372
456
  <body>
373
- <div id="app">${body}</div>${dataScript}${actionsScript}${entryScript}
457
+ <div id="app">${APP_START_MARKER}${body}${APP_END_MARKER}</div>${dataScript}${actionsScript}${entryScript}${routerScript}
374
458
  </body>
375
459
  </html>
376
460
  `;
377
461
  }
378
- var HTML_ESCAPES;
462
+ var HTML_ESCAPES, APP_START_MARKER, APP_END_MARKER;
379
463
  var init_document_shell = __esmMin((() => {
380
464
  HTML_ESCAPES = {
381
465
  "&": "&amp;",
@@ -384,6 +468,14 @@ var init_document_shell = __esmMin((() => {
384
468
  "\"": "&quot;",
385
469
  "'": "&#39;"
386
470
  };
471
+ APP_START_MARKER = "<!--elur:app:start-->";
472
+ APP_END_MARKER = "<!--elur:app:end-->";
473
+ }));
474
+ //#endregion
475
+ //#region src/island/island.ts
476
+ var ISLAND_MARKER_ATTR;
477
+ var init_island = __esmMin((() => {
478
+ ISLAND_MARKER_ATTR = "data-elur-island";
387
479
  }));
388
480
  //#endregion
389
481
  //#region src/action/error-store.ts
@@ -560,7 +652,7 @@ function collectShellExtras(pageData, layoutDataList) {
560
652
  };
561
653
  }
562
654
  async function renderPage(options) {
563
- const { route, params = {}, searchParams = new URLSearchParams(), config, importer = defaultImport$1, actions, request } = options;
655
+ const { route, params = {}, searchParams = new URLSearchParams(), config, importer = defaultImport$2, actions, request } = options;
564
656
  const { default: PageComponent, generateMetadata } = await importer(route.pagePath);
565
657
  let data;
566
658
  let revalidate;
@@ -667,6 +759,16 @@ async function renderPage(options) {
667
759
  });
668
760
  if (!metadata) metadata = extractMetadata(data) ?? extractMetadataFromList(layoutDataList);
669
761
  const resolvedTitle = metadata?.title ?? title;
762
+ const hasIslands = body.includes(ISLAND_MARKER_ATTR);
763
+ const routerCfg = config.router;
764
+ const routerEnabled = routerCfg?.enabled !== false;
765
+ let clientEntry;
766
+ let routerEntry;
767
+ if (!routerCfg || config.js === "legacy") clientEntry = config.clientEntry;
768
+ else if (routerCfg.entry) {
769
+ if (hasIslands) clientEntry = config.clientEntry;
770
+ if (routerEnabled) routerEntry = routerCfg.entry;
771
+ } else if (hasIslands || routerEnabled) clientEntry = config.clientEntry;
670
772
  const html = documentShell({
671
773
  title: resolvedTitle,
672
774
  lang: config.lang,
@@ -677,7 +779,10 @@ async function renderPage(options) {
677
779
  headScripts,
678
780
  headLinks,
679
781
  metadata,
680
- clientEntry: config.clientEntry,
782
+ clientEntry,
783
+ routerEntry,
784
+ routerEnabled: routerCfg ? routerEnabled : void 0,
785
+ speculation: routerCfg?.speculation,
681
786
  renderEndpoint: config.renderEndpoint
682
787
  });
683
788
  const head = metadata ? buildHeadTags(metadata, resolvedTitle) : "";
@@ -687,7 +792,8 @@ async function renderPage(options) {
687
792
  clearActionErrorCookie,
688
793
  head,
689
794
  resolvedTitle,
690
- cachePolicy
795
+ cachePolicy,
796
+ data
691
797
  };
692
798
  }
693
799
  /** Extracts a `metadata` field from a loader data object, if present. */
@@ -725,13 +831,14 @@ async function renderErrorPage(options) {
725
831
  return;
726
832
  }
727
833
  }
728
- var defaultImport$1;
834
+ var defaultImport$2;
729
835
  var init_render = __esmMin((() => {
730
836
  init_render_to_string();
731
837
  init_document_shell();
838
+ init_island();
732
839
  init_error_store();
733
840
  init_policy();
734
- defaultImport$1 = (path) => import(path);
841
+ defaultImport$2 = (path) => import(path);
735
842
  }));
736
843
  //#endregion
737
844
  //#region src/action/scan.ts
@@ -770,6 +877,14 @@ function actionNames(actions) {
770
877
  }
771
878
  var init_scan = __esmMin((() => {}));
772
879
  //#endregion
880
+ //#region __vite-optional-peer-dep:sharp:@elurjs/kit
881
+ var kit_exports = /* @__PURE__ */ __exportAll({ default: () => kit_default });
882
+ var kit_default;
883
+ var init_kit = __esmMin((() => {
884
+ kit_default = {};
885
+ throw new Error(`Could not resolve "sharp" imported by "@elurjs/kit". Is it installed?`);
886
+ }));
887
+ //#endregion
773
888
  //#region src/image/service.ts
774
889
  init_render();
775
890
  init_scan();
@@ -783,7 +898,7 @@ async function loadSharp() {
783
898
  if (sharpLoader === null) return null;
784
899
  if (sharpLoader) return sharpLoader();
785
900
  try {
786
- const sharp = (await import("sharp")).default;
901
+ const sharp = (await Promise.resolve().then(() => (init_kit(), kit_exports))).default;
787
902
  if (typeof sharp !== "function") {
788
903
  sharpLoader = null;
789
904
  return null;
@@ -1041,6 +1156,132 @@ async function runIntegrationHook(integrations, hook, args) {
1041
1156
  }
1042
1157
  }
1043
1158
  //#endregion
1159
+ //#region src/seo/index.ts
1160
+ /**
1161
+ * SEO utilities — sitemap.xml and robots.txt generation.
1162
+ *
1163
+ * @module
1164
+ */
1165
+ /**
1166
+ * Generates a `sitemap.xml` file from a list of URLs.
1167
+ *
1168
+ * @example
1169
+ * ```ts
1170
+ * import { generateSitemap } from "@elurjs/kit/seo";
1171
+ *
1172
+ * await generateSitemap({
1173
+ * siteUrl: "https://elur-kit.dev",
1174
+ * outDir: "./dist",
1175
+ * urls: [
1176
+ * "/",
1177
+ * "/docs/introduction",
1178
+ * { url: "/docs/routing", changefreq: "weekly", priority: 0.8 },
1179
+ * ],
1180
+ * });
1181
+ * ```
1182
+ */
1183
+ async function generateSitemap(config) {
1184
+ const { siteUrl, urls, outDir } = config;
1185
+ const base = siteUrl.replace(/\/$/, "");
1186
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
1187
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
1188
+ ${urls.map((entry) => {
1189
+ const e = typeof entry === "string" ? { url: entry } : entry;
1190
+ const lines = [` <url>`, ` <loc>${escapeXml$1(`${base}${e.url.startsWith("/") ? "" : "/"}${e.url}`)}</loc>`];
1191
+ if (e.lastmod) lines.push(` <lastmod>${e.lastmod}</lastmod>`);
1192
+ if (e.changefreq) lines.push(` <changefreq>${e.changefreq}</changefreq>`);
1193
+ if (e.priority !== void 0) lines.push(` <priority>${e.priority.toFixed(1)}</priority>`);
1194
+ lines.push(` </url>`);
1195
+ return lines.join("\n");
1196
+ }).join("\n")}
1197
+ </urlset>
1198
+ `;
1199
+ const filePath = (0, node_path.join)(outDir, "sitemap.xml");
1200
+ await (0, node_fs_promises.mkdir)((0, node_path.dirname)(filePath), { recursive: true });
1201
+ await (0, node_fs_promises.writeFile)(filePath, xml, "utf8");
1202
+ return filePath;
1203
+ }
1204
+ function escapeXml$1(str) {
1205
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
1206
+ }
1207
+ //#endregion
1208
+ //#region src/seo/sitemap-from-routes.ts
1209
+ /**
1210
+ * Generates a sitemap.xml from the route manifest.
1211
+ *
1212
+ * Static routes are included automatically. Dynamic routes require the author
1213
+ * to provide URLs via `extraUrls` or a `generateSitemapUrls()` export.
1214
+ *
1215
+ * For large sites (>50,000 URLs), a sitemap index is generated.
1216
+ */
1217
+ async function generateSitemapFromRoutes(options) {
1218
+ const { siteUrl, outDir, routes, extraUrls = [], maxUrlsPerSitemap = 5e4 } = options;
1219
+ const routeUrls = [];
1220
+ for (const page of routes.pages) {
1221
+ if (page.params.length > 0) continue;
1222
+ if (page.path === "/404" || page.path === "/500") continue;
1223
+ if (page.path.startsWith("/_elur") || page.path.startsWith("/__elur-js")) continue;
1224
+ routeUrls.push({
1225
+ url: page.path,
1226
+ changefreq: options.defaultChangefreq,
1227
+ priority: options.defaultPriority
1228
+ });
1229
+ }
1230
+ const allUrls = [...routeUrls, ...extraUrls];
1231
+ if (allUrls.length <= maxUrlsPerSitemap) return [await generateSitemap({
1232
+ siteUrl,
1233
+ outDir,
1234
+ urls: allUrls
1235
+ })];
1236
+ return generateSitemapIndex({
1237
+ siteUrl,
1238
+ outDir,
1239
+ urls: allUrls,
1240
+ maxUrlsPerSitemap
1241
+ });
1242
+ }
1243
+ /**
1244
+ * Generates a sitemap index file that references multiple sitemap files.
1245
+ * Used for large sites (>50,000 URLs).
1246
+ */
1247
+ async function generateSitemapIndex(options) {
1248
+ const { siteUrl, outDir, urls, maxUrlsPerSitemap } = options;
1249
+ const base = siteUrl.replace(/\/$/, "");
1250
+ const files = [];
1251
+ const sitemapUrls = [];
1252
+ for (let i = 0; i < urls.length; i += maxUrlsPerSitemap) {
1253
+ const chunk = urls.slice(i, i + maxUrlsPerSitemap);
1254
+ const filename = `sitemap-${Math.floor(i / maxUrlsPerSitemap) + 1}.xml`;
1255
+ const { writeFile, mkdir } = await import("node:fs/promises");
1256
+ const { join, dirname } = await import("node:path");
1257
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${chunk.map((entry) => {
1258
+ const e = typeof entry === "string" ? { url: entry } : entry;
1259
+ const lines = [` <url>`, ` <loc>${escapeXml(`${base}${e.url.startsWith("/") ? "" : "/"}${e.url}`)}</loc>`];
1260
+ if (e.lastmod) lines.push(` <lastmod>${e.lastmod}</lastmod>`);
1261
+ if (e.changefreq) lines.push(` <changefreq>${e.changefreq}</changefreq>`);
1262
+ if (e.priority !== void 0) lines.push(` <priority>${e.priority.toFixed(1)}</priority>`);
1263
+ lines.push(` </url>`);
1264
+ return lines.join("\n");
1265
+ }).join("\n")}\n</urlset>\n`;
1266
+ const filePath = join(outDir, filename);
1267
+ await mkdir(dirname(filePath), { recursive: true });
1268
+ await writeFile(filePath, xml, "utf8");
1269
+ files.push(filePath);
1270
+ sitemapUrls.push(`${base}/${filename}`);
1271
+ }
1272
+ const { writeFile, mkdir } = await import("node:fs/promises");
1273
+ const { join, dirname } = await import("node:path");
1274
+ const indexXml = `<?xml version="1.0" encoding="UTF-8"?>\n<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${sitemapUrls.map((url) => ` <sitemap>\n <loc>${escapeXml(url)}</loc>\n </sitemap>`).join("\n")}\n</sitemapindex>\n`;
1275
+ const indexPath = join(outDir, "sitemap.xml");
1276
+ await mkdir(dirname(indexPath), { recursive: true });
1277
+ await writeFile(indexPath, indexXml, "utf8");
1278
+ files.push(indexPath);
1279
+ return files;
1280
+ }
1281
+ function escapeXml(str) {
1282
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
1283
+ }
1284
+ //#endregion
1044
1285
  //#region src/build/build.ts
1045
1286
  init_route_scanner();
1046
1287
  function urlToFilePath(outDir, urlPath) {
@@ -1077,8 +1318,14 @@ async function build$3(config) {
1077
1318
  } catch (error) {
1078
1319
  if (error.code !== "ENOENT") throw error;
1079
1320
  }
1321
+ const reportPhase = (name, start) => {
1322
+ config.onPhase?.(name, performance.now() - start);
1323
+ };
1324
+ let phaseStart = performance.now();
1080
1325
  const routes = await scanRoutes(config.appDir);
1081
- const publicActions = actionNames(await scanActions(config.appDir));
1326
+ const actions = await scanActions(config.appDir);
1327
+ reportPhase("scan", phaseStart);
1328
+ const publicActions = actionNames(actions);
1082
1329
  const result = {
1083
1330
  pages: 0,
1084
1331
  skipped: [],
@@ -1092,8 +1339,17 @@ async function build$3(config) {
1092
1339
  islands: result.islands,
1093
1340
  outFile: config.generatedEntry,
1094
1341
  hydrateImport: config.hydrateImport,
1095
- routerImport: config.routerImport
1342
+ routerImport: config.routerImport,
1343
+ router: config.router ? {
1344
+ enabled: config.router.enabled !== false,
1345
+ prefetch: config.router.prefetch,
1346
+ morph: config.router.morph,
1347
+ loadingIndicator: config.router.loadingIndicator,
1348
+ separate: config.router.separate === true && config.js !== "legacy",
1349
+ outFile: config.router.outFile
1350
+ } : void 0
1096
1351
  });
1352
+ phaseStart = performance.now();
1097
1353
  for (const route of routes.pages) {
1098
1354
  if (!isDynamic(route.path)) {
1099
1355
  const filePath = await buildPage(config, route, publicActions);
@@ -1111,7 +1367,9 @@ async function build$3(config) {
1111
1367
  const errorConfig = {
1112
1368
  lang: config.lang,
1113
1369
  clientEntry: config.clientEntry,
1114
- renderEndpoint: false
1370
+ renderEndpoint: false,
1371
+ router: pageRouterConfig(config),
1372
+ js: config.js
1115
1373
  };
1116
1374
  if (routes.error404) {
1117
1375
  const result404 = await renderErrorPage({
@@ -1141,9 +1399,11 @@ async function build$3(config) {
1141
1399
  result.files.push(filePath);
1142
1400
  }
1143
1401
  }
1402
+ reportPhase("pages", phaseStart);
1144
1403
  const registeredImages = (0, __image_registry_js.consumeImageRegistry)();
1145
1404
  let manifest = null;
1146
1405
  if (registeredImages.length > 0 && config.publicDir) {
1406
+ phaseStart = performance.now();
1147
1407
  const manifestPath = (0, node_path.join)(config.outDir, ".elur", "image-manifest.json");
1148
1408
  const processResult = await processImageBatch(registeredImages, {
1149
1409
  publicDir: config.publicDir,
@@ -1200,12 +1460,33 @@ async function build$3(config) {
1200
1460
  }
1201
1461
  }
1202
1462
  }
1463
+ reportPhase("images", phaseStart);
1203
1464
  }
1204
1465
  (0, __image_registry_js.setImageManifest)(null);
1205
- if (config.integrations && config.integrations.length > 0) await runIntegrationHook(config.integrations, "build", [result, {
1206
- root: config.root ?? config.outDir,
1207
- command: "build"
1208
- }]);
1466
+ if (config.integrations && config.integrations.length > 0) {
1467
+ phaseStart = performance.now();
1468
+ await runIntegrationHook(config.integrations, "build", [result, {
1469
+ root: config.root ?? config.outDir,
1470
+ command: "build"
1471
+ }]);
1472
+ reportPhase("integrations", phaseStart);
1473
+ }
1474
+ if (config.site) {
1475
+ phaseStart = performance.now();
1476
+ let sitemapExists = false;
1477
+ try {
1478
+ sitemapExists = (await (0, node_fs_promises.stat)((0, node_path.join)(config.outDir, "sitemap.xml"))).isFile();
1479
+ } catch {}
1480
+ if (!sitemapExists) {
1481
+ const sitemapFiles = await generateSitemapFromRoutes({
1482
+ siteUrl: config.site,
1483
+ outDir: config.outDir,
1484
+ routes
1485
+ });
1486
+ result.files.push(...sitemapFiles);
1487
+ }
1488
+ reportPhase("sitemap", phaseStart);
1489
+ }
1209
1490
  return result;
1210
1491
  }
1211
1492
  async function buildPage(config, route, actions) {
@@ -1228,7 +1509,9 @@ async function buildConcretePage(config, route, params, actions) {
1228
1509
  config: {
1229
1510
  lang: config.lang,
1230
1511
  clientEntry: config.clientEntry,
1231
- renderEndpoint: false
1512
+ renderEndpoint: false,
1513
+ router: pageRouterConfig(config),
1514
+ js: config.js
1232
1515
  },
1233
1516
  actions
1234
1517
  });
@@ -1238,6 +1521,20 @@ async function buildConcretePage(config, route, params, actions) {
1238
1521
  await (0, node_fs_promises.writeFile)(filePath, htmlOut, "utf8");
1239
1522
  return filePath;
1240
1523
  }
1524
+ /**
1525
+ * The router slice of the per-page render config: `enabled` gates script/meta
1526
+ * emission, `entry` is advertised only for split bundles, and `speculation`
1527
+ * produces the Speculation Rules block on static pages.
1528
+ */
1529
+ function pageRouterConfig(config) {
1530
+ if (!config.router) return void 0;
1531
+ const separate = config.router.separate === true && config.js !== "legacy";
1532
+ return {
1533
+ enabled: config.router.enabled !== false,
1534
+ entry: separate ? config.router.entry ?? "/_elur/router.js" : void 0,
1535
+ speculation: config.router.speculation
1536
+ };
1537
+ }
1241
1538
  //#endregion
1242
1539
  //#region src/vite/interpolation-plugin.ts
1243
1540
  function warnLegacyOnce() {
@@ -1792,402 +2089,830 @@ async function transformProjectFiles(options) {
1792
2089
  }
1793
2090
  }
1794
2091
  //#endregion
1795
- //#region src/errors.ts
1796
- /**
1797
- * Type guard for action failures. Uses the marker field so it works across
1798
- * bundling boundaries where `instanceof` fails.
1799
- */
1800
- function isActionFailure(value) {
1801
- return typeof value === "object" && value !== null && value.__elur_js_action_failure === true;
1802
- }
1803
- /**
1804
- * Type guard for redirects. Uses the marker field so it works across bundling
1805
- * boundaries where `instanceof` fails.
1806
- */
1807
- function isRedirectResponse(value) {
1808
- return typeof value === "object" && value !== null && value.__elur_js_action_redirect === true;
1809
- }
1810
- /**
1811
- * Maps an arbitrary thrown value to a public-safe error info. By default the
1812
- * public message is generic; `includeDetail` (dev/verbose mode) appends the
1813
- * original `Error.message` for local debugging.
1814
- */
1815
- function toPublicErrorInfo(error, options = {}) {
1816
- const code = "INTERNAL_SERVER_ERROR";
1817
- const status = 500;
1818
- if (options.includeDetail && error instanceof Error && error.message) return {
1819
- code,
1820
- status,
1821
- message: error.message
1822
- };
1823
- return {
1824
- code,
1825
- status,
1826
- message: "Internal Server Error"
2092
+ //#region src/runtime/node-http.ts
2093
+ var GlobalAbortController = globalThis.AbortController ?? AbortController;
2094
+ function incomingMessageToRequest(req, body) {
2095
+ const headers = new Headers();
2096
+ for (let index = 0; index < req.rawHeaders.length; index += 2) headers.append(req.rawHeaders[index], req.rawHeaders[index + 1]);
2097
+ const controller = new GlobalAbortController();
2098
+ req.once("aborted", () => controller.abort());
2099
+ req.once("close", () => {
2100
+ if (!req.complete) controller.abort();
2101
+ });
2102
+ const protocol = req.socket.encrypted ? "https" : "http";
2103
+ const init = {
2104
+ method: req.method ?? "GET",
2105
+ headers,
2106
+ signal: controller.signal
1827
2107
  };
2108
+ if (body !== void 0 && body !== null && init.method !== "GET" && init.method !== "HEAD") init.body = body;
2109
+ return new Request(`${protocol}://${headers.get("host") ?? "localhost"}${req.url ?? "/"}`, init);
1828
2110
  }
1829
2111
  /**
1830
- * Builds a production-safe JSON error Response. Logs the raw error separately
1831
- * (never reflected in the response body) and keeps the request id header.
2112
+ * Writes a Web `Response` to a Node `ServerResponse`, streaming the body.
2113
+ *
2114
+ * Unlike `res.end(Buffer.from(await response.arrayBuffer()))`, this forwards
2115
+ * chunks as they are produced — required for streaming SSR responses to reach
2116
+ * the client progressively. Buffered (non-stream) bodies behave exactly as
2117
+ * before. Honors backpressure (`drain`) and cancels the upstream stream when
2118
+ * the client disconnects (`close` before `finish`).
1832
2119
  */
1833
- function publicErrorResponse(error, options = {}) {
1834
- const info = toPublicErrorInfo(error, options);
1835
- const headers = {
1836
- "Content-Type": "application/json; charset=utf-8",
1837
- "Cache-Control": "no-store"
2120
+ async function sendWebResponse(res, response) {
2121
+ const headers = Object.fromEntries(response.headers.entries());
2122
+ if (response.body !== null) {
2123
+ delete headers["content-length"];
2124
+ delete headers["transfer-encoding"];
2125
+ }
2126
+ res.writeHead(response.status, headers);
2127
+ const body = response.body;
2128
+ if (!body) {
2129
+ res.end();
2130
+ return;
2131
+ }
2132
+ const reader = body.getReader();
2133
+ let done = false;
2134
+ const onClose = () => {
2135
+ if (!done) reader.cancel().catch(() => {});
1838
2136
  };
1839
- if (options.requestId) headers["X-Request-Id"] = options.requestId;
1840
- return new Response(JSON.stringify({ error: info }), {
1841
- status: info.status,
1842
- headers
1843
- });
2137
+ res.once("close", onClose);
2138
+ try {
2139
+ for (;;) {
2140
+ const { done: readDone, value } = await reader.read();
2141
+ if (readDone) break;
2142
+ if (value && value.byteLength > 0 && !res.write(value)) await new Promise((resolveDrain) => res.once("drain", resolveDrain));
2143
+ }
2144
+ done = true;
2145
+ res.end();
2146
+ } catch {
2147
+ done = true;
2148
+ res.destroy();
2149
+ } finally {
2150
+ res.removeListener("close", onClose);
2151
+ }
1844
2152
  }
1845
- var init_errors = __esmMin((() => {}));
1846
2153
  //#endregion
1847
- //#region src/action/origin.ts
2154
+ //#region src/runtime/logger.ts
1848
2155
  /**
1849
- * Returns the host:port of a URL string, or undefined if it cannot be parsed.
2156
+ * Redacts sensitive fields from a log fields object.
2157
+ * Recursively redacts keys that match sensitive header names.
1850
2158
  */
1851
- function originOf(urlString) {
1852
- if (!urlString) return void 0;
1853
- try {
1854
- const url = new URL(urlString);
1855
- if (url.protocol !== "http:" && url.protocol !== "https:") return void 0;
1856
- return url.origin;
1857
- } catch {
1858
- return;
2159
+ function redactSensitive(fields) {
2160
+ const result = {};
2161
+ for (const [key, value] of Object.entries(fields)) {
2162
+ const lowerKey = key.toLowerCase();
2163
+ if (SENSITIVE_HEADERS.has(lowerKey)) result[key] = "[REDACTED]";
2164
+ else if (typeof value === "object" && value !== null && !Array.isArray(value)) result[key] = redactSensitive(value);
2165
+ else result[key] = value;
1859
2166
  }
2167
+ return result;
1860
2168
  }
1861
2169
  /**
1862
- * Verifies that a request originates from the same host (or an allow-listed
1863
- * origin). Returns an error message when the request must be rejected, or
1864
- * undefined when it is allowed.
1865
- *
1866
- * @param request The incoming Request to actions.
1867
- * @param options Origin check configuration.
2170
+ * Creates a logger for a request, optionally using the request's
2171
+ * X-Request-ID header if present.
1868
2172
  */
1869
- function verifyOrigin(request, options = {}) {
1870
- const targetOrigin = originOf(request.url);
1871
- if (!targetOrigin) return "Invalid target URL";
1872
- const origin = request.headers.get("Origin");
1873
- const referer = request.headers.get("Referer");
1874
- if (!origin && !referer) return options.strictOrigin ? "Missing Origin and Referer headers" : void 0;
1875
- const sourceOrigin = origin ? originOf(origin) : originOf(referer);
1876
- if (!sourceOrigin) return origin ? "Invalid Origin header" : "Invalid Referer header";
1877
- if (sourceOrigin === targetOrigin) return void 0;
1878
- if (options.allowedOrigins?.some((allowed) => originOf(allowed) === sourceOrigin)) return void 0;
1879
- return `Cross-origin request blocked: source "${sourceOrigin}" != target "${targetOrigin}"`;
1880
- }
1881
- /** Builds a 403 Response for a rejected origin. */
1882
- function originForbidden(message) {
1883
- return new Response(message, {
1884
- status: 403,
1885
- headers: { "Content-Type": "text/plain; charset=utf-8" }
2173
+ function createRequestLogger(request, minLevel) {
2174
+ const requestId = request?.headers.get("X-Request-ID") ?? void 0;
2175
+ return new StructuredLogger({
2176
+ minLevel,
2177
+ requestId
1886
2178
  });
1887
2179
  }
1888
- var init_origin = __esmMin((() => {}));
1889
- //#endregion
1890
- //#region src/action/server.ts
1891
- /**
1892
- * Reads the request body as text, enforcing a maximum size.
1893
- * Returns a 413 response if the body exceeds the limit.
1894
- */
1895
- async function readBodyWithLimit(request, limit) {
1896
- const contentLength = request.headers.get("Content-Length");
1897
- if (contentLength && parseInt(contentLength, 10) > limit) return {
1898
- ok: false,
1899
- response: new Response("Request body too large", {
1900
- status: 413,
1901
- headers: { "Content-Type": "text/plain" }
1902
- })
1903
- };
1904
- const reader = request.body?.getReader();
1905
- if (!reader) return {
1906
- ok: true,
1907
- text: ""
2180
+ var LEVEL_PRIORITY, SENSITIVE_HEADERS, StructuredLogger;
2181
+ var init_logger = __esmMin((() => {
2182
+ LEVEL_PRIORITY = {
2183
+ debug: 10,
2184
+ info: 20,
2185
+ warn: 30,
2186
+ error: 40
1908
2187
  };
1909
- const chunks = [];
1910
- let totalSize = 0;
1911
- try {
1912
- for (;;) {
1913
- const { done, value } = await reader.read();
1914
- if (done) break;
1915
- totalSize += value.byteLength;
1916
- if (totalSize > limit) {
1917
- try {
1918
- reader.cancel();
1919
- } catch {}
1920
- return {
1921
- ok: false,
1922
- response: new Response("Request body too large", {
1923
- status: 413,
1924
- headers: { "Content-Type": "text/plain" }
1925
- })
1926
- };
2188
+ SENSITIVE_HEADERS = /* @__PURE__ */ new Set([
2189
+ "cookie",
2190
+ "authorization",
2191
+ "set-cookie",
2192
+ "x-api-key",
2193
+ "x-auth-token"
2194
+ ]);
2195
+ StructuredLogger = class {
2196
+ minLevel;
2197
+ requestId;
2198
+ timings = [];
2199
+ entries = [];
2200
+ constructor(options = {}) {
2201
+ this.minLevel = options.minLevel ?? (process.env.NODE_ENV === "production" ? "info" : "debug");
2202
+ this.requestId = options.requestId ?? (0, node_crypto.randomUUID)();
2203
+ }
2204
+ /** Returns the request ID for this logger instance. */
2205
+ getRequestId() {
2206
+ return this.requestId;
2207
+ }
2208
+ /** Logs a debug message. */
2209
+ debug(message, fields) {
2210
+ this.log("debug", message, fields);
2211
+ }
2212
+ /** Logs an info message. */
2213
+ info(message, fields) {
2214
+ this.log("info", message, fields);
2215
+ }
2216
+ /** Logs a warning. */
2217
+ warn(message, fields) {
2218
+ this.log("warn", message, fields);
2219
+ }
2220
+ /** Logs an error. */
2221
+ error(message, fields) {
2222
+ this.log("error", message, fields);
2223
+ }
2224
+ /** Records a Server-Timing metric. */
2225
+ timing(name, durationMs, description) {
2226
+ this.timings.push({
2227
+ name,
2228
+ durationMs,
2229
+ description
2230
+ });
2231
+ }
2232
+ /** Starts a timer and returns a function to stop it and record the timing. */
2233
+ startTimer(name, description) {
2234
+ const start = performance.now();
2235
+ return () => {
2236
+ this.timing(name, performance.now() - start, description);
2237
+ };
2238
+ }
2239
+ /** Returns the Server-Timing header value. */
2240
+ getServerTimingHeader() {
2241
+ return this.timings.map((t) => {
2242
+ const desc = t.description ? `;desc="${t.description}"` : "";
2243
+ return `${t.name};dur=${t.durationMs.toFixed(1)}${desc}`;
2244
+ }).join(", ");
2245
+ }
2246
+ /** Returns all log entries collected so far. */
2247
+ getEntries() {
2248
+ return this.entries;
2249
+ }
2250
+ log(level, message, fields) {
2251
+ if (LEVEL_PRIORITY[level] < LEVEL_PRIORITY[this.minLevel]) return;
2252
+ const entry = {
2253
+ level,
2254
+ message,
2255
+ requestId: this.requestId,
2256
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2257
+ fields: fields ? redactSensitive(fields) : void 0
2258
+ };
2259
+ this.entries.push(entry);
2260
+ if (process.env.NODE_ENV === "production") {
2261
+ const output = JSON.stringify(entry);
2262
+ if (level === "error") console.error(output);
2263
+ else if (level === "warn") console.warn(output);
2264
+ else console.log(output);
2265
+ } else {
2266
+ const output = `${`[${level.toUpperCase()}]`} ${message}${entry.fields ? " " + JSON.stringify(entry.fields) : ""}`;
2267
+ if (level === "error") console.error(output);
2268
+ else if (level === "warn") console.warn(output);
2269
+ else console.log(output);
1927
2270
  }
1928
- chunks.push(value);
1929
2271
  }
1930
- } finally {
1931
- try {
1932
- reader.releaseLock();
1933
- } catch {}
1934
- }
1935
- const total = new Uint8Array(totalSize);
1936
- let offset = 0;
1937
- for (const chunk of chunks) {
1938
- total.set(chunk, offset);
1939
- offset += chunk.byteLength;
1940
- }
1941
- return {
1942
- ok: true,
1943
- text: new TextDecoder().decode(total)
1944
2272
  };
2273
+ }));
2274
+ //#endregion
2275
+ //#region src/config/index.ts
2276
+ init_logger();
2277
+ async function loadElurConfig(options = {}) {
2278
+ const initialRoot = (0, node_path.resolve)(options.root ?? process.cwd());
2279
+ const configFile = options.configFile ? (0, node_path.resolve)(initialRoot, options.configFile) : await findConfigFile(initialRoot);
2280
+ let loaded = {};
2281
+ if (configFile) {
2282
+ const result = await (0, vite.loadConfigFromFile)({
2283
+ command: options.command === "build" ? "build" : "serve",
2284
+ mode: options.mode ?? "development"
2285
+ }, configFile, initialRoot);
2286
+ if (!result) throw new Error(`[elur-kit] Could not load config: ${configFile}`);
2287
+ loaded = result.config;
2288
+ }
2289
+ const merged = mergeConfig(loaded, options.overrides ?? {});
2290
+ const root = (0, node_path.resolve)(initialRoot, merged.root ?? ".");
2291
+ const resolved = resolveConfig(root, merged, configFile);
2292
+ await runIntegrationHook(resolved.integrations, "config", [resolved, {
2293
+ root,
2294
+ command: options.command ?? "dev"
2295
+ }]);
2296
+ return resolved;
1945
2297
  }
1946
- function parseFormBody(body) {
1947
- const params = new URLSearchParams(body);
1948
- const result = {};
1949
- for (const [key, value] of params) if (result[key] === void 0) result[key] = value;
1950
- else if (Array.isArray(result[key])) result[key].push(value);
1951
- else result[key] = [result[key], value];
1952
- return result;
2298
+ function resolveConfig(root, config, configFile) {
2299
+ if (config.site) new URL(config.site);
2300
+ const base = normalizeBase(config.base ?? "/");
2301
+ const imageQuality = config.images?.quality ?? 80;
2302
+ if (!Number.isFinite(imageQuality) || imageQuality < 1 || imageQuality > 100) throw new Error("[elur-kit] images.quality must be between 1 and 100");
2303
+ return {
2304
+ root,
2305
+ appDir: resolveInside(root, config.appDir ?? "src/app", "appDir"),
2306
+ islandsDir: resolveInside(root, config.islandsDir ?? "src/islands", "islandsDir"),
2307
+ contentDir: resolveInside(root, config.contentDir ?? "src/content", "contentDir"),
2308
+ publicDir: resolveInside(root, config.publicDir ?? "public", "publicDir"),
2309
+ outDir: resolveInside(root, config.outDir ?? "dist", "outDir"),
2310
+ site: config.site,
2311
+ base,
2312
+ trailingSlash: config.trailingSlash ?? "ignore",
2313
+ output: config.output ?? "static",
2314
+ adapter: config.adapter,
2315
+ images: {
2316
+ formats: config.images?.formats ?? ["webp", "avif"],
2317
+ quality: imageQuality,
2318
+ strict: config.images?.strict ?? false
2319
+ },
2320
+ cache: {
2321
+ dir: resolveInside(root, config.cache?.dir ?? ".elur/cache", "cache.dir"),
2322
+ defaultRevalidate: config.cache?.defaultRevalidate,
2323
+ adapter: config.cache?.adapter
2324
+ },
2325
+ security: {
2326
+ allowedOrigins: config.security?.allowedOrigins ?? [],
2327
+ strictOrigin: config.security?.strictOrigin ?? false,
2328
+ bodyLimit: config.security?.bodyLimit ?? 1048576,
2329
+ headers: config.security?.headers === false ? false : config.security?.headers ?? {}
2330
+ },
2331
+ router: {
2332
+ enabled: config.router?.enabled ?? true,
2333
+ prefetch: config.router?.prefetch ?? true,
2334
+ morph: config.router?.morph ?? false,
2335
+ speculation: config.router?.speculation,
2336
+ loadingIndicator: config.router?.loadingIndicator ?? false
2337
+ },
2338
+ js: config.js ?? "modern",
2339
+ logger: { level: config.logger?.level },
2340
+ redirects: config.redirects ?? [],
2341
+ rewrites: config.rewrites ?? [],
2342
+ streaming: config.streaming ?? false,
2343
+ headers: config.headers ?? [],
2344
+ integrations: config.integrations ?? [],
2345
+ configFile
2346
+ };
1953
2347
  }
1954
- async function parseActionRequest(request, bodyLimit = DEFAULT_BODY_LIMIT) {
1955
- if (request.method !== "POST") return {
1956
- ok: false,
1957
- response: new Response("Method not allowed", {
1958
- status: 405,
1959
- headers: { "Content-Type": "text/plain" }
1960
- })
2348
+ function mergeConfig(base, override) {
2349
+ return {
2350
+ ...base,
2351
+ ...override,
2352
+ images: {
2353
+ ...base.images,
2354
+ ...override.images
2355
+ },
2356
+ cache: {
2357
+ ...base.cache,
2358
+ ...override.cache
2359
+ },
2360
+ security: {
2361
+ ...base.security,
2362
+ ...override.security
2363
+ },
2364
+ router: {
2365
+ ...base.router,
2366
+ ...override.router
2367
+ },
2368
+ logger: {
2369
+ ...base.logger,
2370
+ ...override.logger
2371
+ },
2372
+ redirects: [...override.redirects ?? [], ...base.redirects ?? []],
2373
+ rewrites: [...override.rewrites ?? [], ...base.rewrites ?? []],
2374
+ headers: [...override.headers ?? [], ...base.headers ?? []],
2375
+ integrations: override.integrations ?? base.integrations
1961
2376
  };
1962
- const contentType = request.headers.get("Content-Type") ?? "";
1963
- const wantsJson = (request.headers.get("Accept") ?? "").includes("application/json");
1964
- let name;
1965
- let page;
1966
- let args = [];
1967
- if (contentType.includes("application/json")) {
1968
- const bodyResult = await readBodyWithLimit(request, bodyLimit);
1969
- if (!bodyResult.ok) return {
1970
- ok: false,
1971
- response: bodyResult.response
1972
- };
1973
- let body;
2377
+ }
2378
+ function resolveInside(root, path, name) {
2379
+ const resolved = (0, node_path.isAbsolute)(path) ? (0, node_path.resolve)(path) : (0, node_path.resolve)(root, path);
2380
+ const rel = (0, node_path.relative)(root, resolved);
2381
+ if (rel === ".." || rel.startsWith(`..${node_path.sep}`) || (0, node_path.isAbsolute)(rel)) throw new Error(`[elur-kit] ${name} must stay inside root: ${resolved}`);
2382
+ return resolved;
2383
+ }
2384
+ function normalizeBase(base) {
2385
+ if (!base.startsWith("/")) throw new Error("[elur-kit] base must start with /");
2386
+ return base === "/" ? base : `${base.replace(/\/+$/, "")}/`;
2387
+ }
2388
+ var PREFERRED_CONFIG_FILES = [
2389
+ "elur.config.ts",
2390
+ "elur.config.js",
2391
+ "elur.config.mjs"
2392
+ ];
2393
+ async function findConfigFile(root) {
2394
+ for (const name of PREFERRED_CONFIG_FILES) {
2395
+ const path = (0, node_path.resolve)(root, name);
1974
2396
  try {
1975
- body = JSON.parse(bodyResult.text);
1976
- } catch {
1977
- return {
1978
- ok: false,
1979
- response: new Response("Invalid JSON body", {
1980
- status: 400,
1981
- headers: { "Content-Type": "text/plain" }
1982
- })
1983
- };
1984
- }
1985
- name = body.name;
1986
- page = body.page;
1987
- args = Array.isArray(body.args) ? body.args : [];
1988
- } else if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
1989
- if (contentType.includes("multipart/form-data")) {
1990
- const contentLength = request.headers.get("Content-Length");
1991
- if (contentLength && parseInt(contentLength, 10) > bodyLimit) return {
1992
- ok: false,
1993
- response: new Response("Request body too large", {
1994
- status: 413,
1995
- headers: { "Content-Type": "text/plain" }
1996
- })
1997
- };
1998
- let form;
1999
- try {
2000
- form = await request.formData();
2001
- } catch {
2002
- return {
2003
- ok: false,
2004
- response: new Response("Invalid form body", {
2005
- status: 400,
2006
- headers: { "Content-Type": "text/plain" }
2007
- })
2008
- };
2009
- }
2010
- name = form.get("__elur_js_action_name") ?? void 0;
2011
- page = form.get("__elur_js_action_page") ?? void 0;
2012
- const input = {};
2013
- for (const [key, value] of form) {
2014
- if (key === "__elur_js_action_name" || key === "__elur_js_action_page") continue;
2015
- input[key] = value;
2016
- }
2017
- args = [input];
2018
- } else {
2019
- const bodyResult = await readBodyWithLimit(request, bodyLimit);
2020
- if (!bodyResult.ok) return {
2021
- ok: false,
2022
- response: bodyResult.response
2023
- };
2024
- const form = parseFormBody(bodyResult.text);
2025
- name = form.__elur_js_action_name;
2026
- page = form.__elur_js_action_page;
2027
- const input = {};
2028
- for (const [key, value] of Object.entries(form)) {
2029
- if (key === "__elur_js_action_name" || key === "__elur_js_action_page") continue;
2030
- input[key] = value;
2397
+ await (0, node_fs_promises.access)(path);
2398
+ return path;
2399
+ } catch {}
2400
+ }
2401
+ }
2402
+ //#endregion
2403
+ //#region src/manifest/index.ts
2404
+ init_scan();
2405
+ init_route_scanner();
2406
+ async function createAppManifest(config) {
2407
+ const [routes, actions, islands] = await Promise.all([
2408
+ scanRoutes(config.appDir),
2409
+ scanActions(config.appDir),
2410
+ scanIslands(config.islandsDir)
2411
+ ]);
2412
+ validateManifestRoutes(routes);
2413
+ validateIslands(islands);
2414
+ const manifest = {
2415
+ version: 1,
2416
+ root: config.root,
2417
+ routes,
2418
+ actions,
2419
+ islands,
2420
+ base: config.base,
2421
+ output: config.output
2422
+ };
2423
+ await runIntegrationHook(config.integrations, "routes", [manifest, {
2424
+ root: config.root,
2425
+ command: "build"
2426
+ }]);
2427
+ return manifest;
2428
+ }
2429
+ async function writeAppManifest(manifest, path) {
2430
+ await (0, node_fs_promises.mkdir)((0, node_path.dirname)(path), { recursive: true });
2431
+ await (0, node_fs_promises.writeFile)(path, JSON.stringify(toPortableManifest(manifest), null, 2), "utf8");
2432
+ }
2433
+ async function writeRouteTypes(manifest, path) {
2434
+ const routePaths = manifest.routes.pages.map((route) => JSON.stringify(route.path));
2435
+ const actionNames = Object.values(manifest.actions).flatMap((actions) => Object.keys(actions)).filter((name, index, names) => names.indexOf(name) === index).map((name) => JSON.stringify(name));
2436
+ const source = [
2437
+ `export type ElurRoutePath = ${routePaths.length ? routePaths.join(" | ") : "never"};`,
2438
+ `export type ElurActionName = ${actionNames.length ? actionNames.join(" | ") : "never"};`,
2439
+ "export interface ElurRouteParams { [name: string]: string | string[] | undefined }",
2440
+ ""
2441
+ ].join("\n");
2442
+ await (0, node_fs_promises.mkdir)((0, node_path.dirname)(path), { recursive: true });
2443
+ await (0, node_fs_promises.writeFile)(path, source, "utf8");
2444
+ }
2445
+ function validateManifestRoutes(routes) {
2446
+ const seen = /* @__PURE__ */ new Map();
2447
+ for (const route of routes.pages) {
2448
+ registerRoute(seen, route.path, route.pagePath, "page");
2449
+ assertNotReserved(route.path, route.pagePath);
2450
+ }
2451
+ for (const route of routes.api) {
2452
+ registerRoute(seen, route.path, route.routePath, "API");
2453
+ assertNotReserved(route.path, route.routePath);
2454
+ }
2455
+ }
2456
+ function registerRoute(seen, path, file, kind) {
2457
+ const existing = seen.get(path);
2458
+ if (existing) throw new Error(`[elur-kit] Duplicate ${kind} route "${path}": ${existing} and ${file}`);
2459
+ seen.set(path, file);
2460
+ }
2461
+ function assertNotReserved(path, file) {
2462
+ if (path === "/__elur-js" || path.startsWith("/__elur-js/") || path === "/_elur" || path.startsWith("/_elur/")) throw new Error(`[elur-kit] Reserved route "${path}" declared by ${file}`);
2463
+ }
2464
+ function validateIslands(islands) {
2465
+ const names = /* @__PURE__ */ new Set();
2466
+ for (const island of islands) {
2467
+ if (names.has(island.name)) throw new Error(`[elur-kit] Duplicate island name: ${island.name}`);
2468
+ names.add(island.name);
2469
+ }
2470
+ }
2471
+ function toPortableManifest(manifest) {
2472
+ const relativePath = (path) => path ? (0, node_path.relative)(manifest.root, path).split("\\").join("/") : void 0;
2473
+ const routes = {
2474
+ pages: manifest.routes.pages.map((route) => ({
2475
+ ...route,
2476
+ pagePath: relativePath(route.pagePath),
2477
+ dataPath: relativePath(route.dataPath),
2478
+ actionPath: relativePath(route.actionPath),
2479
+ loadingPath: relativePath(route.loadingPath),
2480
+ layouts: route.layouts.map((layout) => relativePath(layout))
2481
+ })),
2482
+ api: manifest.routes.api.map((route) => ({
2483
+ ...route,
2484
+ routePath: relativePath(route.routePath)
2485
+ })),
2486
+ error404: manifest.routes.error404 ? {
2487
+ ...manifest.routes.error404,
2488
+ pagePath: relativePath(manifest.routes.error404.pagePath),
2489
+ dataPath: relativePath(manifest.routes.error404.dataPath),
2490
+ actionPath: relativePath(manifest.routes.error404.actionPath),
2491
+ loadingPath: relativePath(manifest.routes.error404.loadingPath),
2492
+ layouts: manifest.routes.error404.layouts.map((layout) => relativePath(layout))
2493
+ } : void 0,
2494
+ error500: manifest.routes.error500 ? {
2495
+ ...manifest.routes.error500,
2496
+ pagePath: relativePath(manifest.routes.error500.pagePath),
2497
+ dataPath: relativePath(manifest.routes.error500.dataPath),
2498
+ actionPath: relativePath(manifest.routes.error500.actionPath),
2499
+ loadingPath: relativePath(manifest.routes.error500.loadingPath),
2500
+ layouts: manifest.routes.error500.layouts.map((layout) => relativePath(layout))
2501
+ } : void 0
2502
+ };
2503
+ const actions = {};
2504
+ for (const [page, pageActions] of Object.entries(manifest.actions)) actions[page] = Object.fromEntries(Object.entries(pageActions).map(([name, path]) => [name, relativePath(path)]));
2505
+ return {
2506
+ ...manifest,
2507
+ root: ".",
2508
+ routes,
2509
+ actions,
2510
+ islands: manifest.islands.map((island) => ({
2511
+ ...island,
2512
+ filePath: relativePath(island.filePath)
2513
+ }))
2514
+ };
2515
+ }
2516
+ //#endregion
2517
+ //#region src/runtime/capabilities.ts
2518
+ /** True when the host supports streaming responses (streaming !== false). */
2519
+ function supportsStreaming(capabilities = { streaming: true }) {
2520
+ return capabilities.streaming !== false;
2521
+ }
2522
+ /** True when the host can write to persistent storage (for ISR/cache/image writes). */
2523
+ function supportsPersistentStorage(capabilities) {
2524
+ return capabilities.filesystem === "persistent";
2525
+ }
2526
+ /**
2527
+ * Validates a capability declaration and reports incompatible combinations.
2528
+ * Used by the build pipeline so invalid hosts fail at build time instead of
2529
+ * producing a broken runtime.
2530
+ */
2531
+ function validateCapabilities(capabilities, features = {}) {
2532
+ const problems = [];
2533
+ if (features.isr && !supportsPersistentStorage(capabilities)) problems.push(`ISR requires a persistent filesystem; the host declares filesystem="${capabilities.filesystem}".`);
2534
+ if (features.images && capabilities.imageRuntime === false && capabilities.filesystem === "none") problems.push("On-demand image transforms require either imageRuntime=true or a readable filesystem; the host has neither.");
2535
+ if (features.streaming && capabilities.streaming === false) problems.push("Streaming was requested but the host declares streaming=false.");
2536
+ return {
2537
+ ok: problems.length === 0,
2538
+ problems
2539
+ };
2540
+ }
2541
+ var DEFAULT_CAPABILITIES, SERVERLESS_CAPABILITIES;
2542
+ var init_capabilities = __esmMin((() => {
2543
+ DEFAULT_CAPABILITIES = {
2544
+ streaming: true,
2545
+ filesystem: "persistent",
2546
+ imageRuntime: true,
2547
+ backgroundWork: true
2548
+ };
2549
+ SERVERLESS_CAPABILITIES = {
2550
+ streaming: true,
2551
+ filesystem: "ephemeral",
2552
+ imageRuntime: false,
2553
+ backgroundWork: false,
2554
+ maxBodySize: 1048576
2555
+ };
2556
+ }));
2557
+ //#endregion
2558
+ //#region src/cli/output.ts
2559
+ var useColor = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
2560
+ function paint(code, text) {
2561
+ return useColor ? `\x1b[${code}m${text}\x1b[0m` : text;
2562
+ }
2563
+ var bold = (text) => paint(1, text);
2564
+ var dim = (text) => paint(2, text);
2565
+ var red = (text) => paint(31, text);
2566
+ var green = (text) => paint(32, text);
2567
+ var yellow = (text) => paint(33, text);
2568
+ var cyan = (text) => paint(36, text);
2569
+ var quiet = false;
2570
+ /** Enables quiet mode: only errors are printed. */
2571
+ function setQuiet(value) {
2572
+ quiet = value;
2573
+ }
2574
+ /** Success message with a green check. */
2575
+ function success(message) {
2576
+ if (quiet) return;
2577
+ console.log(`${green("✓")} ${message}`);
2578
+ }
2579
+ /** Indented info line with a cyan arrow. */
2580
+ function info(message) {
2581
+ if (quiet) return;
2582
+ console.log(` ${cyan("→")} ${message}`);
2583
+ }
2584
+ /** Indented detail line (file lists, sub-items). */
2585
+ function detail(message) {
2586
+ if (quiet) return;
2587
+ console.log(dim(` - ${message}`));
2588
+ }
2589
+ /** Lifecycle event with a dim bracket tag, e.g. [dev], [change]. */
2590
+ function event(tag, message) {
2591
+ if (quiet) return;
2592
+ console.log(`${dim(`[${tag}]`)} ${message}`);
2593
+ }
2594
+ /** Warning; suppressed in quiet mode. */
2595
+ function warn(message) {
2596
+ if (quiet) return;
2597
+ console.warn(`${yellow("!")} ${message}`);
2598
+ }
2599
+ /** Error; always printed, even in quiet mode. */
2600
+ function error(message) {
2601
+ console.error(`${red("✗")} ${message}`);
2602
+ }
2603
+ /** Formats a byte count as "640 B", "1.5 kB", "2.3 MB". */
2604
+ function formatBytes(bytes) {
2605
+ if (bytes < 1024) return `${bytes} B`;
2606
+ if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} kB`;
2607
+ return `${(bytes / 1048576).toFixed(1)} MB`;
2608
+ }
2609
+ /** Formats a duration as "45ms" or "1.23s". */
2610
+ function formatDuration(ms) {
2611
+ if (ms < 1e3) return `${Math.round(ms)}ms`;
2612
+ return `${(ms / 1e3).toFixed(2)}s`;
2613
+ }
2614
+ /** Build phase line: checkmark + label + dim duration. */
2615
+ function phase(label, durationMs) {
2616
+ if (quiet) return;
2617
+ console.log(` ${green("✓")} ${label} ${dim(formatDuration(durationMs))}`);
2618
+ }
2619
+ var MAX_FILE_ROWS = 20;
2620
+ var SHOWN_FILE_ROWS = 10;
2621
+ /**
2622
+ * Renders the generated-file list as aligned "path size" rows (plain text,
2623
+ * no color). With more than MAX_FILE_ROWS entries, shows the SHOWN_FILE_ROWS
2624
+ * largest plus a "… and N more" line.
2625
+ */
2626
+ function fileRows(files, max = MAX_FILE_ROWS, shown = SHOWN_FILE_ROWS) {
2627
+ const sorted = files.length > max ? [...files].sort((a, b) => b.bytes - a.bytes) : files;
2628
+ const visible = sorted.slice(0, files.length > max ? shown : sorted.length);
2629
+ const pathWidth = Math.max(...visible.map((f) => f.path.length), 0);
2630
+ const sizeWidth = Math.max(...visible.map((f) => formatBytes(f.bytes).length), 0);
2631
+ const rows = visible.map((f) => `${f.path.padEnd(pathWidth)} ${formatBytes(f.bytes).padStart(sizeWidth)}`);
2632
+ const hidden = files.length - visible.length;
2633
+ if (hidden > 0) rows.push(`… and ${hidden} more`);
2634
+ return rows;
2635
+ }
2636
+ /** Prints the generated-file list: dim paths, aligned sizes. */
2637
+ function fileList(files) {
2638
+ if (quiet || files.length === 0) return;
2639
+ for (const row of fileRows(files)) {
2640
+ const sizeMatch = /^(.*?)( \S+)$/.exec(row);
2641
+ if (sizeMatch) console.log(` ${dim(sizeMatch[1])} ${dim(sizeMatch[2])}`);
2642
+ else console.log(` ${dim(row)}`);
2643
+ }
2644
+ }
2645
+ /**
2646
+ * Plain-text lines of the server startup banner (Astro-style, no box):
2647
+ *
2648
+ * elur-kit v2.4.10 dev server running at:
2649
+ * → Local: http://localhost:3000/
2650
+ * → Network: http://192.168.1.20:3000/
2651
+ */
2652
+ function serverBannerLines(options) {
2653
+ const lines = [`${options.name} ${options.version} ${options.command} server running at:`, ""];
2654
+ const labelWidth = options.networkUrl ? 8 : 6;
2655
+ lines.push(` → ${"Local:".padEnd(labelWidth)} ${options.localUrl}`);
2656
+ if (options.networkUrl) lines.push(` → ${"Network:".padEnd(labelWidth)} ${options.networkUrl}`);
2657
+ return lines;
2658
+ }
2659
+ /** Prints the server startup banner with brand colors. */
2660
+ function serverBanner(options) {
2661
+ if (quiet) return;
2662
+ const [title, blank, ...urls] = serverBannerLines(options);
2663
+ console.log();
2664
+ console.log(` ${bold(cyan(title))}`);
2665
+ console.log(blank);
2666
+ for (const line of urls) {
2667
+ const arrowEnd = line.indexOf("→") + 1;
2668
+ console.log(` ${cyan("→")}${dim(line.slice(arrowEnd))}`);
2669
+ }
2670
+ }
2671
+ /** First external (LAN) IPv4 address, for the Network URL. */
2672
+ function getNetworkAddress() {
2673
+ for (const infos of Object.values((0, node_os.networkInterfaces)())) for (const info of infos ?? []) if (info.family === "IPv4" && !info.internal) return info.address;
2674
+ }
2675
+ /**
2676
+ * Listens on host:port, falling back to the next port while the failure is
2677
+ * EADDRINUSE. Resolves with the port actually bound. Rejects with the
2678
+ * original error for non-port failures or when the range is exhausted.
2679
+ */
2680
+ function listenWithFallback(server, host, port, options = {}) {
2681
+ const maxTries = options.maxTries ?? 20;
2682
+ return new Promise((resolvePromise, reject) => {
2683
+ let attempt = 0;
2684
+ let candidate = port;
2685
+ const onListening = () => {
2686
+ server.removeListener("error", onError);
2687
+ resolvePromise(candidate);
2688
+ };
2689
+ const onError = (err) => {
2690
+ server.removeListener("listening", onListening);
2691
+ if (err.code === "EADDRINUSE" && attempt < maxTries) {
2692
+ attempt++;
2693
+ const nextPort = port + attempt;
2694
+ options.onFallback?.(nextPort - 1, nextPort);
2695
+ tryListen(nextPort);
2696
+ return;
2031
2697
  }
2032
- args = [input];
2033
- }
2034
- } else {
2035
- const bodyResult = await readBodyWithLimit(request, bodyLimit);
2036
- if (!bodyResult.ok) return {
2037
- ok: false,
2038
- response: bodyResult.response
2698
+ reject(err);
2039
2699
  };
2040
- const form = parseFormBody(bodyResult.text);
2041
- name = form.__elur_js_action_name;
2042
- page = form.__elur_js_action_page;
2043
- const input = {};
2044
- for (const [key, value] of Object.entries(form)) {
2045
- if (key === "__elur_js_action_name" || key === "__elur_js_action_page") continue;
2046
- input[key] = value;
2700
+ const tryListen = (next) => {
2701
+ candidate = next;
2702
+ server.once("error", onError);
2703
+ server.once("listening", onListening);
2704
+ server.listen(candidate, host);
2705
+ };
2706
+ tryListen(port);
2707
+ });
2708
+ }
2709
+ //#endregion
2710
+ //#region src/build/vite-build.ts
2711
+ var vite_build_exports = /* @__PURE__ */ __exportAll({
2712
+ beginAtomicStage: () => beginAtomicStage,
2713
+ buildClientBundle: () => buildClientBundle,
2714
+ copyPublicAssets: () => copyPublicAssets,
2715
+ loadUserConfig: () => loadUserConfig,
2716
+ resolveClientInputs: () => resolveClientInputs
2717
+ });
2718
+ /**
2719
+ * Build the client hydration bundle using the Vite JavaScript API.
2720
+ *
2721
+ * The user's config is loaded programmatically and the elur interpolation
2722
+ * plugin is injected so partial attribute interpolations inside islands are
2723
+ * transformed before reaching the browser.
2724
+ */
2725
+ async function buildClientBundle(options) {
2726
+ const log = options.logPrefix ?? "[client]";
2727
+ const quiet = options.quiet ?? false;
2728
+ if (!quiet) console.log(`${log} Building hydration bundle...`);
2729
+ const userConfig = options.userConfigPath ? await loadUserConfig(options.userConfigPath, options.root) : defaultClientConfig(options.defaultInputs);
2730
+ const pluginOptions = shouldUseLegacyInterpolation(options.interpolation ?? "auto") ? elurJsInterpolationPlugin({
2731
+ appDir: options.appDir,
2732
+ islandsDir: options.islandsDir
2733
+ }) : [];
2734
+ const config = {
2735
+ ...userConfig,
2736
+ root: options.root,
2737
+ base: options.base ?? userConfig.base ?? "/",
2738
+ logLevel: quiet ? "silent" : userConfig.logLevel,
2739
+ build: {
2740
+ ...userConfig.build ?? {},
2741
+ outDir: options.outDir,
2742
+ emptyOutDir: true
2743
+ },
2744
+ plugins: [...userConfig.plugins ?? [], pluginOptions],
2745
+ configFile: false
2746
+ };
2747
+ const result = await (0, vite.build)(config);
2748
+ const outputCount = (Array.isArray(result) ? result : [result]).reduce((n, r) => n + ("output" in r ? r.output?.length ?? 0 : 0), 0);
2749
+ if (!quiet) console.log(`${log} ✓ ${outputCount} asset(s) emitted → ${(0, node_path.relative)(options.root, options.outDir)}`);
2750
+ return {
2751
+ outDir: options.outDir,
2752
+ outputCount
2753
+ };
2754
+ }
2755
+ async function loadUserConfig(path, _root) {
2756
+ const mod = await import(path);
2757
+ const raw = mod.default ?? mod;
2758
+ const resolved = typeof raw === "function" ? await raw({
2759
+ command: "build",
2760
+ mode: "production"
2761
+ }) : raw;
2762
+ return (resolved && typeof resolved.then === "function" ? await resolved : resolved) ?? {};
2763
+ }
2764
+ /**
2765
+ * Resolves a user Vite config's `build.rollupOptions.input` to a list of
2766
+ * absolute entry paths. Used to detect whether the bundle will emit the
2767
+ * generated router module as its own chunk (split build) or not (legacy
2768
+ * single-entry bundle — the router stays embedded in the entry).
2769
+ */
2770
+ async function resolveClientInputs(userConfigPath, root) {
2771
+ const input = (await loadUserConfig(userConfigPath, root)).build?.rollupOptions?.input;
2772
+ if (!input || typeof input === "string") return input ? [(0, node_path.resolve)(root, input)] : [];
2773
+ return (Array.isArray(input) ? input : Object.values(input)).filter((v) => typeof v === "string").map((v) => (0, node_path.resolve)(root, v));
2774
+ }
2775
+ /**
2776
+ * The synthesized client bundle config used when the project does not ship
2777
+ * its own `vite.client.config.*`: named inputs with `[name].js` filenames in
2778
+ * ES format — the same contract the docs give to hand-written client
2779
+ * configs, so `entry-client` → `entry-client.js` and `router` → `router.js`.
2780
+ */
2781
+ function defaultClientConfig(defaultInputs) {
2782
+ return { build: { rollupOptions: {
2783
+ input: defaultInputs ?? {},
2784
+ output: {
2785
+ entryFileNames: "[name].js",
2786
+ format: "es"
2047
2787
  }
2048
- args = [input];
2049
- }
2050
- if (!name || typeof name !== "string") return {
2051
- ok: false,
2052
- response: new Response("Missing action name", {
2053
- status: 400,
2054
- headers: { "Content-Type": "text/plain" }
2055
- })
2788
+ } } };
2789
+ }
2790
+ /**
2791
+ * Prepare an atomic staging directory for build output.
2792
+ *
2793
+ * Usage:
2794
+ * const stage = await beginAtomicStage({ outDir });
2795
+ * try {
2796
+ * await buildInto(stage.tempDir);
2797
+ * await stage.commit();
2798
+ * } catch (err) {
2799
+ * await stage.rollback();
2800
+ * throw err;
2801
+ * }
2802
+ */
2803
+ async function beginAtomicStage(options) {
2804
+ const outDir = (0, node_path.resolve)(options.outDir);
2805
+ const tempDir = (0, node_path.resolve)(options.tempDir ?? (0, node_path.join)((0, node_path.dirname)(outDir), `.${basename(outDir)}.tmp-${process.pid}`));
2806
+ await (0, node_fs_promises.rm)(tempDir, {
2807
+ recursive: true,
2808
+ force: true
2809
+ });
2810
+ await (0, node_fs_promises.mkdir)(tempDir, { recursive: true });
2811
+ const commit = async () => {
2812
+ const backup = options.keepExisting && (0, node_fs.existsSync)(outDir) ? `${outDir}.bak-${process.pid}` : void 0;
2813
+ if (backup) {
2814
+ await (0, node_fs_promises.rm)(backup, {
2815
+ recursive: true,
2816
+ force: true
2817
+ });
2818
+ await safeRename(outDir, backup);
2819
+ }
2820
+ try {
2821
+ await safeRename(tempDir, outDir);
2822
+ } catch (err) {
2823
+ if (isCrossDevice(err)) {
2824
+ await (0, node_fs_promises.cp)(tempDir, outDir, {
2825
+ recursive: true,
2826
+ force: true
2827
+ });
2828
+ await (0, node_fs_promises.rm)(tempDir, {
2829
+ recursive: true,
2830
+ force: true
2831
+ });
2832
+ } else {
2833
+ if (backup) await safeRename(backup, outDir);
2834
+ throw err;
2835
+ }
2836
+ }
2837
+ if (backup) await (0, node_fs_promises.rm)(backup, {
2838
+ recursive: true,
2839
+ force: true
2840
+ });
2841
+ };
2842
+ const rollback = async () => {
2843
+ await (0, node_fs_promises.rm)(tempDir, {
2844
+ recursive: true,
2845
+ force: true
2846
+ });
2056
2847
  };
2057
2848
  return {
2058
- ok: true,
2059
- name,
2060
- page,
2061
- args,
2062
- wantsJson
2849
+ tempDir,
2850
+ commit,
2851
+ rollback
2063
2852
  };
2064
2853
  }
2065
- /**
2066
- * Handles a POST request to the server action endpoint.
2067
- *
2068
- * Accepts both JSON requests (`{ name, page?, args }`) and HTML form submissions
2069
- * for progressive enhancement. The provided resolver looks up the action
2070
- * implementation, invokes it with the supplied arguments and returns the result
2071
- * as JSON or redirects back to the request origin for form submissions.
2072
- *
2073
- * Origin verification (CSRF protection) runs before parsing the body: any
2074
- * cross-origin POST is rejected with 403 unless its origin is allow-listed via
2075
- * `security.allowedOrigins`.
2076
- *
2077
- * For progressive-enhancement form submissions that fail, the failure payload
2078
- * is relayed back via a short-lived `__elur_js_action_error` cookie (SameSite=Lax,
2079
- * Max-Age=15s) instead of a query param, so errors do not leak into browser
2080
- * history, server logs or third-party Referer headers.
2081
- */
2082
- async function handleActionRequest(request, resolveAction, security = {}) {
2083
- const originError = verifyOrigin(request, security);
2084
- if (originError) return originForbidden(originError);
2085
- const parsed = await parseActionRequest(request, security.bodyLimit ?? DEFAULT_BODY_LIMIT);
2086
- if (!parsed.ok) return parsed.response;
2087
- const { name, page, args, wantsJson } = parsed;
2854
+ function basename(path) {
2855
+ const parts = path.split(/[\\/]+/).filter(Boolean);
2856
+ return parts[parts.length - 1] ?? "output";
2857
+ }
2858
+ async function safeRename(src, dest) {
2859
+ await (0, node_fs_promises.rm)(dest, {
2860
+ recursive: true,
2861
+ force: true
2862
+ });
2088
2863
  try {
2089
- const action = await resolveAction(name, page);
2090
- if (!action) {
2091
- const message = page ? `Action not found: ${name} (page: ${page})` : `Action not found: ${name}`;
2092
- return new Response(message, {
2093
- status: 404,
2094
- headers: { "Content-Type": "text/plain" }
2095
- });
2096
- }
2097
- const result = await action(...args);
2098
- if (isActionFailure(result)) {
2099
- if (wantsJson) return new Response(JSON.stringify({
2100
- __elur_js_action_failure: true,
2101
- status: result.status,
2102
- data: result.data
2103
- }), {
2104
- status: result.status,
2105
- headers: { "Content-Type": "application/json" }
2106
- });
2107
- const referer = request.headers.get("Referer") ?? "/";
2108
- const url = new URL(referer, "http://localhost");
2109
- const { value } = encodeActionErrorCookie(result.data, result.status);
2110
- return new Response(null, {
2111
- status: 303,
2112
- headers: {
2113
- Location: url.pathname + url.search,
2114
- "Content-Type": "text/plain",
2115
- "Set-Cookie": setActionErrorCookieHeader(value)
2116
- }
2117
- });
2118
- }
2119
- if (isRedirectResponse(result)) {
2120
- if (wantsJson) return new Response(JSON.stringify({
2121
- __elur_js_action_redirect: true,
2122
- status: result.status,
2123
- location: result.location
2124
- }), {
2125
- status: 200,
2126
- headers: { "Content-Type": "application/json" }
2864
+ await (0, node_fs_promises.rename)(src, dest);
2865
+ } catch (err) {
2866
+ if (isCrossDevice(err)) {
2867
+ await (0, node_fs_promises.cp)(src, dest, {
2868
+ recursive: true,
2869
+ force: true
2127
2870
  });
2128
- return new Response(null, {
2129
- status: result.status,
2130
- headers: {
2131
- Location: result.location,
2132
- "Content-Type": "text/plain"
2133
- }
2871
+ await (0, node_fs_promises.rm)(src, {
2872
+ recursive: true,
2873
+ force: true
2134
2874
  });
2135
- }
2136
- if (wantsJson) return new Response(JSON.stringify(result ?? null), {
2137
- status: 200,
2138
- headers: { "Content-Type": "application/json" }
2139
- });
2140
- const referer = request.headers.get("Referer") ?? "/";
2141
- return new Response(null, {
2142
- status: 303,
2143
- headers: {
2144
- Location: typeof result === "string" ? result : referer,
2145
- "Content-Type": "text/plain"
2146
- }
2147
- });
2148
- } catch (err) {
2149
- console.error("[elur-kit] Action error:", err);
2150
- return publicErrorResponse(err, { includeDetail: false });
2875
+ } else throw err;
2151
2876
  }
2152
2877
  }
2153
- var DEFAULT_BODY_LIMIT;
2154
- var init_server = __esmMin((() => {
2155
- init_errors();
2156
- init_origin();
2157
- init_error_store();
2158
- DEFAULT_BODY_LIMIT = 1048576;
2159
- }));
2160
- //#endregion
2161
- //#region src/cache.ts
2162
- function cachePath(cacheDir, pathname) {
2163
- const key = (0, node_crypto.createHash)("sha256").update(pathname).digest("hex");
2164
- return (0, node_path.join)(cacheDir, `${key}.html.json`);
2878
+ function isCrossDevice(err) {
2879
+ return err?.code === "EXDEV";
2165
2880
  }
2166
- async function getCachedHtml(cacheDir, pathname) {
2167
- const path = cachePath(cacheDir, pathname);
2881
+ /**
2882
+ * Copy the public directory into the output directory.
2883
+ * Returns the number of files copied.
2884
+ */
2885
+ async function copyPublicAssets(options) {
2168
2886
  try {
2169
- const raw = await (0, node_fs_promises.readFile)(path, "utf8");
2170
- const entry = JSON.parse(raw);
2171
- if (Date.now() - entry.generatedAt < entry.revalidate * 1e3) return entry;
2172
- } catch {}
2887
+ await (0, node_fs_promises.access)(options.publicDir);
2888
+ if (!(await (0, node_fs_promises.stat)(options.publicDir)).isDirectory()) return 0;
2889
+ } catch {
2890
+ return 0;
2891
+ }
2892
+ await (0, node_fs_promises.mkdir)(options.outDir, { recursive: true });
2893
+ await (0, node_fs_promises.cp)(options.publicDir, options.outDir, {
2894
+ recursive: true,
2895
+ force: true
2896
+ });
2897
+ return countFiles(options.outDir);
2173
2898
  }
2174
- async function setCachedHtml(cacheDir, pathname, html, revalidate) {
2175
- const path = cachePath(cacheDir, pathname);
2176
- await (0, node_fs_promises.mkdir)((0, node_path.dirname)(path), { recursive: true });
2177
- const entry = {
2178
- html,
2179
- generatedAt: Date.now(),
2180
- revalidate
2181
- };
2182
- const temporaryPath = `${path}.${process.pid}.${(0, node_crypto.randomUUID)()}.tmp`;
2183
- try {
2184
- await (0, node_fs_promises.writeFile)(temporaryPath, JSON.stringify(entry), "utf8");
2185
- await (0, node_fs_promises.rename)(temporaryPath, path);
2186
- } finally {
2187
- await (0, node_fs_promises.rm)(temporaryPath, { force: true });
2899
+ async function countFiles(dir) {
2900
+ const { readdir } = await import("node:fs/promises");
2901
+ let count = 0;
2902
+ async function walk(d) {
2903
+ const entries = await readdir(d, { withFileTypes: true });
2904
+ for (const entry of entries) {
2905
+ const path = (0, node_path.join)(d, entry.name);
2906
+ if (entry.isDirectory()) await walk(path);
2907
+ else count++;
2908
+ }
2188
2909
  }
2910
+ await walk(dir);
2911
+ return count;
2189
2912
  }
2190
- var init_cache = __esmMin((() => {}));
2913
+ var init_vite_build = __esmMin((() => {
2914
+ init_interpolation_plugin();
2915
+ }));
2191
2916
  //#endregion
2192
2917
  //#region src/ssr/match.ts
2193
2918
  /**
@@ -2228,174 +2953,53 @@ function matchApiRoute(pathname, routes) {
2228
2953
  * Safely decodes a URI component. If decoding fails (malformed % sequences),
2229
2954
  * returns the original string rather than throwing (runtime-security §10).
2230
2955
  */
2231
- function safeDecodeURIComponent(segment) {
2232
- try {
2233
- return decodeURIComponent(segment);
2234
- } catch {
2235
- return segment;
2236
- }
2237
- }
2238
- function specificity(path) {
2239
- return path.split("/").filter(Boolean).reduce((score, segment) => {
2240
- if (segment.endsWith("*")) return score;
2241
- if (segment.startsWith(":")) return score + 1;
2242
- return score + 2;
2243
- }, 0);
2244
- }
2245
- function tryMatch(requestSegments, routeSegments, optionalCatchAll = false) {
2246
- const params = {};
2247
- let i = 0;
2248
- for (let r = 0; r < routeSegments.length; r++) {
2249
- const routeSeg = routeSegments[r];
2250
- if (routeSeg.endsWith("*")) {
2251
- const name = routeSeg.slice(1, -1);
2252
- const rest = requestSegments.slice(i);
2253
- if (rest.length === 0 && !optionalCatchAll) return void 0;
2254
- params[name] = rest.length > 0 ? rest : [];
2255
- return params;
2256
- }
2257
- if (routeSeg.startsWith(":")) {
2258
- const requestSeg = requestSegments[i];
2259
- if (requestSeg === void 0) return void 0;
2260
- params[routeSeg.slice(1)] = requestSeg;
2261
- i++;
2262
- continue;
2263
- }
2264
- if (routeSeg !== requestSegments[i]) return;
2265
- i++;
2266
- }
2267
- if (i !== requestSegments.length) return void 0;
2268
- return params;
2269
- }
2270
- var init_match = __esmMin((() => {}));
2271
- //#endregion
2272
- //#region src/ssr/stream.ts
2273
- /** Builds the concrete URL path for a route pattern given matched params. */
2274
- function buildConcretePath(routePath, params) {
2275
- return routePath.replace(/:([a-zA-Z0-9_]+)(\*)?/g, (_m, name, catchAll) => {
2276
- const value = params[name];
2277
- if (value === void 0 || value === null) return "";
2278
- return catchAll ? Array.isArray(value) ? value.join("/") : String(value) : String(value);
2279
- });
2280
- }
2281
- function streamingScript(page, search) {
2282
- return `<script type="module">${`
2283
- async function __elurJsStreamRender() {
2284
- try {
2285
- const url = "/__elur-js/render?page=" + encodeURIComponent(${JSON.stringify(page)}) + "&search=" + encodeURIComponent(${JSON.stringify(search)});
2286
- const res = await fetch(url);
2287
- if (!res.ok) throw new Error("Streaming render failed: " + res.status);
2288
- const html = await res.text();
2289
- const app = document.getElementById("app");
2290
- if (app) app.innerHTML = html;
2291
- document.dispatchEvent(new CustomEvent("elur:rendered"));
2292
- } catch (err) {
2293
- console.error("[elur-kit] streaming render failed", err);
2294
- }
2295
- }
2296
- __elurJsStreamRender();
2297
- `}<\/script>`;
2298
- }
2299
- /**
2300
- * Render a page shell that shows the loading boundary while the real content
2301
- * is fetched and injected by the client.
2302
- */
2303
- async function renderStreamingPage(options) {
2304
- const { route, params, searchParams, config, importer = defaultImport, actions } = options;
2305
- if (!route.loadingPath) throw new Error("Cannot stream a page without a loading.ts boundary");
2306
- const { default: Loading } = await importer(route.loadingPath);
2307
- const body = `<div id="elur-loading">${await renderToString(() => Loading())}</div>${streamingScript(buildConcretePath(route.path, params), searchParams.toString())}`;
2308
- const htmlAttributes = {};
2309
- const headScripts = [];
2310
- const headLinks = [];
2311
- if (route.layouts.length > 0) {
2312
- const rootLayout = route.layouts[0];
2313
- const dataPath = rootLayout.replace(/layout\.ts$/, "layout.data.ts");
2314
- if (dataPath !== rootLayout) try {
2315
- const mod = await importer(dataPath);
2316
- const layoutData = mod.load ? await mod.load({
2317
- params,
2318
- searchParams,
2319
- request: options.request
2320
- }) : void 0;
2321
- if (layoutData && typeof layoutData === "object") {
2322
- const attrs = layoutData.htmlAttributes;
2323
- if (attrs) Object.assign(htmlAttributes, attrs);
2324
- const scripts = layoutData.headScripts;
2325
- if (Array.isArray(scripts)) headScripts.push(...scripts);
2326
- const links = layoutData.headLinks;
2327
- if (Array.isArray(links)) headLinks.push(...links);
2328
- }
2329
- } catch {}
2330
- }
2331
- return documentShell({
2332
- title: "Loading...",
2333
- lang: config.lang,
2334
- body,
2335
- data: {
2336
- __elur_js_streaming: true,
2337
- page: route.path
2338
- },
2339
- actions,
2340
- htmlAttributes,
2341
- headScripts,
2342
- headLinks,
2343
- clientEntry: config.clientEntry
2344
- });
2345
- }
2346
- /**
2347
- * Render only the inner HTML body for a page. Used by the streaming endpoint
2348
- * to inject the real content into the shell.
2349
- */
2350
- async function renderPageBody(options) {
2351
- const { routes, pathname, searchParams, config, actions, importer = defaultImport, request } = options;
2352
- const match = matchRoute(pathname, routes.pages);
2353
- if (!match) throw new RouteNotFoundError(pathname);
2354
- const result = await renderPage({
2355
- route: match.route,
2356
- params: match.params,
2357
- searchParams,
2358
- config,
2359
- actions,
2360
- importer,
2361
- request
2362
- });
2363
- if (result.response) return {
2364
- body: "",
2365
- title: "",
2366
- response: result.response
2367
- };
2368
- const bodyMatch = result.html.match(/<div id="app">([\s\S]*)<\/div>\s*(<script|$)/);
2369
- const body = bodyMatch ? bodyMatch[1].trim() : result.html;
2370
- const titleMatch = result.html.match(/<title[^>]*>([^<]*)<\/title>/);
2371
- return {
2372
- body,
2373
- title: titleMatch ? titleMatch[1] : result.resolvedTitle ?? "",
2374
- fullHtml: result.html,
2375
- clearActionErrorCookie: result.clearActionErrorCookie,
2376
- head: result.head
2377
- };
2956
+ function safeDecodeURIComponent(segment) {
2957
+ try {
2958
+ return decodeURIComponent(segment);
2959
+ } catch {
2960
+ return segment;
2961
+ }
2378
2962
  }
2379
- var defaultImport, RouteNotFoundError;
2380
- var init_stream = __esmMin((() => {
2381
- init_render_to_string();
2382
- init_document_shell();
2383
- init_match();
2384
- init_render();
2385
- defaultImport = (path) => import(path);
2386
- RouteNotFoundError = class extends Error {
2387
- constructor(pathname) {
2388
- super(`No route found for ${pathname}`);
2389
- this.name = "RouteNotFoundError";
2963
+ function specificity(path) {
2964
+ return path.split("/").filter(Boolean).reduce((score, segment) => {
2965
+ if (segment.endsWith("*")) return score;
2966
+ if (segment.startsWith(":")) return score + 1;
2967
+ return score + 2;
2968
+ }, 0);
2969
+ }
2970
+ function tryMatch(requestSegments, routeSegments, optionalCatchAll = false) {
2971
+ const params = {};
2972
+ let i = 0;
2973
+ for (let r = 0; r < routeSegments.length; r++) {
2974
+ const routeSeg = routeSegments[r];
2975
+ if (routeSeg.endsWith("*")) {
2976
+ const name = routeSeg.slice(1, -1);
2977
+ const rest = requestSegments.slice(i);
2978
+ if (rest.length === 0 && !optionalCatchAll) return void 0;
2979
+ params[name] = rest.length > 0 ? rest : [];
2980
+ return params;
2390
2981
  }
2391
- };
2392
- }));
2982
+ if (routeSeg.startsWith(":")) {
2983
+ const requestSeg = requestSegments[i];
2984
+ if (requestSeg === void 0) return void 0;
2985
+ params[routeSeg.slice(1)] = requestSeg;
2986
+ i++;
2987
+ continue;
2988
+ }
2989
+ if (routeSeg !== requestSegments[i]) return;
2990
+ i++;
2991
+ }
2992
+ if (i !== requestSegments.length) return void 0;
2993
+ return params;
2994
+ }
2995
+ var init_match = __esmMin((() => {}));
2393
2996
  //#endregion
2394
2997
  //#region src/middleware/index.ts
2395
- init_server();
2396
- init_cache();
2397
- init_stream();
2398
- init_match();
2998
+ var middleware_exports = /* @__PURE__ */ __exportAll({
2999
+ loadMiddleware: () => loadMiddleware,
3000
+ matchesMiddleware: () => matchesMiddleware,
3001
+ runMiddleware: () => runMiddleware
3002
+ });
2399
3003
  /**
2400
3004
  * Loads the user's `src/middleware.ts` module. Returns `null` if no middleware
2401
3005
  * file exists. Distinguishes "file not found" from "file has errors" (§6):
@@ -2484,854 +3088,731 @@ async function runMiddleware(middleware, request, params) {
2484
3088
  }
2485
3089
  }
2486
3090
  }
3091
+ var init_middleware = __esmMin((() => {
3092
+ init_match();
3093
+ }));
2487
3094
  //#endregion
2488
- //#region src/runtime/node-http.ts
2489
- var GlobalAbortController = globalThis.AbortController ?? AbortController;
2490
- function incomingMessageToRequest(req, body) {
2491
- const headers = new Headers();
2492
- for (let index = 0; index < req.rawHeaders.length; index += 2) headers.append(req.rawHeaders[index], req.rawHeaders[index + 1]);
2493
- const controller = new GlobalAbortController();
2494
- req.once("aborted", () => controller.abort());
2495
- req.once("close", () => {
2496
- if (!req.complete) controller.abort();
2497
- });
2498
- const protocol = req.socket.encrypted ? "https" : "http";
2499
- const init = {
2500
- method: req.method ?? "GET",
2501
- headers,
2502
- signal: controller.signal
2503
- };
2504
- if (body !== void 0 && body !== null && init.method !== "GET" && init.method !== "HEAD") init.body = body;
2505
- return new Request(`${protocol}://${headers.get("host") ?? "localhost"}${req.url ?? "/"}`, init);
2506
- }
2507
- //#endregion
2508
- //#region src/runtime/static.ts
2509
- function isInside(root, candidate) {
2510
- return candidate === root || candidate.startsWith(`${root}${node_path.sep}`);
2511
- }
2512
- function decodePathname(pathname) {
2513
- try {
2514
- const decoded = decodeURIComponent(pathname);
2515
- if (decoded.includes("\0") || decoded.includes("\\") || /%(?:00|2e|2f|5c)/i.test(decoded)) return null;
2516
- if (decoded.split("/").some((segment) => segment === "..")) return null;
2517
- return decoded;
2518
- } catch {
2519
- return null;
2520
- }
2521
- }
2522
- async function resolveStaticFile(root, pathname) {
2523
- const decoded = decodePathname(pathname);
2524
- if (decoded === null) return null;
2525
- const resolvedRoot = (0, node_path.resolve)(root);
2526
- const relativePath = decoded.replace(/^\/+/, "");
2527
- let candidate = (0, node_path.resolve)(resolvedRoot, relativePath);
2528
- if (!isInside(resolvedRoot, candidate)) return null;
2529
- try {
2530
- if ((await (0, node_fs_promises.stat)(candidate)).isDirectory()) candidate = (0, node_path.resolve)(candidate, "index.html");
2531
- } catch {
2532
- if (decoded.endsWith("/") || (0, node_path.extname)(decoded) === "") candidate = (0, node_path.resolve)(candidate, "index.html");
2533
- }
2534
- if (!isInside(resolvedRoot, candidate)) return null;
2535
- try {
2536
- const [canonicalRoot, canonicalCandidate, candidateStat] = await Promise.all([
2537
- (0, node_fs_promises.realpath)(resolvedRoot),
2538
- (0, node_fs_promises.realpath)(candidate),
2539
- (0, node_fs_promises.stat)(candidate)
2540
- ]);
2541
- if (!candidateStat.isFile() || !isInside(canonicalRoot, canonicalCandidate)) return null;
2542
- return canonicalCandidate;
2543
- } catch {
2544
- return null;
2545
- }
2546
- }
2547
- var init_static = __esmMin((() => {}));
2548
- //#endregion
2549
- //#region src/ssr/server.ts
2550
- init_route_scanner();
2551
- init_scan();
2552
- init_match();
2553
- init_render();
2554
- init_static();
2555
- init_errors();
3095
+ //#region src/errors.ts
2556
3096
  /**
2557
- * Create an SSR server that renders pages on demand and serves static files.
3097
+ * Type guard for action failures. Uses the marker field so it works across
3098
+ * bundling boundaries where `instanceof` fails.
2558
3099
  */
2559
- async function createSsrServer(options) {
2560
- const routes = await scanRoutes(options.appDir);
2561
- const actions = await scanActions(options.appDir);
2562
- const publicActions = actionNames(actions);
2563
- const middleware = options.root ? await loadMiddleware(options.root) : null;
2564
- const resolveAction = async (name, page) => {
2565
- const pageKey = resolveActionPageKey(page, routes);
2566
- const pageActions = pageKey ? actions[pageKey] : Object.values(actions).find((p) => p[name]) ?? void 0;
2567
- const actionPath = pageActions ? pageActions[name] : void 0;
2568
- if (!actionPath) return void 0;
2569
- const action = (await import(actionPath))[name];
2570
- if (typeof action === "function") return action;
2571
- };
2572
- const server = (0, node_http.createServer)(async (req, res) => {
2573
- let urlPath = req.url ?? "/";
2574
- if (urlPath.includes("?")) urlPath = urlPath.split("?")[0];
2575
- if (urlPath === "/__elur-js/actions" && req.method === "POST") {
2576
- try {
2577
- const response = await handleActionRequest(incomingMessageToRequest(req, await readRequestBody$1(req)), resolveAction, options.actionSecurity);
2578
- res.writeHead(response.status, Object.fromEntries(response.headers.entries()));
2579
- res.end(await response.text());
2580
- } catch (err) {
2581
- console.error("[action] error handling", err);
2582
- res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
2583
- res.end(toPublicErrorInfo(err).message);
2584
- }
2585
- return;
2586
- }
2587
- if (urlPath === "/__elur-js/render") {
2588
- const renderUrl = new URL(req.url ?? "/", "http://localhost");
2589
- const page = renderUrl.searchParams.get("page") ?? "/";
2590
- const search = renderUrl.searchParams.get("search") ?? "";
2591
- const wantsJson = (req.headers["accept"] ?? "").includes("application/json");
2592
- try {
2593
- const request = incomingMessageToRequest(req);
2594
- let body;
2595
- let title;
2596
- let lastRenderedCookie;
2597
- let lastRenderedHead;
2598
- const ttl = await resolveTtl(options, page, routes);
2599
- const cacheKey = `/__elur-js/render${page}?${search}`;
2600
- if (options.cacheDir && typeof ttl === "number" && canUsePublicCache(request)) {
2601
- const cached = await getCachedHtml(options.cacheDir, cacheKey);
2602
- if (cached) {
2603
- body = extractBody(cached.html);
2604
- title = extractTitle(cached.html);
2605
- } else {
2606
- const rendered = await renderPageBody({
2607
- routes,
2608
- pathname: page,
2609
- searchParams: new URLSearchParams(search),
2610
- config: {
2611
- lang: options.lang ?? "es",
2612
- clientEntry: options.clientEntry
2613
- },
2614
- actions: publicActions,
2615
- request
2616
- });
2617
- body = rendered.body;
2618
- title = rendered.title;
2619
- lastRenderedCookie = rendered.clearActionErrorCookie;
2620
- lastRenderedHead = rendered.head;
2621
- await setCachedHtml(options.cacheDir, cacheKey, rendered.fullHtml ?? "", ttl);
2622
- }
2623
- } else {
2624
- const rendered = await renderPageBody({
2625
- routes,
2626
- pathname: page,
2627
- searchParams: new URLSearchParams(search),
2628
- config: {
2629
- lang: options.lang ?? "es",
2630
- clientEntry: options.clientEntry
2631
- },
2632
- actions: publicActions,
2633
- request
2634
- });
2635
- body = rendered.body;
2636
- title = rendered.title;
2637
- lastRenderedCookie = rendered.clearActionErrorCookie;
2638
- lastRenderedHead = rendered.head;
2639
- }
2640
- if (wantsJson) {
2641
- const headers = { "Content-Type": "application/json; charset=utf-8" };
2642
- const setCookie = lastRenderedCookie;
2643
- if (setCookie) headers["X-Elur-Action-Clear-Cookie"] = setCookie;
2644
- res.writeHead(200, headers);
2645
- res.end(JSON.stringify({
2646
- title,
2647
- body,
2648
- head: lastRenderedHead,
2649
- clearActionErrorCookie: setCookie
2650
- }));
2651
- } else {
2652
- const headers = { "Content-Type": "text/html; charset=utf-8" };
2653
- if (lastRenderedCookie) headers["Set-Cookie"] = lastRenderedCookie;
2654
- res.writeHead(200, headers);
2655
- res.end(body);
2656
- }
2657
- } catch (err) {
2658
- if (err instanceof RouteNotFoundError) {
2659
- console.log(`[ssr] render endpoint: no route for ${page}`);
2660
- res.writeHead(404, { "Content-Type": "text/plain" });
2661
- res.end("Not Found");
2662
- return;
2663
- }
2664
- console.error("[ssr] streaming render error", err);
2665
- res.writeHead(500, { "Content-Type": "text/plain" });
2666
- res.end("Internal Server Error");
2667
- }
2668
- return;
2669
- }
2670
- let middlewareHeaders;
2671
- if (middleware && matchesMiddleware(urlPath, middleware.config)) {
2672
- const mwResult = await runMiddleware(middleware, incomingMessageToRequest(req));
2673
- if (mwResult.kind === "response") {
2674
- res.writeHead(mwResult.response.status, Object.fromEntries(mwResult.response.headers.entries()));
2675
- res.end(Buffer.from(await mwResult.response.arrayBuffer()));
2676
- return;
2677
- }
2678
- middlewareHeaders = mwResult.headers;
2679
- }
2680
- const apiMatch = matchApiRoute(urlPath, routes.api);
2681
- if (apiMatch) {
2682
- try {
2683
- const handler = (await import(apiMatch.route.routePath))[req.method ?? "GET"];
2684
- if (typeof handler !== "function") {
2685
- res.writeHead(405, { "Content-Type": "text/plain" });
2686
- res.end(`Method not allowed: ${req.method}`);
2687
- return;
2688
- }
2689
- const request = incomingMessageToRequest(req, req.method && req.method !== "GET" && req.method !== "HEAD" ? await readRequestBody$1(req) : void 0);
2690
- applyHeaders(request.headers, middlewareHeaders);
2691
- const response = await handler(request, { params: apiMatch.params });
2692
- res.writeHead(response.status, Object.fromEntries(response.headers.entries()));
2693
- res.end(Buffer.from(await response.arrayBuffer()));
2694
- } catch (err) {
2695
- console.error("[api] error handling", urlPath, err);
2696
- res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
2697
- res.end(toPublicErrorInfo(err).message);
2698
- }
2699
- return;
2700
- }
2701
- if (options.publicDir) try {
2702
- if (await tryServeStatic(res, options.publicDir, urlPath)) return;
2703
- } catch (err) {
2704
- console.error("[static] error serving", urlPath, err);
2705
- }
2706
- const match = matchRoute(urlPath, routes.pages);
2707
- const config = {
2708
- lang: options.lang ?? "es",
2709
- clientEntry: options.clientEntry
2710
- };
2711
- if (match) try {
2712
- const request = incomingMessageToRequest(req);
2713
- applyHeaders(request.headers, middlewareHeaders);
2714
- let html;
2715
- let clearActionErrorCookie;
2716
- const ttl = (match.route.dataPath ? (await import(match.route.dataPath)).revalidate : void 0) ?? options.defaultRevalidate;
2717
- if (options.streaming !== false && match.route.loadingPath) html = await renderStreamingPage({
2718
- route: match.route,
2719
- params: match.params,
2720
- searchParams: new URLSearchParams(req.url?.split("?")[1] ?? ""),
2721
- config,
2722
- actions: publicActions,
2723
- request
2724
- });
2725
- else if (options.cacheDir && typeof ttl === "number" && canUsePublicCache(request)) {
2726
- const cacheKey = new URL(request.url).pathname + new URL(request.url).search;
2727
- const cached = await getCachedHtml(options.cacheDir, cacheKey);
2728
- if (cached) html = cached.html;
2729
- else {
2730
- const result = await renderPage({
2731
- route: match.route,
2732
- params: match.params,
2733
- searchParams: new URLSearchParams(req.url?.split("?")[1] ?? ""),
2734
- config,
2735
- actions: publicActions,
2736
- request
2737
- });
2738
- html = result.html;
2739
- clearActionErrorCookie = result.clearActionErrorCookie;
2740
- await setCachedHtml(options.cacheDir, cacheKey, html, ttl);
2741
- }
2742
- } else {
2743
- const result = await renderPage({
2744
- route: match.route,
2745
- params: match.params,
2746
- searchParams: new URLSearchParams(req.url?.split("?")[1] ?? ""),
2747
- config,
2748
- actions: publicActions,
2749
- request
2750
- });
2751
- html = result.html;
2752
- clearActionErrorCookie = result.clearActionErrorCookie;
2753
- }
2754
- const responseHeaders = { "Content-Type": "text/html; charset=utf-8" };
2755
- if (clearActionErrorCookie) responseHeaders["Set-Cookie"] = clearActionErrorCookie;
2756
- res.writeHead(200, responseHeaders);
2757
- res.end(html);
2758
- return;
2759
- } catch (err) {
2760
- console.error("[ssr] error rendering", urlPath, err);
2761
- const errorResult = await renderErrorPage({
2762
- routes,
2763
- status: 500,
2764
- error: err,
2765
- config,
2766
- actions: publicActions
2767
- });
2768
- if (errorResult) {
2769
- res.writeHead(errorResult.status, { "Content-Type": "text/html; charset=utf-8" });
2770
- res.end(errorResult.html);
2771
- } else {
2772
- res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
2773
- res.end(toPublicErrorInfo(err).message);
2774
- }
2775
- return;
2776
- }
2777
- const errorResult = await renderErrorPage({
2778
- routes,
2779
- status: 404,
2780
- config,
2781
- actions: publicActions
2782
- });
2783
- if (errorResult) {
2784
- res.writeHead(errorResult.status, { "Content-Type": "text/html; charset=utf-8" });
2785
- res.end(errorResult.html);
2786
- return;
2787
- }
2788
- res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
2789
- res.end(`Not found: ${req.url}`);
2790
- });
2791
- return {
2792
- server,
2793
- listen() {
2794
- return new Promise((resolve) => {
2795
- server.listen(options.port ?? 3e3, options.host ?? "127.0.0.1", () => {
2796
- console.log(`\n → SSR server http://${options.host ?? "127.0.0.1"}:${options.port ?? 3e3}`);
2797
- resolve();
2798
- });
2799
- });
2800
- },
2801
- close() {
2802
- return new Promise((resolve, reject) => {
2803
- server.close((err) => err ? reject(err) : resolve());
2804
- });
2805
- }
2806
- };
2807
- }
2808
- async function tryServeStatic(res, publicDir, urlPath) {
2809
- const filePath = await resolveStaticFile(publicDir, urlPath);
2810
- if (!filePath) return false;
2811
- const contentType = guessContentType$1(filePath);
2812
- let data = await (0, node_fs_promises.readFile)(filePath);
2813
- if (contentType.includes("text/html")) data = data.toString("utf8").replace("<meta name=\"elur:render-endpoint\" content=\"off\" />", "<meta name=\"elur:render-endpoint\" content=\"on\" />");
2814
- res.writeHead(200, {
2815
- "Content-Type": contentType,
2816
- "Content-Length": Buffer.byteLength(data)
2817
- });
2818
- res.end(data);
2819
- return true;
2820
- }
2821
- function canUsePublicCache(request) {
2822
- return !request.headers.has("cookie") && !request.headers.has("authorization");
2823
- }
2824
- function applyHeaders(headers, values) {
2825
- if (!values) return;
2826
- for (const [name, value] of Object.entries(values)) headers.set(name, value);
2827
- }
2828
- function readRequestBody$1(req) {
2829
- return new Promise((resolve, reject) => {
2830
- let body = "";
2831
- req.setEncoding("utf8");
2832
- req.on("data", (chunk) => {
2833
- body += chunk;
2834
- });
2835
- req.on("end", () => resolve(body));
2836
- req.on("error", reject);
2837
- });
2838
- }
2839
- function guessContentType$1(filePath) {
2840
- switch ((0, node_path.extname)(filePath)) {
2841
- case ".html": return "text/html; charset=utf-8";
2842
- case ".js":
2843
- case ".mjs": return "application/javascript; charset=utf-8";
2844
- case ".css": return "text/css; charset=utf-8";
2845
- case ".json": return "application/json; charset=utf-8";
2846
- case ".svg": return "image/svg+xml";
2847
- case ".png": return "image/png";
2848
- case ".jpg":
2849
- case ".jpeg": return "image/jpeg";
2850
- case ".webp": return "image/webp";
2851
- case ".avif": return "image/avif";
2852
- case ".ico": return "image/x-icon";
2853
- case ".woff": return "font/woff";
2854
- case ".woff2": return "font/woff2";
2855
- case ".wasm": return "application/wasm";
2856
- default: return "application/octet-stream";
2857
- }
3100
+ function isActionFailure(value) {
3101
+ return typeof value === "object" && value !== null && value.__elur_js_action_failure === true;
2858
3102
  }
2859
3103
  /**
2860
- * Maps a concrete page path (e.g. `/movies/inception`) to the route pattern
2861
- * key used by the action registry (e.g. `/movies/:slug`). Falls back to the
2862
- * path itself when it matches an exact registry key.
3104
+ * Type guard for redirects. Uses the marker field so it works across bundling
3105
+ * boundaries where `instanceof` fails.
2863
3106
  */
2864
- function resolveActionPageKey(page, routes) {
2865
- if (!page) return void 0;
2866
- if (routes.pages.some((route) => route.path === page)) return page;
2867
- const match = matchRoute(page, routes.pages);
2868
- return match ? match.route.path : page;
2869
- }
2870
- /** Resolves the ISR TTL for a page: route `revalidate` or the default. */
2871
- async function resolveTtl(options, pathname, routes) {
2872
- const match = matchRoute(pathname, routes.pages);
2873
- if (!match) return void 0;
2874
- return (match.route.dataPath ? (await import(match.route.dataPath)).revalidate : void 0) ?? options.defaultRevalidate;
2875
- }
2876
- function extractBody(fullHtml) {
2877
- const match = fullHtml.match(/<div id="app">([\s\S]*)<\/div>\s*(<script|$)/);
2878
- return match ? match[1].trim() : fullHtml;
2879
- }
2880
- function extractTitle(fullHtml) {
2881
- const match = fullHtml.match(/<title>([^<]*)<\/title>/);
2882
- return match ? match[1] : "";
2883
- }
2884
- //#endregion
2885
- //#region src/config/index.ts
2886
- async function loadElurConfig(options = {}) {
2887
- const initialRoot = (0, node_path.resolve)(options.root ?? process.cwd());
2888
- const configFile = options.configFile ? (0, node_path.resolve)(initialRoot, options.configFile) : await findConfigFile(initialRoot);
2889
- let loaded = {};
2890
- if (configFile) {
2891
- const result = await (0, vite.loadConfigFromFile)({
2892
- command: options.command === "build" ? "build" : "serve",
2893
- mode: options.mode ?? "development"
2894
- }, configFile, initialRoot);
2895
- if (!result) throw new Error(`[elur-kit] Could not load config: ${configFile}`);
2896
- loaded = result.config;
2897
- }
2898
- const merged = mergeConfig(loaded, options.overrides ?? {});
2899
- const root = (0, node_path.resolve)(initialRoot, merged.root ?? ".");
2900
- const resolved = resolveConfig(root, merged, configFile);
2901
- await runIntegrationHook(resolved.integrations, "config", [resolved, {
2902
- root,
2903
- command: options.command ?? "dev"
2904
- }]);
2905
- return resolved;
3107
+ function isRedirectResponse(value) {
3108
+ return typeof value === "object" && value !== null && value.__elur_js_action_redirect === true;
2906
3109
  }
2907
- function resolveConfig(root, config, configFile) {
2908
- if (config.site) new URL(config.site);
2909
- const base = normalizeBase(config.base ?? "/");
2910
- const imageQuality = config.images?.quality ?? 80;
2911
- if (!Number.isFinite(imageQuality) || imageQuality < 1 || imageQuality > 100) throw new Error("[elur-kit] images.quality must be between 1 and 100");
3110
+ /**
3111
+ * Maps an arbitrary thrown value to a public-safe error info. By default the
3112
+ * public message is generic; `includeDetail` (dev/verbose mode) appends the
3113
+ * original `Error.message` for local debugging.
3114
+ */
3115
+ function toPublicErrorInfo(error, options = {}) {
3116
+ const code = "INTERNAL_SERVER_ERROR";
3117
+ const status = 500;
3118
+ if (options.includeDetail && error instanceof Error && error.message) return {
3119
+ code,
3120
+ status,
3121
+ message: error.message
3122
+ };
2912
3123
  return {
2913
- root,
2914
- appDir: resolveInside(root, config.appDir ?? "src/app", "appDir"),
2915
- islandsDir: resolveInside(root, config.islandsDir ?? "src/islands", "islandsDir"),
2916
- contentDir: resolveInside(root, config.contentDir ?? "src/content", "contentDir"),
2917
- publicDir: resolveInside(root, config.publicDir ?? "public", "publicDir"),
2918
- outDir: resolveInside(root, config.outDir ?? "dist", "outDir"),
2919
- site: config.site,
2920
- base,
2921
- trailingSlash: config.trailingSlash ?? "ignore",
2922
- output: config.output ?? "static",
2923
- adapter: config.adapter,
2924
- images: {
2925
- formats: config.images?.formats ?? ["webp", "avif"],
2926
- quality: imageQuality,
2927
- strict: config.images?.strict ?? false
2928
- },
2929
- cache: {
2930
- dir: resolveInside(root, config.cache?.dir ?? ".elur/cache", "cache.dir"),
2931
- defaultRevalidate: config.cache?.defaultRevalidate
2932
- },
2933
- security: {
2934
- allowedOrigins: config.security?.allowedOrigins ?? [],
2935
- strictOrigin: config.security?.strictOrigin ?? false,
2936
- bodyLimit: config.security?.bodyLimit ?? 1048576,
2937
- headers: config.security?.headers === false ? false : config.security?.headers ?? {}
2938
- },
2939
- router: {
2940
- enabled: config.router?.enabled ?? true,
2941
- prefetch: config.router?.prefetch ?? true
2942
- },
2943
- integrations: config.integrations ?? [],
2944
- configFile
3124
+ code,
3125
+ status,
3126
+ message: "Internal Server Error"
2945
3127
  };
2946
3128
  }
2947
- function mergeConfig(base, override) {
2948
- return {
2949
- ...base,
2950
- ...override,
2951
- images: {
2952
- ...base.images,
2953
- ...override.images
2954
- },
2955
- cache: {
2956
- ...base.cache,
2957
- ...override.cache
2958
- },
2959
- security: {
2960
- ...base.security,
2961
- ...override.security
2962
- },
2963
- router: {
2964
- ...base.router,
2965
- ...override.router
2966
- },
2967
- integrations: override.integrations ?? base.integrations
3129
+ /**
3130
+ * Builds a production-safe JSON error Response. Logs the raw error separately
3131
+ * (never reflected in the response body) and keeps the request id header.
3132
+ */
3133
+ function publicErrorResponse(error, options = {}) {
3134
+ const info = toPublicErrorInfo(error, options);
3135
+ const headers = {
3136
+ "Content-Type": "application/json; charset=utf-8",
3137
+ "Cache-Control": "no-store"
2968
3138
  };
3139
+ if (options.requestId) headers["X-Request-Id"] = options.requestId;
3140
+ return new Response(JSON.stringify({ error: info }), {
3141
+ status: info.status,
3142
+ headers
3143
+ });
2969
3144
  }
2970
- function resolveInside(root, path, name) {
2971
- const resolved = (0, node_path.isAbsolute)(path) ? (0, node_path.resolve)(path) : (0, node_path.resolve)(root, path);
2972
- const rel = (0, node_path.relative)(root, resolved);
2973
- if (rel === ".." || rel.startsWith(`..${node_path.sep}`) || (0, node_path.isAbsolute)(rel)) throw new Error(`[elur-kit] ${name} must stay inside root: ${resolved}`);
2974
- return resolved;
3145
+ var init_errors = __esmMin((() => {}));
3146
+ //#endregion
3147
+ //#region src/action/origin.ts
3148
+ /**
3149
+ * Returns the host:port of a URL string, or undefined if it cannot be parsed.
3150
+ */
3151
+ function originOf(urlString) {
3152
+ if (!urlString) return void 0;
3153
+ try {
3154
+ const url = new URL(urlString);
3155
+ if (url.protocol !== "http:" && url.protocol !== "https:") return void 0;
3156
+ return url.origin;
3157
+ } catch {
3158
+ return;
3159
+ }
2975
3160
  }
2976
- function normalizeBase(base) {
2977
- if (!base.startsWith("/")) throw new Error("[elur-kit] base must start with /");
2978
- return base === "/" ? base : `${base.replace(/\/+$/, "")}/`;
3161
+ /**
3162
+ * Verifies that a request originates from the same host (or an allow-listed
3163
+ * origin). Returns an error message when the request must be rejected, or
3164
+ * undefined when it is allowed.
3165
+ *
3166
+ * @param request The incoming Request to actions.
3167
+ * @param options Origin check configuration.
3168
+ */
3169
+ function verifyOrigin(request, options = {}) {
3170
+ const targetOrigin = originOf(request.url);
3171
+ if (!targetOrigin) return "Invalid target URL";
3172
+ const origin = request.headers.get("Origin");
3173
+ const referer = request.headers.get("Referer");
3174
+ if (!origin && !referer) return options.strictOrigin ? "Missing Origin and Referer headers" : void 0;
3175
+ const sourceOrigin = origin ? originOf(origin) : originOf(referer);
3176
+ if (!sourceOrigin) return origin ? "Invalid Origin header" : "Invalid Referer header";
3177
+ if (sourceOrigin === targetOrigin) return void 0;
3178
+ if (options.allowedOrigins?.some((allowed) => originOf(allowed) === sourceOrigin)) return void 0;
3179
+ return `Cross-origin request blocked: source "${sourceOrigin}" != target "${targetOrigin}"`;
2979
3180
  }
2980
- var PREFERRED_CONFIG_FILES = [
2981
- "elur.config.ts",
2982
- "elur.config.js",
2983
- "elur.config.mjs"
2984
- ];
2985
- var LEGACY_CONFIG_FILES = [
2986
- "elur.config.ts",
2987
- "elur.config.js",
2988
- "elur.config.mjs"
2989
- ];
2990
- async function findConfigFile(root) {
2991
- for (const name of PREFERRED_CONFIG_FILES) {
2992
- const path = (0, node_path.resolve)(root, name);
3181
+ /** Builds a 403 Response for a rejected origin. */
3182
+ function originForbidden(message) {
3183
+ return new Response(message, {
3184
+ status: 403,
3185
+ headers: { "Content-Type": "text/plain; charset=utf-8" }
3186
+ });
3187
+ }
3188
+ var init_origin = __esmMin((() => {}));
3189
+ //#endregion
3190
+ //#region src/cache/adapter.ts
3191
+ var adapter_exports = /* @__PURE__ */ __exportAll({
3192
+ cacheKey: () => cacheKey,
3193
+ createFsCacheAdapter: () => createFsCacheAdapter,
3194
+ getWithSWR: () => getWithSWR
3195
+ });
3196
+ /** Computes a SHA-256 key from a normalized identity string. */
3197
+ function cacheKey(...parts) {
3198
+ return (0, node_crypto.createHash)("sha256").update(parts.join("\n")).digest("hex");
3199
+ }
3200
+ function createFsCacheAdapter(options) {
3201
+ const { cacheDir } = options;
3202
+ const maxEntries = options.maxEntries ?? 1e3;
3203
+ const maxAgeMs = options.maxAgeMs ?? 864e5;
3204
+ const inFlight = /* @__PURE__ */ new Map();
3205
+ const tagIndexPath = (0, node_path.join)(cacheDir, "_tag-index.json");
3206
+ async function loadTagIndex() {
2993
3207
  try {
2994
- await (0, node_fs_promises.access)(path);
2995
- return path;
2996
- } catch {}
3208
+ const raw = await (0, node_fs_promises.readFile)(tagIndexPath, "utf8");
3209
+ return JSON.parse(raw);
3210
+ } catch {
3211
+ return {};
3212
+ }
2997
3213
  }
2998
- for (const name of LEGACY_CONFIG_FILES) {
2999
- const path = (0, node_path.resolve)(root, name);
3214
+ async function saveTagIndex(index) {
3215
+ await (0, node_fs_promises.mkdir)((0, node_path.dirname)(tagIndexPath), { recursive: true });
3216
+ const tmp = `${tagIndexPath}.${process.pid}.${(0, node_crypto.randomUUID)()}.tmp`;
3000
3217
  try {
3001
- await (0, node_fs_promises.access)(path);
3002
- console.warn(`[elur-kit] "${name}" is deprecated and will be removed in a future release. Rename it to "elur.config.${name.split(".").slice(1).join(".")}" to keep your config working.`);
3003
- return path;
3004
- } catch {}
3005
- }
3006
- }
3007
- //#endregion
3008
- //#region src/manifest/index.ts
3009
- init_scan();
3010
- init_route_scanner();
3011
- async function createAppManifest(config) {
3012
- const [routes, actions, islands] = await Promise.all([
3013
- scanRoutes(config.appDir),
3014
- scanActions(config.appDir),
3015
- scanIslands(config.islandsDir)
3016
- ]);
3017
- validateManifestRoutes(routes);
3018
- validateIslands(islands);
3019
- const manifest = {
3020
- version: 1,
3021
- root: config.root,
3022
- routes,
3023
- actions,
3024
- islands,
3025
- base: config.base,
3026
- output: config.output
3027
- };
3028
- await runIntegrationHook(config.integrations, "routes", [manifest, {
3029
- root: config.root,
3030
- command: "build"
3031
- }]);
3032
- return manifest;
3033
- }
3034
- async function writeAppManifest(manifest, path) {
3035
- await (0, node_fs_promises.mkdir)((0, node_path.dirname)(path), { recursive: true });
3036
- await (0, node_fs_promises.writeFile)(path, JSON.stringify(toPortableManifest(manifest), null, 2), "utf8");
3037
- }
3038
- async function writeRouteTypes(manifest, path) {
3039
- const routePaths = manifest.routes.pages.map((route) => JSON.stringify(route.path));
3040
- const actionNames = Object.values(manifest.actions).flatMap((actions) => Object.keys(actions)).filter((name, index, names) => names.indexOf(name) === index).map((name) => JSON.stringify(name));
3041
- const source = [
3042
- `export type ElurRoutePath = ${routePaths.length ? routePaths.join(" | ") : "never"};`,
3043
- `export type ElurActionName = ${actionNames.length ? actionNames.join(" | ") : "never"};`,
3044
- "export interface ElurRouteParams { [name: string]: string | string[] | undefined }",
3045
- ""
3046
- ].join("\n");
3047
- await (0, node_fs_promises.mkdir)((0, node_path.dirname)(path), { recursive: true });
3048
- await (0, node_fs_promises.writeFile)(path, source, "utf8");
3049
- }
3050
- function validateManifestRoutes(routes) {
3051
- const seen = /* @__PURE__ */ new Map();
3052
- for (const route of routes.pages) {
3053
- registerRoute(seen, route.path, route.pagePath, "page");
3054
- assertNotReserved(route.path, route.pagePath);
3218
+ await (0, node_fs_promises.writeFile)(tmp, JSON.stringify(index), "utf8");
3219
+ await (0, node_fs_promises.rename)(tmp, tagIndexPath);
3220
+ } finally {
3221
+ await (0, node_fs_promises.rm)(tmp, { force: true });
3222
+ }
3055
3223
  }
3056
- for (const route of routes.api) {
3057
- registerRoute(seen, route.path, route.routePath, "API");
3058
- assertNotReserved(route.path, route.routePath);
3224
+ function entryPath(key) {
3225
+ return (0, node_path.join)(cacheDir, `${key}.html.json`);
3059
3226
  }
3060
- }
3061
- function registerRoute(seen, path, file, kind) {
3062
- const existing = seen.get(path);
3063
- if (existing) throw new Error(`[elur-kit] Duplicate ${kind} route "${path}": ${existing} and ${file}`);
3064
- seen.set(path, file);
3065
- }
3066
- function assertNotReserved(path, file) {
3067
- if (path === "/__elur-js" || path.startsWith("/__elur-js/") || path === "/_elur" || path.startsWith("/_elur/")) throw new Error(`[elur-kit] Reserved route "${path}" declared by ${file}`);
3068
- }
3069
- function validateIslands(islands) {
3070
- const names = /* @__PURE__ */ new Set();
3071
- for (const island of islands) {
3072
- if (names.has(island.name)) throw new Error(`[elur-kit] Duplicate island name: ${island.name}`);
3073
- names.add(island.name);
3227
+ async function get(key) {
3228
+ const existing = inFlight.get(key);
3229
+ if (existing) return existing;
3230
+ const promise = (async () => {
3231
+ try {
3232
+ const raw = await (0, node_fs_promises.readFile)(entryPath(key), "utf8");
3233
+ return JSON.parse(raw);
3234
+ } catch {
3235
+ return null;
3236
+ }
3237
+ })();
3238
+ inFlight.set(key, promise);
3239
+ try {
3240
+ return await promise;
3241
+ } finally {
3242
+ inFlight.delete(key);
3243
+ }
3244
+ }
3245
+ async function set(key, value, opts) {
3246
+ const path = entryPath(key);
3247
+ await (0, node_fs_promises.mkdir)((0, node_path.dirname)(path), { recursive: true });
3248
+ const entry = {
3249
+ html: value.html,
3250
+ generatedAt: value.generatedAt ?? Date.now(),
3251
+ revalidate: opts.revalidate,
3252
+ tags: opts.tags,
3253
+ version: opts.version
3254
+ };
3255
+ const tmp = `${path}.${process.pid}.${(0, node_crypto.randomUUID)()}.tmp`;
3256
+ try {
3257
+ await (0, node_fs_promises.writeFile)(tmp, JSON.stringify(entry), "utf8");
3258
+ await (0, node_fs_promises.rename)(tmp, path);
3259
+ } finally {
3260
+ await (0, node_fs_promises.rm)(tmp, { force: true });
3261
+ }
3262
+ if (opts.tags && opts.tags.length > 0) {
3263
+ const index = await loadTagIndex();
3264
+ for (const tag of opts.tags) {
3265
+ if (!index[tag]) index[tag] = [];
3266
+ if (!index[tag].includes(key)) index[tag].push(key);
3267
+ }
3268
+ await saveTagIndex(index);
3269
+ }
3270
+ await maybeCleanup();
3271
+ }
3272
+ async function del(key) {
3273
+ await (0, node_fs_promises.rm)(entryPath(key), { force: true });
3274
+ }
3275
+ async function invalidateTags(tags) {
3276
+ if (tags.length === 0) return;
3277
+ const index = await loadTagIndex();
3278
+ const keysToDelete = /* @__PURE__ */ new Set();
3279
+ for (const tag of tags) {
3280
+ const keys = index[tag];
3281
+ if (keys) {
3282
+ for (const key of keys) keysToDelete.add(key);
3283
+ delete index[tag];
3284
+ }
3285
+ }
3286
+ await Promise.all([...keysToDelete].map((key) => del(key)));
3287
+ await saveTagIndex(index);
3288
+ }
3289
+ let lastCleanup = 0;
3290
+ async function maybeCleanup() {
3291
+ const now = Date.now();
3292
+ if (now - lastCleanup < 6e4) return;
3293
+ lastCleanup = now;
3294
+ try {
3295
+ const files = await (0, node_fs_promises.readdir)(cacheDir);
3296
+ let entryCount = 0;
3297
+ const toDelete = [];
3298
+ for (const file of files) {
3299
+ if (!file.endsWith(".html.json")) continue;
3300
+ entryCount++;
3301
+ const filePath = (0, node_path.join)(cacheDir, file);
3302
+ try {
3303
+ if (now - (await (0, node_fs_promises.stat)(filePath)).mtimeMs > maxAgeMs) toDelete.push(filePath);
3304
+ } catch {}
3305
+ }
3306
+ if (entryCount - toDelete.length > maxEntries) {
3307
+ const candidates = [];
3308
+ for (const file of files) {
3309
+ if (!file.endsWith(".html.json")) continue;
3310
+ const filePath = (0, node_path.join)(cacheDir, file);
3311
+ if (toDelete.includes(filePath)) continue;
3312
+ try {
3313
+ const stats = await (0, node_fs_promises.stat)(filePath);
3314
+ candidates.push({
3315
+ path: filePath,
3316
+ mtime: stats.mtimeMs
3317
+ });
3318
+ } catch {}
3319
+ }
3320
+ candidates.sort((a, b) => a.mtime - b.mtime);
3321
+ const excess = entryCount - toDelete.length - maxEntries;
3322
+ for (let i = 0; i < excess && i < candidates.length; i++) toDelete.push(candidates[i].path);
3323
+ }
3324
+ await Promise.all(toDelete.map((p) => (0, node_fs_promises.rm)(p, { force: true })));
3325
+ } catch {}
3074
3326
  }
3075
- }
3076
- function toPortableManifest(manifest) {
3077
- const relativePath = (path) => path ? (0, node_path.relative)(manifest.root, path).split("\\").join("/") : void 0;
3078
- const routes = {
3079
- pages: manifest.routes.pages.map((route) => ({
3080
- ...route,
3081
- pagePath: relativePath(route.pagePath),
3082
- dataPath: relativePath(route.dataPath),
3083
- actionPath: relativePath(route.actionPath),
3084
- loadingPath: relativePath(route.loadingPath),
3085
- layouts: route.layouts.map((layout) => relativePath(layout))
3086
- })),
3087
- api: manifest.routes.api.map((route) => ({
3088
- ...route,
3089
- routePath: relativePath(route.routePath)
3090
- })),
3091
- error404: manifest.routes.error404 ? {
3092
- ...manifest.routes.error404,
3093
- pagePath: relativePath(manifest.routes.error404.pagePath),
3094
- dataPath: relativePath(manifest.routes.error404.dataPath),
3095
- actionPath: relativePath(manifest.routes.error404.actionPath),
3096
- loadingPath: relativePath(manifest.routes.error404.loadingPath),
3097
- layouts: manifest.routes.error404.layouts.map((layout) => relativePath(layout))
3098
- } : void 0,
3099
- error500: manifest.routes.error500 ? {
3100
- ...manifest.routes.error500,
3101
- pagePath: relativePath(manifest.routes.error500.pagePath),
3102
- dataPath: relativePath(manifest.routes.error500.dataPath),
3103
- actionPath: relativePath(manifest.routes.error500.actionPath),
3104
- loadingPath: relativePath(manifest.routes.error500.loadingPath),
3105
- layouts: manifest.routes.error500.layouts.map((layout) => relativePath(layout))
3106
- } : void 0
3107
- };
3108
- const actions = {};
3109
- for (const [page, pageActions] of Object.entries(manifest.actions)) actions[page] = Object.fromEntries(Object.entries(pageActions).map(([name, path]) => [name, relativePath(path)]));
3110
3327
  return {
3111
- ...manifest,
3112
- root: ".",
3113
- routes,
3114
- actions,
3115
- islands: manifest.islands.map((island) => ({
3116
- ...island,
3117
- filePath: relativePath(island.filePath)
3118
- }))
3328
+ get,
3329
+ set,
3330
+ delete: del,
3331
+ invalidateTags
3119
3332
  };
3120
3333
  }
3121
- //#endregion
3122
- //#region src/runtime/capabilities.ts
3123
- /** True when the host can write to persistent storage (for ISR/cache/image writes). */
3124
- function supportsPersistentStorage(capabilities) {
3125
- return capabilities.filesystem === "persistent";
3126
- }
3127
3334
  /**
3128
- * Validates a capability declaration and reports incompatible combinations.
3129
- * Used by the build pipeline so invalid hosts fail at build time instead of
3130
- * producing a broken runtime.
3335
+ * Gets a cached entry. If the entry is stale (past revalidate), serves it
3336
+ * immediately and triggers a background revalidation.
3337
+ *
3338
+ * @param adapter The cache adapter.
3339
+ * @param key The cache key.
3340
+ * @param revalidate The revalidation function (called if stale or missing).
3341
+ * @returns The cache entry (fresh or stale), or null if missing.
3131
3342
  */
3132
- function validateCapabilities(capabilities, features = {}) {
3133
- const problems = [];
3134
- if (features.isr && !supportsPersistentStorage(capabilities)) problems.push(`ISR requires a persistent filesystem; the host declares filesystem="${capabilities.filesystem}".`);
3135
- if (features.images && capabilities.imageRuntime === false && capabilities.filesystem === "none") problems.push("On-demand image transforms require either imageRuntime=true or a readable filesystem; the host has neither.");
3136
- if (features.streaming && capabilities.streaming === false) problems.push("Streaming was requested but the host declares streaming=false.");
3343
+ async function getWithSWR(adapter, key, revalidate) {
3344
+ const entry = await adapter.get(key);
3345
+ if (!entry) return {
3346
+ entry: await revalidate(),
3347
+ stale: false
3348
+ };
3349
+ if (Date.now() - entry.generatedAt >= entry.revalidate * 1e3) {
3350
+ revalidate().then((fresh) => {
3351
+ if (fresh) adapter.set(key, fresh, {
3352
+ revalidate: entry.revalidate,
3353
+ tags: entry.tags,
3354
+ version: entry.version
3355
+ }).catch((err) => {
3356
+ console.error("[elur-kit] background cache write failed:", err);
3357
+ });
3358
+ }, (err) => {
3359
+ console.error("[elur-kit] background revalidation failed:", err);
3360
+ });
3361
+ return {
3362
+ entry,
3363
+ stale: true
3364
+ };
3365
+ }
3137
3366
  return {
3138
- ok: problems.length === 0,
3139
- problems
3367
+ entry,
3368
+ stale: false
3140
3369
  };
3141
3370
  }
3142
- var DEFAULT_CAPABILITIES, SERVERLESS_CAPABILITIES;
3143
- var init_capabilities = __esmMin((() => {
3144
- DEFAULT_CAPABILITIES = {
3145
- streaming: true,
3146
- filesystem: "persistent",
3147
- imageRuntime: true,
3148
- backgroundWork: true
3149
- };
3150
- SERVERLESS_CAPABILITIES = {
3151
- streaming: true,
3152
- filesystem: "ephemeral",
3153
- imageRuntime: false,
3154
- backgroundWork: false,
3155
- maxBodySize: 1048576
3371
+ var init_adapter = __esmMin((() => {}));
3372
+ //#endregion
3373
+ //#region src/cache/invalidation.ts
3374
+ /**
3375
+ * Connects a CacheAdapter to the default invalidator so that tag/path
3376
+ * invalidation events from actions are dispatched to the cache.
3377
+ *
3378
+ * Returns an unsubscribe function.
3379
+ */
3380
+ function connectCacheAdapter(adapter, invalidator = defaultInvalidator) {
3381
+ return invalidator.on(async (event) => {
3382
+ if (event.tags && event.tags.length > 0) await adapter.invalidateTags(event.tags);
3383
+ if (event.paths && event.paths.length > 0 && adapter.delete) {
3384
+ const { cacheKey } = await Promise.resolve().then(() => (init_adapter(), adapter_exports));
3385
+ await Promise.all(event.paths.map((p) => adapter.delete(cacheKey(p))));
3386
+ }
3387
+ });
3388
+ }
3389
+ var CacheInvalidator, defaultInvalidator;
3390
+ var init_invalidation = __esmMin((() => {
3391
+ CacheInvalidator = class {
3392
+ listeners = /* @__PURE__ */ new Set();
3393
+ /** Registers a listener for invalidation events. Returns an unsubscribe function. */
3394
+ on(listener) {
3395
+ this.listeners.add(listener);
3396
+ return () => this.listeners.delete(listener);
3397
+ }
3398
+ /** Emits an invalidation event to all listeners. */
3399
+ async emit(event) {
3400
+ const promises = [];
3401
+ for (const listener of this.listeners) try {
3402
+ const result = listener(event);
3403
+ if (result instanceof Promise) promises.push(result.catch((err) => {
3404
+ console.error("[elur-kit] invalidation listener error:", err);
3405
+ }));
3406
+ } catch (err) {
3407
+ console.error("[elur-kit] invalidation listener error:", err);
3408
+ }
3409
+ await Promise.all(promises);
3410
+ }
3411
+ /** Convenience: invalidate by tags. */
3412
+ async invalidateTags(tags, source) {
3413
+ if (tags.length === 0) return;
3414
+ await this.emit({
3415
+ tags,
3416
+ source
3417
+ });
3418
+ }
3419
+ /** Convenience: invalidate by paths. */
3420
+ async invalidatePaths(paths, source) {
3421
+ if (paths.length === 0) return;
3422
+ await this.emit({
3423
+ paths,
3424
+ source
3425
+ });
3426
+ }
3427
+ /** Removes all listeners. */
3428
+ clear() {
3429
+ this.listeners.clear();
3430
+ }
3156
3431
  };
3432
+ defaultInvalidator = new CacheInvalidator();
3157
3433
  }));
3158
3434
  //#endregion
3159
- //#region src/build/vite-build.ts
3160
- var vite_build_exports = /* @__PURE__ */ __exportAll({
3161
- beginAtomicStage: () => beginAtomicStage,
3162
- buildClientBundle: () => buildClientBundle,
3163
- copyPublicAssets: () => copyPublicAssets
3164
- });
3435
+ //#region src/action/server.ts
3165
3436
  /**
3166
- * Build the client hydration bundle using the Vite JavaScript API.
3167
- *
3168
- * The user's config is loaded programmatically and the elur interpolation
3169
- * plugin is injected so partial attribute interpolations inside islands are
3170
- * transformed before reaching the browser.
3437
+ * Reads the request body as text, enforcing a maximum size.
3438
+ * Returns a 413 response if the body exceeds the limit.
3171
3439
  */
3172
- async function buildClientBundle(options) {
3173
- const log = options.logPrefix ?? "[client]";
3174
- console.log(`${log} Building hydration bundle...`);
3175
- const userConfig = await loadUserConfig(options.userConfigPath, options.root);
3176
- const pluginOptions = shouldUseLegacyInterpolation(options.interpolation ?? "auto") ? elurJsInterpolationPlugin({
3177
- appDir: options.appDir,
3178
- islandsDir: options.islandsDir
3179
- }) : [];
3180
- const config = {
3181
- ...userConfig,
3182
- root: options.root,
3183
- base: options.base ?? userConfig.base ?? "/",
3184
- build: {
3185
- ...userConfig.build ?? {},
3186
- outDir: options.outDir,
3187
- emptyOutDir: true
3188
- },
3189
- plugins: [...userConfig.plugins ?? [], pluginOptions],
3190
- configFile: false
3440
+ async function readBodyWithLimit(request, limit) {
3441
+ const contentLength = request.headers.get("Content-Length");
3442
+ if (contentLength && parseInt(contentLength, 10) > limit) return {
3443
+ ok: false,
3444
+ response: new Response("Request body too large", {
3445
+ status: 413,
3446
+ headers: { "Content-Type": "text/plain" }
3447
+ })
3448
+ };
3449
+ const reader = request.body?.getReader();
3450
+ if (!reader) return {
3451
+ ok: true,
3452
+ text: ""
3453
+ };
3454
+ const chunks = [];
3455
+ let totalSize = 0;
3456
+ try {
3457
+ for (;;) {
3458
+ const { done, value } = await reader.read();
3459
+ if (done) break;
3460
+ totalSize += value.byteLength;
3461
+ if (totalSize > limit) {
3462
+ try {
3463
+ reader.cancel();
3464
+ } catch {}
3465
+ return {
3466
+ ok: false,
3467
+ response: new Response("Request body too large", {
3468
+ status: 413,
3469
+ headers: { "Content-Type": "text/plain" }
3470
+ })
3471
+ };
3472
+ }
3473
+ chunks.push(value);
3474
+ }
3475
+ } finally {
3476
+ try {
3477
+ reader.releaseLock();
3478
+ } catch {}
3479
+ }
3480
+ const total = new Uint8Array(totalSize);
3481
+ let offset = 0;
3482
+ for (const chunk of chunks) {
3483
+ total.set(chunk, offset);
3484
+ offset += chunk.byteLength;
3485
+ }
3486
+ return {
3487
+ ok: true,
3488
+ text: new TextDecoder().decode(total)
3489
+ };
3490
+ }
3491
+ function parseFormBody(body) {
3492
+ const params = new URLSearchParams(body);
3493
+ const result = {};
3494
+ for (const [key, value] of params) if (result[key] === void 0) result[key] = value;
3495
+ else if (Array.isArray(result[key])) result[key].push(value);
3496
+ else result[key] = [result[key], value];
3497
+ return result;
3498
+ }
3499
+ async function parseActionRequest(request, bodyLimit = DEFAULT_BODY_LIMIT) {
3500
+ if (request.method !== "POST") return {
3501
+ ok: false,
3502
+ response: new Response("Method not allowed", {
3503
+ status: 405,
3504
+ headers: { "Content-Type": "text/plain" }
3505
+ })
3506
+ };
3507
+ const contentType = request.headers.get("Content-Type") ?? "";
3508
+ const wantsJson = (request.headers.get("Accept") ?? "").includes("application/json");
3509
+ let name;
3510
+ let page;
3511
+ let args = [];
3512
+ if (contentType.includes("application/json")) {
3513
+ const bodyResult = await readBodyWithLimit(request, bodyLimit);
3514
+ if (!bodyResult.ok) return {
3515
+ ok: false,
3516
+ response: bodyResult.response
3517
+ };
3518
+ let body;
3519
+ try {
3520
+ body = JSON.parse(bodyResult.text);
3521
+ } catch {
3522
+ return {
3523
+ ok: false,
3524
+ response: new Response("Invalid JSON body", {
3525
+ status: 400,
3526
+ headers: { "Content-Type": "text/plain" }
3527
+ })
3528
+ };
3529
+ }
3530
+ name = body.name;
3531
+ page = body.page;
3532
+ args = Array.isArray(body.args) ? body.args : [];
3533
+ } else if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
3534
+ if (contentType.includes("multipart/form-data")) {
3535
+ const contentLength = request.headers.get("Content-Length");
3536
+ if (contentLength && parseInt(contentLength, 10) > bodyLimit) return {
3537
+ ok: false,
3538
+ response: new Response("Request body too large", {
3539
+ status: 413,
3540
+ headers: { "Content-Type": "text/plain" }
3541
+ })
3542
+ };
3543
+ let form;
3544
+ try {
3545
+ form = await request.formData();
3546
+ } catch {
3547
+ return {
3548
+ ok: false,
3549
+ response: new Response("Invalid form body", {
3550
+ status: 400,
3551
+ headers: { "Content-Type": "text/plain" }
3552
+ })
3553
+ };
3554
+ }
3555
+ name = form.get("__elur_js_action_name") ?? void 0;
3556
+ page = form.get("__elur_js_action_page") ?? void 0;
3557
+ const input = {};
3558
+ for (const [key, value] of form) {
3559
+ if (key === "__elur_js_action_name" || key === "__elur_js_action_page") continue;
3560
+ input[key] = value;
3561
+ }
3562
+ args = [input];
3563
+ } else {
3564
+ const bodyResult = await readBodyWithLimit(request, bodyLimit);
3565
+ if (!bodyResult.ok) return {
3566
+ ok: false,
3567
+ response: bodyResult.response
3568
+ };
3569
+ const form = parseFormBody(bodyResult.text);
3570
+ name = form.__elur_js_action_name;
3571
+ page = form.__elur_js_action_page;
3572
+ const input = {};
3573
+ for (const [key, value] of Object.entries(form)) {
3574
+ if (key === "__elur_js_action_name" || key === "__elur_js_action_page") continue;
3575
+ input[key] = value;
3576
+ }
3577
+ args = [input];
3578
+ }
3579
+ } else {
3580
+ const bodyResult = await readBodyWithLimit(request, bodyLimit);
3581
+ if (!bodyResult.ok) return {
3582
+ ok: false,
3583
+ response: bodyResult.response
3584
+ };
3585
+ const form = parseFormBody(bodyResult.text);
3586
+ name = form.__elur_js_action_name;
3587
+ page = form.__elur_js_action_page;
3588
+ const input = {};
3589
+ for (const [key, value] of Object.entries(form)) {
3590
+ if (key === "__elur_js_action_name" || key === "__elur_js_action_page") continue;
3591
+ input[key] = value;
3592
+ }
3593
+ args = [input];
3594
+ }
3595
+ if (!name || typeof name !== "string") return {
3596
+ ok: false,
3597
+ response: new Response("Missing action name", {
3598
+ status: 400,
3599
+ headers: { "Content-Type": "text/plain" }
3600
+ })
3191
3601
  };
3192
- const result = await (0, vite.build)(config);
3193
- const outputCount = (Array.isArray(result) ? result : [result]).reduce((n, r) => n + ("output" in r ? r.output?.length ?? 0 : 0), 0);
3194
- console.log(`${log} ✓ ${outputCount} asset(s) emitted → ${(0, node_path.relative)(options.root, options.outDir)}`);
3195
3602
  return {
3196
- outDir: options.outDir,
3197
- outputCount
3603
+ ok: true,
3604
+ name,
3605
+ page,
3606
+ args,
3607
+ wantsJson
3198
3608
  };
3199
3609
  }
3200
- async function loadUserConfig(path, _root) {
3201
- const mod = await import(path);
3202
- const raw = mod.default ?? mod;
3203
- const resolved = typeof raw === "function" ? await raw({
3204
- command: "build",
3205
- mode: "production"
3206
- }) : raw;
3207
- return (resolved && typeof resolved.then === "function" ? await resolved : resolved) ?? {};
3208
- }
3209
3610
  /**
3210
- * Prepare an atomic staging directory for build output.
3611
+ * Handles a POST request to the server action endpoint.
3211
3612
  *
3212
- * Usage:
3213
- * const stage = await beginAtomicStage({ outDir });
3214
- * try {
3215
- * await buildInto(stage.tempDir);
3216
- * await stage.commit();
3217
- * } catch (err) {
3218
- * await stage.rollback();
3219
- * throw err;
3220
- * }
3613
+ * Accepts both JSON requests (`{ name, page?, args }`) and HTML form submissions
3614
+ * for progressive enhancement. The provided resolver looks up the action
3615
+ * implementation, invokes it with the supplied arguments and returns the result
3616
+ * as JSON or redirects back to the request origin for form submissions.
3617
+ *
3618
+ * Origin verification (CSRF protection) runs before parsing the body: any
3619
+ * cross-origin POST is rejected with 403 unless its origin is allow-listed via
3620
+ * `security.allowedOrigins`.
3621
+ *
3622
+ * For progressive-enhancement form submissions that fail, the failure payload
3623
+ * is relayed back via a short-lived `__elur_js_action_error` cookie (SameSite=Lax,
3624
+ * Max-Age=15s) instead of a query param, so errors do not leak into browser
3625
+ * history, server logs or third-party Referer headers.
3221
3626
  */
3222
- async function beginAtomicStage(options) {
3223
- const outDir = (0, node_path.resolve)(options.outDir);
3224
- const tempDir = (0, node_path.resolve)(options.tempDir ?? (0, node_path.join)((0, node_path.dirname)(outDir), `.${basename(outDir)}.tmp-${process.pid}`));
3225
- await (0, node_fs_promises.rm)(tempDir, {
3226
- recursive: true,
3227
- force: true
3228
- });
3229
- await (0, node_fs_promises.mkdir)(tempDir, { recursive: true });
3230
- const commit = async () => {
3231
- const backup = options.keepExisting && (0, node_fs.existsSync)(outDir) ? `${outDir}.bak-${process.pid}` : void 0;
3232
- if (backup) {
3233
- await (0, node_fs_promises.rm)(backup, {
3234
- recursive: true,
3235
- force: true
3627
+ async function handleActionRequest(request, resolveAction, security = {}) {
3628
+ const originError = verifyOrigin(request, security);
3629
+ if (originError) return originForbidden(originError);
3630
+ const parsed = await parseActionRequest(request, security.bodyLimit ?? DEFAULT_BODY_LIMIT);
3631
+ if (!parsed.ok) return parsed.response;
3632
+ const { name, page, args, wantsJson } = parsed;
3633
+ try {
3634
+ const action = await resolveAction(name, page);
3635
+ if (!action) {
3636
+ const message = page ? `Action not found: ${name} (page: ${page})` : `Action not found: ${name}`;
3637
+ return new Response(message, {
3638
+ status: 404,
3639
+ headers: { "Content-Type": "text/plain" }
3236
3640
  });
3237
- await safeRename(outDir, backup);
3238
3641
  }
3239
- try {
3240
- await safeRename(tempDir, outDir);
3241
- } catch (err) {
3242
- if (isCrossDevice(err)) {
3243
- await (0, node_fs_promises.cp)(tempDir, outDir, {
3244
- recursive: true,
3245
- force: true
3246
- });
3247
- await (0, node_fs_promises.rm)(tempDir, {
3248
- recursive: true,
3249
- force: true
3250
- });
3251
- } else {
3252
- if (backup) await safeRename(backup, outDir);
3253
- throw err;
3254
- }
3642
+ const actionMeta = action.__elurAction;
3643
+ let result;
3644
+ if (actionMeta) {
3645
+ const ctx = {
3646
+ request,
3647
+ signal: request.signal,
3648
+ idempotencyKey: request.headers.get("Idempotency-Key") ?? void 0,
3649
+ params: {},
3650
+ locals: {}
3651
+ };
3652
+ result = await action(args[0], ctx);
3653
+ } else result = await action(...args);
3654
+ if (!isActionFailure(result) && actionMeta) {
3655
+ const tags = actionMeta.invalidateTags ?? [];
3656
+ const paths = actionMeta.invalidatePaths ?? [];
3657
+ if (tags.length > 0 || paths.length > 0) await defaultInvalidator.emit({
3658
+ tags,
3659
+ paths,
3660
+ source: name
3661
+ });
3255
3662
  }
3256
- if (backup) await (0, node_fs_promises.rm)(backup, {
3257
- recursive: true,
3258
- force: true
3663
+ if (isActionFailure(result)) {
3664
+ if (wantsJson) return new Response(JSON.stringify({
3665
+ __elur_js_action_failure: true,
3666
+ status: result.status,
3667
+ data: result.data
3668
+ }), {
3669
+ status: result.status,
3670
+ headers: { "Content-Type": "application/json" }
3671
+ });
3672
+ const referer = request.headers.get("Referer") ?? "/";
3673
+ const url = new URL(referer, "http://localhost");
3674
+ const { value } = encodeActionErrorCookie(result.data, result.status);
3675
+ return new Response(null, {
3676
+ status: 303,
3677
+ headers: {
3678
+ Location: url.pathname + url.search,
3679
+ "Content-Type": "text/plain",
3680
+ "Set-Cookie": setActionErrorCookieHeader(value)
3681
+ }
3682
+ });
3683
+ }
3684
+ if (isRedirectResponse(result)) {
3685
+ if (wantsJson) return new Response(JSON.stringify({
3686
+ __elur_js_action_redirect: true,
3687
+ status: result.status,
3688
+ location: result.location
3689
+ }), {
3690
+ status: 200,
3691
+ headers: { "Content-Type": "application/json" }
3692
+ });
3693
+ return new Response(null, {
3694
+ status: result.status,
3695
+ headers: {
3696
+ Location: result.location,
3697
+ "Content-Type": "text/plain"
3698
+ }
3699
+ });
3700
+ }
3701
+ if (wantsJson) return new Response(JSON.stringify(result ?? null), {
3702
+ status: 200,
3703
+ headers: { "Content-Type": "application/json" }
3259
3704
  });
3260
- };
3261
- const rollback = async () => {
3262
- await (0, node_fs_promises.rm)(tempDir, {
3263
- recursive: true,
3264
- force: true
3705
+ const referer = request.headers.get("Referer") ?? "/";
3706
+ return new Response(null, {
3707
+ status: 303,
3708
+ headers: {
3709
+ Location: typeof result === "string" ? result : referer,
3710
+ "Content-Type": "text/plain"
3711
+ }
3265
3712
  });
3266
- };
3267
- return {
3268
- tempDir,
3269
- commit,
3270
- rollback
3271
- };
3272
- }
3273
- function basename(path) {
3274
- const parts = path.split(/[\\/]+/).filter(Boolean);
3275
- return parts[parts.length - 1] ?? "output";
3276
- }
3277
- async function safeRename(src, dest) {
3278
- await (0, node_fs_promises.rm)(dest, {
3279
- recursive: true,
3280
- force: true
3281
- });
3282
- try {
3283
- await (0, node_fs_promises.rename)(src, dest);
3284
3713
  } catch (err) {
3285
- if (isCrossDevice(err)) {
3286
- await (0, node_fs_promises.cp)(src, dest, {
3287
- recursive: true,
3288
- force: true
3289
- });
3290
- await (0, node_fs_promises.rm)(src, {
3291
- recursive: true,
3292
- force: true
3293
- });
3294
- } else throw err;
3714
+ console.error("[elur-kit] Action error:", err);
3715
+ return publicErrorResponse(err, { includeDetail: false });
3295
3716
  }
3296
3717
  }
3297
- function isCrossDevice(err) {
3298
- return err?.code === "EXDEV";
3299
- }
3718
+ var DEFAULT_BODY_LIMIT;
3719
+ var init_server = __esmMin((() => {
3720
+ init_errors();
3721
+ init_origin();
3722
+ init_invalidation();
3723
+ init_error_store();
3724
+ DEFAULT_BODY_LIMIT = 1048576;
3725
+ }));
3726
+ //#endregion
3727
+ //#region src/ssr/stream.ts
3300
3728
  /**
3301
- * Copy the public directory into the output directory.
3302
- * Returns the number of files copied.
3729
+ * Render only the inner HTML body for a page. Used by the streaming endpoint
3730
+ * to inject the real content into the shell.
3303
3731
  */
3304
- async function copyPublicAssets(options) {
3732
+ async function renderPageBody(options) {
3733
+ const { routes, pathname, searchParams, config, actions, importer = defaultImport$1, request } = options;
3734
+ const match = matchRoute(pathname, routes.pages);
3735
+ if (!match) throw new RouteNotFoundError(pathname);
3736
+ const result = await renderPage({
3737
+ route: match.route,
3738
+ params: match.params,
3739
+ searchParams,
3740
+ config,
3741
+ actions,
3742
+ importer,
3743
+ request
3744
+ });
3745
+ if (result.response) return {
3746
+ body: "",
3747
+ title: "",
3748
+ response: result.response
3749
+ };
3750
+ const body = extractAppBody(result.html)?.trim() ?? result.html.match(/<div id="app">([\s\S]*)<\/div>\s*(<script|$)/)?.[1]?.trim() ?? result.html;
3751
+ const titleMatch = result.html.match(/<title[^>]*>([^<]*)<\/title>/);
3752
+ return {
3753
+ body,
3754
+ title: titleMatch ? titleMatch[1] : result.resolvedTitle ?? "",
3755
+ fullHtml: result.html,
3756
+ clearActionErrorCookie: result.clearActionErrorCookie,
3757
+ head: result.head,
3758
+ data: result.data !== void 0 ? serializeData(result.data) : void 0,
3759
+ actions: actions && Object.keys(actions).length > 0 ? serializeData(actions) : void 0
3760
+ };
3761
+ }
3762
+ var defaultImport$1, RouteNotFoundError;
3763
+ var init_stream = __esmMin((() => {
3764
+ init_document_shell();
3765
+ init_match();
3766
+ init_render();
3767
+ defaultImport$1 = (path) => import(path);
3768
+ RouteNotFoundError = class extends Error {
3769
+ constructor(pathname) {
3770
+ super(`No route found for ${pathname}`);
3771
+ this.name = "RouteNotFoundError";
3772
+ }
3773
+ };
3774
+ }));
3775
+ //#endregion
3776
+ //#region src/runtime/static.ts
3777
+ function isInside(root, candidate) {
3778
+ return candidate === root || candidate.startsWith(`${root}${node_path.sep}`);
3779
+ }
3780
+ function decodePathname(pathname) {
3305
3781
  try {
3306
- await (0, node_fs_promises.access)(options.publicDir);
3307
- if (!(await (0, node_fs_promises.stat)(options.publicDir)).isDirectory()) return 0;
3782
+ const decoded = decodeURIComponent(pathname);
3783
+ if (decoded.includes("\0") || decoded.includes("\\") || /%(?:00|2e|2f|5c)/i.test(decoded)) return null;
3784
+ if (decoded.split("/").some((segment) => segment === "..")) return null;
3785
+ return decoded;
3308
3786
  } catch {
3309
- return 0;
3787
+ return null;
3310
3788
  }
3311
- await (0, node_fs_promises.mkdir)(options.outDir, { recursive: true });
3312
- await (0, node_fs_promises.cp)(options.publicDir, options.outDir, {
3313
- recursive: true,
3314
- force: true
3315
- });
3316
- return countFiles(options.outDir);
3317
3789
  }
3318
- async function countFiles(dir) {
3319
- const { readdir } = await import("node:fs/promises");
3320
- let count = 0;
3321
- async function walk(d) {
3322
- const entries = await readdir(d, { withFileTypes: true });
3323
- for (const entry of entries) {
3324
- const path = (0, node_path.join)(d, entry.name);
3325
- if (entry.isDirectory()) await walk(path);
3326
- else count++;
3327
- }
3790
+ async function resolveStaticFile(root, pathname) {
3791
+ const decoded = decodePathname(pathname);
3792
+ if (decoded === null) return null;
3793
+ const resolvedRoot = (0, node_path.resolve)(root);
3794
+ const relativePath = decoded.replace(/^\/+/, "");
3795
+ let candidate = (0, node_path.resolve)(resolvedRoot, relativePath);
3796
+ if (!isInside(resolvedRoot, candidate)) return null;
3797
+ try {
3798
+ if ((await (0, node_fs_promises.stat)(candidate)).isDirectory()) candidate = (0, node_path.resolve)(candidate, "index.html");
3799
+ } catch {
3800
+ if (decoded.endsWith("/") || (0, node_path.extname)(decoded) === "") candidate = (0, node_path.resolve)(candidate, "index.html");
3801
+ }
3802
+ if (!isInside(resolvedRoot, candidate)) return null;
3803
+ try {
3804
+ const [canonicalRoot, canonicalCandidate, candidateStat] = await Promise.all([
3805
+ (0, node_fs_promises.realpath)(resolvedRoot),
3806
+ (0, node_fs_promises.realpath)(candidate),
3807
+ (0, node_fs_promises.stat)(candidate)
3808
+ ]);
3809
+ if (!candidateStat.isFile() || !isInside(canonicalRoot, canonicalCandidate)) return null;
3810
+ return canonicalCandidate;
3811
+ } catch {
3812
+ return null;
3328
3813
  }
3329
- await walk(dir);
3330
- return count;
3331
3814
  }
3332
- var init_vite_build = __esmMin((() => {
3333
- init_interpolation_plugin();
3334
- }));
3815
+ var init_static = __esmMin((() => {}));
3335
3816
  //#endregion
3336
3817
  //#region src/runtime/context.ts
3337
3818
  function htmlResponse(body, status = 200, headers) {
@@ -3567,6 +4048,213 @@ var init_security_headers = __esmMin((() => {
3567
4048
  };
3568
4049
  }));
3569
4050
  //#endregion
4051
+ //#region src/router/redirects.ts
4052
+ /**
4053
+ * Checks if a pathname matches a redirect rule and returns the redirect
4054
+ * Response if so.
4055
+ */
4056
+ function matchRedirect(pathname, rules) {
4057
+ for (const rule of rules) {
4058
+ const params = matchPattern(pathname, rule.from);
4059
+ if (params) {
4060
+ const location = interpolatePath(rule.to, params);
4061
+ const status = rule.status ?? 308;
4062
+ return new Response(null, {
4063
+ status,
4064
+ headers: { Location: location }
4065
+ });
4066
+ }
4067
+ }
4068
+ }
4069
+ /**
4070
+ * Checks if a pathname matches a rewrite rule and returns the rewritten
4071
+ * pathname if so.
4072
+ */
4073
+ function matchRewrite(pathname, rules) {
4074
+ for (const rule of rules) {
4075
+ const params = matchPattern(pathname, rule.from);
4076
+ if (params) return interpolatePath(rule.to, params);
4077
+ }
4078
+ }
4079
+ /**
4080
+ * Returns headers that should be applied to a response for the given pathname.
4081
+ */
4082
+ function matchRouteHeaders(pathname, rules) {
4083
+ for (const rule of rules) if (matchPattern(pathname, rule.path)) return rule.headers;
4084
+ }
4085
+ /**
4086
+ * Matches a pathname against a pattern with :param and * wildcards.
4087
+ * Returns the extracted params, or undefined if no match.
4088
+ */
4089
+ function matchPattern(pathname, pattern) {
4090
+ const requestSegments = pathname.split("?")[0].split("/").filter(Boolean);
4091
+ const patternSegments = pattern.split("/").filter(Boolean);
4092
+ const params = {};
4093
+ let i = 0;
4094
+ for (let r = 0; r < patternSegments.length; r++) {
4095
+ const seg = patternSegments[r];
4096
+ if (seg === "*") return params;
4097
+ if (seg.endsWith("*")) {
4098
+ const name = seg.slice(1, -1);
4099
+ params[name] = requestSegments.slice(i).join("/");
4100
+ return params;
4101
+ }
4102
+ if (seg.startsWith(":")) {
4103
+ const name = seg.slice(1);
4104
+ if (requestSegments[i] === void 0) return void 0;
4105
+ params[name] = requestSegments[i];
4106
+ i++;
4107
+ continue;
4108
+ }
4109
+ if (seg !== requestSegments[i]) return void 0;
4110
+ i++;
4111
+ }
4112
+ if (i !== requestSegments.length) return void 0;
4113
+ return params;
4114
+ }
4115
+ /**
4116
+ * Interpolates :param placeholders in a path with actual values.
4117
+ */
4118
+ function interpolatePath(template, params) {
4119
+ return template.replace(/:(\w+)\*?/g, (_match, name) => {
4120
+ return params[name] ?? "";
4121
+ });
4122
+ }
4123
+ var init_redirects = __esmMin((() => {}));
4124
+ //#endregion
4125
+ //#region src/middleware/stream-boundary.ts
4126
+ /**
4127
+ * Builds the resolved content chunk for a boundary ID.
4128
+ * Emits a `<template>` element with the resolved content, followed by a
4129
+ * `<script>` that replaces the fallback div with the template content
4130
+ * in-place. This is real Suspense streaming — the browser swaps the DOM
4131
+ * node without a full re-render.
4132
+ *
4133
+ * (v2.1 — Fix #4: real Suspense streaming with `<template>` replacement)
4134
+ */
4135
+ function buildResolvedChunk(boundaryId, resolvedHtml) {
4136
+ return `<template id="${boundaryId}-tpl">${resolvedHtml}</template><script>(function(){var t=document.getElementById(${JSON.stringify(boundaryId + "-tpl")});var f=document.getElementById(${JSON.stringify(boundaryId)});if(t&&f){f.replaceWith(t.content.cloneNode(true));}document.dispatchEvent(new CustomEvent("elur:rendered"));})();<\/script>`;
4137
+ }
4138
+ var init_stream_boundary = __esmMin((() => {
4139
+ new node_async_hooks.AsyncLocalStorage();
4140
+ }));
4141
+ //#endregion
4142
+ //#region src/ssr/stream-response.ts
4143
+ /**
4144
+ * Mid-stream error notice swapped into the loading boundary when the
4145
+ * background render fails after the shell was already sent. Inline styles
4146
+ * keep it self-contained (the page's CSS may assume the final layout).
4147
+ */
4148
+ function buildErrorNotice() {
4149
+ return "<div role=\"alert\" style=\"margin:2rem auto;max-width:32rem;padding:1rem 1.25rem;border:1px solid #e5484d;border-radius:8px;color:#b3373c;font-family:system-ui,sans-serif\"><strong style=\"display:block;margin-bottom:.25rem\">No se pudo cargar el contenido.</strong><span>Recarga la página para intentarlo de nuevo.</span></div>";
4150
+ }
4151
+ /**
4152
+ * Creates a streaming Response that sends the shell + loading fallback first,
4153
+ * then appends the resolved content.
4154
+ *
4155
+ * If the route has no loading boundary, falls back to a normal renderPage.
4156
+ */
4157
+ async function createStreamingResponse(options) {
4158
+ const { route, params, searchParams, config, actions, importer = defaultImport, request, signal } = options;
4159
+ if (!route.loadingPath) {
4160
+ const result = await renderPage({
4161
+ route,
4162
+ params,
4163
+ searchParams,
4164
+ config,
4165
+ actions,
4166
+ importer,
4167
+ request
4168
+ });
4169
+ if (result.response) return result.response;
4170
+ return new Response(result.html, { headers: { "Content-Type": "text/html; charset=utf-8" } });
4171
+ }
4172
+ if (signal?.aborted) return new Response("Client Closed Request", { status: 499 });
4173
+ const loadingHtml = await renderToString((await importer(route.loadingPath)).default);
4174
+ const boundaryId = `elur-stream-${(0, node_crypto.randomUUID)().slice(0, 8)}`;
4175
+ const routerCfg = config.router;
4176
+ const routerEnabled = routerCfg?.enabled !== false;
4177
+ const routerEntry = routerCfg?.entry && routerEnabled && config.js !== "legacy" ? routerCfg.entry : void 0;
4178
+ const shellHtml = documentShell({
4179
+ title: "Loading...",
4180
+ lang: config.lang,
4181
+ body: `<div id="${boundaryId}">${loadingHtml}</div>`,
4182
+ data: {
4183
+ __elur_js_streaming: true,
4184
+ page: route.path
4185
+ },
4186
+ actions,
4187
+ clientEntry: config.clientEntry,
4188
+ routerEntry,
4189
+ routerEnabled: routerCfg ? routerEnabled : void 0,
4190
+ renderEndpoint: config.renderEndpoint
4191
+ });
4192
+ let aborted = false;
4193
+ const stream = new ReadableStream({
4194
+ async start(controller) {
4195
+ const encoder = new TextEncoder();
4196
+ const onAbort = () => {
4197
+ aborted = true;
4198
+ try {
4199
+ controller.close();
4200
+ } catch {}
4201
+ };
4202
+ signal?.addEventListener("abort", onAbort, { once: true });
4203
+ /** Enqueue unless the client went away mid-render. */
4204
+ const send = (html) => {
4205
+ if (aborted) return;
4206
+ controller.enqueue(encoder.encode(html));
4207
+ };
4208
+ try {
4209
+ send(shellHtml);
4210
+ const result = await renderPage({
4211
+ route,
4212
+ params,
4213
+ searchParams,
4214
+ config,
4215
+ actions,
4216
+ importer,
4217
+ request
4218
+ });
4219
+ if (aborted) return;
4220
+ if (result.response) {
4221
+ const status = result.response.status;
4222
+ const location = result.response.headers.get("Location");
4223
+ if (location && (status === 301 || status === 302 || status === 307 || status === 308)) send(`<script>window.location.href=${JSON.stringify(location)};<\/script>`);
4224
+ else send(buildResolvedChunk(boundaryId, buildErrorNotice()) + `<script>console.error(${JSON.stringify(`Loader responded with status ${status}`)});<\/script>`);
4225
+ return;
4226
+ }
4227
+ const innerBody = extractAppBody(result.html) ?? result.html.match(/<div id="app">([\s\S]*)<\/div>\s*(<script|$)/)?.[1]?.trim() ?? result.html;
4228
+ send(buildResolvedChunk(boundaryId, innerBody));
4229
+ } catch (err) {
4230
+ const errorMsg = err instanceof Error ? err.message : String(err);
4231
+ send(buildResolvedChunk(boundaryId, buildErrorNotice()) + `<script>console.error(${JSON.stringify(errorMsg)});<\/script>`);
4232
+ } finally {
4233
+ signal?.removeEventListener("abort", onAbort);
4234
+ if (!aborted) controller.close();
4235
+ }
4236
+ },
4237
+ cancel() {
4238
+ aborted = true;
4239
+ }
4240
+ });
4241
+ return new Response(stream, { headers: STREAM_HEADERS });
4242
+ }
4243
+ var STREAM_HEADERS, defaultImport;
4244
+ var init_stream_response = __esmMin((() => {
4245
+ init_render_to_string();
4246
+ init_document_shell();
4247
+ init_render();
4248
+ init_stream_boundary();
4249
+ init_capabilities();
4250
+ STREAM_HEADERS = {
4251
+ "Content-Type": "text/html; charset=utf-8",
4252
+ "X-Accel-Buffering": "no",
4253
+ "Cache-Control": "no-store"
4254
+ };
4255
+ defaultImport = (path) => import(path);
4256
+ }));
4257
+ //#endregion
3570
4258
  //#region src/runtime/handler.ts
3571
4259
  var handler_exports = /* @__PURE__ */ __exportAll({ createWebHandler: () => createWebHandler });
3572
4260
  /**
@@ -3580,14 +4268,28 @@ function createWebHandler(routes, actions, options) {
3580
4268
  const clientEntry = options.clientEntry;
3581
4269
  const renderEndpoint = options.renderEndpoint ?? true;
3582
4270
  const noCache = options.noCache ?? false;
3583
- const cacheDir = options.cacheDir;
3584
4271
  const defaultRevalidate = options.defaultRevalidate;
3585
4272
  const renderConfig = {
3586
4273
  lang,
3587
4274
  clientEntry,
3588
- renderEndpoint
4275
+ renderEndpoint,
4276
+ router: options.router ? {
4277
+ enabled: options.router.enabled !== false,
4278
+ entry: options.router.entry
4279
+ } : void 0,
4280
+ js: options.js
3589
4281
  };
3590
4282
  const securityHeadersConfig = options.securityHeaders ?? {};
4283
+ const redirectRules = options.redirects ?? [];
4284
+ const rewriteRules = options.rewrites ?? [];
4285
+ const routeHeaderRules = options.routeHeaders ?? [];
4286
+ const capabilities = options.capabilities ?? DEFAULT_CAPABILITIES;
4287
+ const streamingEnabled = options.streaming === true && supportsStreaming(capabilities);
4288
+ const cacheAdapter = resolveCacheAdapter(options);
4289
+ if (cacheAdapter && !invalidatorConnectedAdapters.has(cacheAdapter)) {
4290
+ invalidatorConnectedAdapters.add(cacheAdapter);
4291
+ connectCacheAdapter(cacheAdapter);
4292
+ }
3591
4293
  function createActionResolver() {
3592
4294
  return async (name, page) => {
3593
4295
  const pageKey = page ? routes.pages.some((route) => route.path === page) ? page : matchRoute(page, routes.pages)?.route.path ?? page : void 0;
@@ -3604,20 +4306,29 @@ function createWebHandler(routes, actions, options) {
3604
4306
  };
3605
4307
  }
3606
4308
  const actionResolver = createActionResolver();
3607
- async function handleActions(request) {
4309
+ async function handleActions(request, logger) {
4310
+ const stopTimer = logger.startTimer("action", "Server action");
3608
4311
  try {
3609
4312
  return await handleActionRequest(request, actionResolver);
3610
4313
  } catch (err) {
3611
- console.error("[elur-kit] action error:", err);
4314
+ logger.error("[elur-kit] action error", {
4315
+ path: new URL(request.url).pathname,
4316
+ method: request.method,
4317
+ error: errorMessage(err),
4318
+ stack: errorStack(err)
4319
+ });
3612
4320
  return publicErrorResponse(err, { includeDetail: noCache });
4321
+ } finally {
4322
+ stopTimer();
3613
4323
  }
3614
4324
  }
3615
- async function handleRenderEndpoint(request, url) {
4325
+ async function handleRenderEndpoint(request, url, logger) {
3616
4326
  const page = url.searchParams.get("page") ?? "/";
3617
4327
  const search = url.searchParams.get("search") ?? "";
3618
4328
  const wantsJson = (request.headers.get("Accept") ?? "").includes("application/json");
4329
+ const stopTimer = logger.startTimer("render-endpoint", "SPA render endpoint");
3619
4330
  try {
3620
- const { body, title } = await renderPageBody({
4331
+ const result = await renderPageBody({
3621
4332
  routes,
3622
4333
  pathname: page,
3623
4334
  searchParams: new URLSearchParams(search),
@@ -3626,21 +4337,39 @@ function createWebHandler(routes, actions, options) {
3626
4337
  request,
3627
4338
  importer: options.importer
3628
4339
  });
3629
- if (wantsJson) return jsonResponse({
3630
- title,
3631
- body
3632
- });
3633
- return htmlResponse(body);
4340
+ if (result.response) return result.response;
4341
+ const { body, title, head, clearActionErrorCookie, data, actions } = result;
4342
+ if (wantsJson) {
4343
+ const headers = {};
4344
+ if (clearActionErrorCookie) headers["X-Elur-Action-Clear-Cookie"] = clearActionErrorCookie;
4345
+ return jsonResponse({
4346
+ title,
4347
+ body,
4348
+ head: head ?? null,
4349
+ data: data ?? null,
4350
+ actions: actions ?? null,
4351
+ clearActionErrorCookie: clearActionErrorCookie ?? null
4352
+ }, 200, headers);
4353
+ }
4354
+ return htmlResponse(body, 200, clearActionErrorCookie ? { "Set-Cookie": clearActionErrorCookie } : void 0);
3634
4355
  } catch (err) {
3635
4356
  if (err instanceof RouteNotFoundError) return notFound("Not Found");
3636
4357
  if (err instanceof Response) return err;
3637
- console.error("[elur-kit] render endpoint error:", err);
4358
+ logger.error("[elur-kit] render endpoint error", {
4359
+ path: url.pathname,
4360
+ page,
4361
+ error: errorMessage(err),
4362
+ stack: errorStack(err)
4363
+ });
3638
4364
  return publicErrorResponse(err, { includeDetail: noCache });
4365
+ } finally {
4366
+ stopTimer();
3639
4367
  }
3640
4368
  }
3641
- async function handleApiRoute(request, pathname) {
4369
+ async function handleApiRoute(request, pathname, logger, middlewareLocals) {
3642
4370
  const apiMatch = matchApiRoute(pathname, routes.api);
3643
4371
  if (!apiMatch) return null;
4372
+ const stopTimer = logger.startTimer("api", "API route");
3644
4373
  try {
3645
4374
  let mod;
3646
4375
  if (options.importer) mod = await options.importer(apiMatch.route.routePath);
@@ -3649,11 +4378,19 @@ function createWebHandler(routes, actions, options) {
3649
4378
  if (typeof handler !== "function") return methodNotAllowed(request.method ?? "GET");
3650
4379
  return await handler(request, {
3651
4380
  params: apiMatch.params,
3652
- locals: {}
4381
+ locals: middlewareLocals ?? {}
3653
4382
  });
3654
4383
  } catch (err) {
3655
- console.error("[elur-kit] API route error:", err);
4384
+ logger.error("[elur-kit] API route error", {
4385
+ path: pathname,
4386
+ method: request.method,
4387
+ route: apiMatch.route.path,
4388
+ error: errorMessage(err),
4389
+ stack: errorStack(err)
4390
+ });
3656
4391
  return publicErrorResponse(err, { includeDetail: noCache });
4392
+ } finally {
4393
+ stopTimer();
3657
4394
  }
3658
4395
  }
3659
4396
  async function handleStatic(pathname, request) {
@@ -3698,7 +4435,7 @@ function createWebHandler(routes, actions, options) {
3698
4435
  }
3699
4436
  return response;
3700
4437
  }
3701
- async function handleDynamicRender(request, pathname) {
4438
+ async function handleDynamicRender(request, pathname, logger) {
3702
4439
  const match = matchRoute(pathname, routes.pages);
3703
4440
  if (!match) {
3704
4441
  const errorResult = await renderErrorPage({
@@ -3711,12 +4448,44 @@ function createWebHandler(routes, actions, options) {
3711
4448
  if (errorResult) return htmlResponse(errorResult.html, errorResult.status);
3712
4449
  return notFound(`Not found: ${pathname}`);
3713
4450
  }
3714
- const cacheable = !noCache && cacheDir && isCacheable(request);
3715
- if (cacheable && cacheDir) {
3716
- const cached = await getCachedHtml(cacheDir, pathname);
3717
- if (cached) return htmlResponse(cached.html);
4451
+ if (streamingEnabled && match.route.loadingPath) {
4452
+ const stopStreamTimer = logger.startTimer("ssr", "SSR stream shell");
4453
+ try {
4454
+ return await createStreamingResponse({
4455
+ route: match.route,
4456
+ params: match.params,
4457
+ searchParams: new URLSearchParams(request.url.split("?")[1] ?? ""),
4458
+ config: renderConfig,
4459
+ actions: publicActions,
4460
+ importer: options.importer,
4461
+ request,
4462
+ signal: request.signal
4463
+ });
4464
+ } catch (err) {
4465
+ if (err instanceof Response) return err;
4466
+ logger.error("[elur-kit] SSR stream error", {
4467
+ path: pathname,
4468
+ route: match.route.path,
4469
+ error: errorMessage(err),
4470
+ stack: errorStack(err)
4471
+ });
4472
+ const errorResult = await renderErrorPage({
4473
+ routes,
4474
+ status: 500,
4475
+ error: err,
4476
+ config: renderConfig,
4477
+ actions: publicActions,
4478
+ importer: options.importer
4479
+ }).catch(() => void 0);
4480
+ if (errorResult) return htmlResponse(errorResult.html, errorResult.status);
4481
+ return publicErrorResponse(err, { includeDetail: noCache });
4482
+ } finally {
4483
+ stopStreamTimer();
4484
+ }
3718
4485
  }
3719
- try {
4486
+ const cacheable = !noCache && cacheAdapter && isCacheable(request);
4487
+ const pageCacheKey = cacheable ? cacheKey(pathname) : void 0;
4488
+ const renderAndStore = async () => {
3720
4489
  const result = await renderPage({
3721
4490
  route: match.route,
3722
4491
  params: match.params,
@@ -3727,14 +4496,43 @@ function createWebHandler(routes, actions, options) {
3727
4496
  importer: options.importer
3728
4497
  });
3729
4498
  if (result.response) return result.response;
3730
- if (cacheable && cacheDir && isResultCacheable(result, request)) {
4499
+ if (cacheable && cacheAdapter && pageCacheKey && isResultCacheable(result, request)) {
3731
4500
  const revalidateSeconds = result.revalidate ?? defaultRevalidate ?? 0;
3732
- if (revalidateSeconds > 0) await setCachedHtml(cacheDir, pathname, result.html, revalidateSeconds);
4501
+ if (revalidateSeconds > 0) await cacheAdapter.set(pageCacheKey, {
4502
+ html: result.html,
4503
+ generatedAt: Date.now(),
4504
+ revalidate: revalidateSeconds
4505
+ }, {
4506
+ revalidate: revalidateSeconds,
4507
+ tags: result.cachePolicy?.tags
4508
+ });
3733
4509
  }
3734
4510
  return htmlResponse(result.html);
4511
+ };
4512
+ if (cacheable && cacheAdapter && pageCacheKey) {
4513
+ const cached = await cacheAdapter.get(pageCacheKey);
4514
+ if (cached) {
4515
+ if (Date.now() - cached.generatedAt >= cached.revalidate * 1e3) renderAndStore().catch((err) => {
4516
+ logger.error("[elur-kit] background cache revalidation failed", {
4517
+ path: pathname,
4518
+ error: errorMessage(err),
4519
+ stack: errorStack(err)
4520
+ });
4521
+ });
4522
+ return htmlResponse(cached.html);
4523
+ }
4524
+ }
4525
+ const stopTimer = logger.startTimer("ssr", "SSR render");
4526
+ try {
4527
+ return await renderAndStore();
3735
4528
  } catch (err) {
3736
4529
  if (err instanceof Response) return err;
3737
- console.error("[elur-kit] SSR render error:", err);
4530
+ logger.error("[elur-kit] SSR render error", {
4531
+ path: pathname,
4532
+ route: match.route.path,
4533
+ error: errorMessage(err),
4534
+ stack: errorStack(err)
4535
+ });
3738
4536
  const errorResult = await renderErrorPage({
3739
4537
  routes,
3740
4538
  status: 500,
@@ -3745,22 +4543,83 @@ function createWebHandler(routes, actions, options) {
3745
4543
  }).catch(() => void 0);
3746
4544
  if (errorResult) return htmlResponse(errorResult.html, errorResult.status);
3747
4545
  return publicErrorResponse(err, { includeDetail: noCache });
4546
+ } finally {
4547
+ stopTimer();
3748
4548
  }
3749
4549
  }
4550
+ function finalizeResponse(response, logger, secHeaders, routeHeaders) {
4551
+ const secured = applySecurityHeaders(response, secHeaders);
4552
+ const headers = new Headers(secured.headers);
4553
+ if (routeHeaders) for (const [key, value] of Object.entries(routeHeaders)) headers.set(key, value);
4554
+ const timing = logger.getServerTimingHeader();
4555
+ if (timing) headers.set("Server-Timing", timing);
4556
+ headers.set("X-Request-ID", logger.getRequestId());
4557
+ return new Response(secured.body, {
4558
+ status: secured.status,
4559
+ statusText: secured.statusText,
4560
+ headers
4561
+ });
4562
+ }
3750
4563
  return async function handler(request) {
4564
+ const logger = createRequestLogger(request, options.logLevel);
3751
4565
  const url = new URL(request.url);
3752
- const pathname = url.pathname;
4566
+ const originalPathname = url.pathname;
3753
4567
  const isHttps = url.protocol === "https:";
3754
4568
  const secHeaders = securityHeadersConfig === false ? {} : buildSecurityHeaders(securityHeadersConfig, isHttps);
3755
- if (pathname === "/__elur-js/actions" && request.method === "POST") return applySecurityHeaders(await handleActions(request), secHeaders);
3756
- if (pathname === "/__elur-js/render" && renderEndpoint) return applySecurityHeaders(await handleRenderEndpoint(request, url), secHeaders);
3757
- const apiResponse = await handleApiRoute(request, pathname);
3758
- if (apiResponse) return applySecurityHeaders(apiResponse, secHeaders);
4569
+ if (redirectRules.length > 0) {
4570
+ const redirect = matchRedirect(originalPathname, redirectRules);
4571
+ if (redirect) return finalizeResponse(redirect, logger, secHeaders, matchRouteHeaders(originalPathname, routeHeaderRules));
4572
+ }
4573
+ let pathname = originalPathname;
4574
+ if (rewriteRules.length > 0) pathname = matchRewrite(originalPathname, rewriteRules) ?? originalPathname;
4575
+ const routeHeaders = matchRouteHeaders(originalPathname, routeHeaderRules);
4576
+ if (pathname === "/__elur-js/actions" && request.method === "POST") return finalizeResponse(await handleActions(request, logger), logger, secHeaders, routeHeaders);
4577
+ if (pathname === "/__elur-js/render" && renderEndpoint) return finalizeResponse(await handleRenderEndpoint(request, url, logger), logger, secHeaders, routeHeaders);
4578
+ let middlewareLocals;
4579
+ const middleware = options.middleware;
4580
+ if (middleware && matchesMiddleware(pathname, middleware.config)) {
4581
+ let mwResult;
4582
+ try {
4583
+ mwResult = await runMiddleware(middleware, request);
4584
+ } catch (err) {
4585
+ logger.error("[elur-kit] middleware error", {
4586
+ path: pathname,
4587
+ error: errorMessage(err),
4588
+ stack: errorStack(err)
4589
+ });
4590
+ return finalizeResponse(publicErrorResponse(err, { includeDetail: noCache }), logger, secHeaders, routeHeaders);
4591
+ }
4592
+ if (mwResult.kind === "response") return finalizeResponse(mwResult.response, logger, secHeaders, routeHeaders);
4593
+ middlewareLocals = mwResult.locals;
4594
+ if (mwResult.headers) {
4595
+ const merged = new Headers(request.headers);
4596
+ for (const [key, value] of Object.entries(mwResult.headers)) merged.set(key, value);
4597
+ request = new Request(request, { headers: merged });
4598
+ }
4599
+ }
4600
+ const apiResponse = await handleApiRoute(request, pathname, logger, middlewareLocals);
4601
+ if (apiResponse) return finalizeResponse(apiResponse, logger, secHeaders, routeHeaders);
3759
4602
  const staticResponse = await handleStatic(pathname, request);
3760
- if (staticResponse) return applySecurityHeaders(staticResponse, secHeaders);
3761
- return applySecurityHeaders(await handleDynamicRender(request, pathname), secHeaders);
4603
+ if (staticResponse) return finalizeResponse(staticResponse, logger, secHeaders, routeHeaders);
4604
+ return finalizeResponse(await handleDynamicRender(request, pathname, logger), logger, secHeaders, routeHeaders);
3762
4605
  };
3763
4606
  }
4607
+ function resolveCacheAdapter(options) {
4608
+ if (options.cacheAdapter) return options.cacheAdapter;
4609
+ if (!options.cacheDir) return void 0;
4610
+ let adapter = defaultCacheAdapters.get(options.cacheDir);
4611
+ if (!adapter) {
4612
+ adapter = createFsCacheAdapter({ cacheDir: options.cacheDir });
4613
+ defaultCacheAdapters.set(options.cacheDir, adapter);
4614
+ }
4615
+ return adapter;
4616
+ }
4617
+ function errorMessage(err) {
4618
+ return err instanceof Error ? err.message : String(err);
4619
+ }
4620
+ function errorStack(err) {
4621
+ return err instanceof Error ? err.stack : void 0;
4622
+ }
3764
4623
  function isCacheable(request) {
3765
4624
  if (request.method !== "GET" && request.method !== "HEAD") return false;
3766
4625
  if (request.headers.get("Cookie")) return false;
@@ -3780,6 +4639,7 @@ function isResultCacheable(result, request) {
3780
4639
  if (request.headers.get("Authorization")) return false;
3781
4640
  return true;
3782
4641
  }
4642
+ var defaultCacheAdapters, invalidatorConnectedAdapters;
3783
4643
  var init_handler = __esmMin((() => {
3784
4644
  init_match();
3785
4645
  init_server();
@@ -3788,9 +4648,16 @@ var init_handler = __esmMin((() => {
3788
4648
  init_scan();
3789
4649
  init_context();
3790
4650
  init_errors();
3791
- init_cache();
4651
+ init_adapter();
4652
+ init_invalidation();
3792
4653
  init_policy();
3793
4654
  init_security_headers();
4655
+ init_redirects();
4656
+ init_middleware();
4657
+ init_stream_response();
4658
+ init_capabilities();
4659
+ defaultCacheAdapters = /* @__PURE__ */ new Map();
4660
+ invalidatorConnectedAdapters = /* @__PURE__ */ new WeakSet();
3794
4661
  }));
3795
4662
  //#endregion
3796
4663
  //#region src/adapters/shared.ts
@@ -3878,8 +4745,10 @@ async function buildSsrEntry(routes, options, entryDir) {
3878
4745
  }
3879
4746
  if (names.length > 0) actionsRegistry[page.path] = names;
3880
4747
  }
4748
+ const routerEnabled = options.router?.enabled !== false;
4749
+ const routerEntry = routerEnabled && options.js !== "legacy" && (0, node_fs.existsSync)((0, node_path.resolve)(options.root, options.outDir, "_elur", "router.js")) ? "/_elur/router.js" : null;
3881
4750
  return `// AUTO-GENERATED by @elurjs/kit. Do not edit.
3882
- import { handleActionRequest, matchApiRoute, matchRoute, renderPage, renderErrorPage } from "@elurjs/kit";
4751
+ import { handleActionRequest, matchApiRoute, matchRoute, renderPage, renderPageBody, renderErrorPage, createStreamingResponse } from "@elurjs/kit";
3883
4752
  ${imports}
3884
4753
 
3885
4754
  const registry = new Map<string, unknown>([
@@ -3909,6 +4778,15 @@ const routes = {
3909
4778
 
3910
4779
  const clientEntry = ${JSON.stringify(options.clientEntry)};
3911
4780
  const lang = ${JSON.stringify(options.lang)};
4781
+ // Client router/JS emission rules baked at adapter build time.
4782
+ const router = ${JSON.stringify({
4783
+ enabled: routerEnabled,
4784
+ entry: routerEntry
4785
+ })};
4786
+ const jsMode = ${JSON.stringify(options.js === "legacy" ? "legacy" : "modern")};
4787
+ // Opt-in streaming SSR (experimental): routes with a loading boundary stream
4788
+ // the shell first and swap in the resolved content as a follow-up chunk.
4789
+ const streaming = ${options.streaming === true};
3912
4790
 
3913
4791
  function loadModule(path: string) {
3914
4792
  const mod = registry.get(path);
@@ -3957,37 +4835,38 @@ export default async function handler(request: Request): Promise<Response> {
3957
4835
  const search = url.searchParams.get("search") ?? "";
3958
4836
  const wantsJson = (request.headers.get("Accept") ?? "").includes("application/json");
3959
4837
  try {
3960
- const match = matchRoute(page, routes.pages);
3961
- if (!match) throw new Error(\`No route found for \${page}\`);
3962
- const { html } = await renderPage({
3963
- route: match.route,
3964
- params: match.params,
4838
+ const result = await renderPageBody({
4839
+ routes,
4840
+ pathname: page,
3965
4841
  searchParams: new URLSearchParams(search),
3966
- config: { lang, clientEntry },
4842
+ config: { lang, clientEntry, router, js: jsMode },
3967
4843
  importer: loadModule,
3968
4844
  actions,
3969
4845
  request,
3970
4846
  });
3971
- const bodyStart = html.indexOf('<div id="app">');
3972
- const bodyEnd = html.lastIndexOf("</div>");
3973
- const body = bodyStart >= 0 && bodyEnd > bodyStart
3974
- ? html.slice(bodyStart + 13, bodyEnd).trim()
3975
- : html;
3976
- const titleStart = html.indexOf("<title>");
3977
- const titleEnd = html.indexOf("</title>");
3978
- const title = titleStart >= 0 && titleEnd > titleStart
3979
- ? html.slice(titleStart + 7, titleEnd)
3980
- : "";
4847
+ // A thrown Response from a loader is a first-class response (A-22).
4848
+ if (result.response) return result.response;
4849
+ const { body, title, head, clearActionErrorCookie, data, actions: actionsPayload } = result;
3981
4850
  if (wantsJson) {
3982
- return new Response(JSON.stringify({ title, body }), {
3983
- status: 200,
3984
- headers: { "Content-Type": "application/json; charset=utf-8" },
3985
- });
4851
+ const headers = { "Content-Type": "application/json; charset=utf-8" };
4852
+ if (clearActionErrorCookie) headers["X-Elur-Action-Clear-Cookie"] = clearActionErrorCookie;
4853
+ // The ?? null fallbacks keep every key present — JSON.stringify drops
4854
+ // undefined and the SPA payload shape must be stable across runtimes.
4855
+ return new Response(
4856
+ JSON.stringify({
4857
+ title,
4858
+ body,
4859
+ head: head ?? null,
4860
+ data: data ?? null,
4861
+ actions: actionsPayload ?? null,
4862
+ clearActionErrorCookie: clearActionErrorCookie ?? null,
4863
+ }),
4864
+ { status: 200, headers },
4865
+ );
3986
4866
  }
3987
- return new Response(body, {
3988
- status: 200,
3989
- headers: { "Content-Type": "text/html; charset=utf-8" },
3990
- });
4867
+ const headers = { "Content-Type": "text/html; charset=utf-8" };
4868
+ if (clearActionErrorCookie) headers["Set-Cookie"] = clearActionErrorCookie;
4869
+ return new Response(body, { status: 200, headers });
3991
4870
  } catch (err) {
3992
4871
  if ((err as { name?: string }).name === "RouteNotFoundError") {
3993
4872
  return new Response("Not Found", {
@@ -4018,7 +4897,7 @@ export default async function handler(request: Request): Promise<Response> {
4018
4897
 
4019
4898
  const match = matchRoute(url.pathname, routes.pages);
4020
4899
  if (!match) {
4021
- const errorResult = await renderErrorPage({ routes, status: 404, config: { lang, clientEntry }, actions, importer: loadModule });
4900
+ const errorResult = await renderErrorPage({ routes, status: 404, config: { lang, clientEntry, router, js: jsMode }, actions, importer: loadModule });
4022
4901
  if (errorResult) {
4023
4902
  return new Response(errorResult.html, { status: errorResult.status, headers: { "Content-Type": "text/html; charset=utf-8" } });
4024
4903
  }
@@ -4026,19 +4905,37 @@ export default async function handler(request: Request): Promise<Response> {
4026
4905
  }
4027
4906
 
4028
4907
  try {
4029
- const { html } = await renderPage({
4908
+ if (streaming && match.route.loadingPath) {
4909
+ // Streaming SSR: shell + loading boundary first, resolved content as a
4910
+ // follow-up chunk. Streamed pages are rendered live (no cache).
4911
+ return createStreamingResponse({
4912
+ route: match.route,
4913
+ params: match.params,
4914
+ searchParams: new URLSearchParams(url.search),
4915
+ config: { lang, clientEntry, router, js: jsMode },
4916
+ importer: loadModule,
4917
+ actions,
4918
+ request,
4919
+ signal: request.signal,
4920
+ });
4921
+ }
4922
+ const result = await renderPage({
4030
4923
  route: match.route,
4031
4924
  params: match.params,
4032
4925
  searchParams: new URLSearchParams(url.search),
4033
- config: { lang, clientEntry },
4926
+ config: { lang, clientEntry, router, js: jsMode },
4034
4927
  importer: loadModule,
4035
4928
  actions,
4036
4929
  request,
4037
4930
  });
4038
- return new Response(html, { status: 200, headers: { "Content-Type": "text/html; charset=utf-8" } });
4931
+ // A thrown Response from a loader is a first-class response (A-22).
4932
+ if (result.response) return result.response;
4933
+ const headers = { "Content-Type": "text/html; charset=utf-8" };
4934
+ if (result.clearActionErrorCookie) headers["Set-Cookie"] = result.clearActionErrorCookie;
4935
+ return new Response(result.html, { status: 200, headers });
4039
4936
  } catch (err) {
4040
4937
  console.error("[elur-kit] SSR render error:", err);
4041
- const errorResult = await renderErrorPage({ routes, status: 500, error: err, config: { lang, clientEntry }, actions, importer: loadModule });
4938
+ const errorResult = await renderErrorPage({ routes, status: 500, error: err, config: { lang, clientEntry, router, js: jsMode }, actions, importer: loadModule });
4042
4939
  if (errorResult) {
4043
4940
  return new Response(errorResult.html, { status: errorResult.status, headers: { "Content-Type": "text/html; charset=utf-8" } });
4044
4941
  }
@@ -4201,6 +5098,15 @@ var init_netlify = __esmMin((() => {
4201
5098
  //#region src/adapters/bun.ts
4202
5099
  var bun_exports = /* @__PURE__ */ __exportAll({ bunAdapter: () => bunAdapter });
4203
5100
  function buildBunServerSource(outDirUrl, options) {
5101
+ const logLevelOption = options.logLevel ? `, logLevel: ${JSON.stringify(options.logLevel)}` : "";
5102
+ const routingOptions = [
5103
+ "redirects",
5104
+ "rewrites",
5105
+ "routeHeaders"
5106
+ ].map((key) => {
5107
+ const rules = options[key];
5108
+ return rules && rules.length > 0 ? `, ${key}: ${JSON.stringify(rules)}` : "";
5109
+ }).join("");
4204
5110
  return `// AUTO-GENERATED by @elurjs/kit. Do not edit.
4205
5111
  import { fileURLToPath } from "node:url";
4206
5112
  import { createWebHandler } from "@elurjs/kit/runtime";
@@ -4212,7 +5118,7 @@ const port = Number(process.env.PORT) || ${options.port ?? 3e3};
4212
5118
  const webHandler = createWebHandler(
4213
5119
  { pages: [], api: [], error404: undefined, error500: undefined },
4214
5120
  {},
4215
- { staticRoot: outDir, lang: ${JSON.stringify(options.lang)}, clientEntry: ${JSON.stringify(options.clientEntry)} },
5121
+ { staticRoot: outDir, lang: ${JSON.stringify(options.lang)}, clientEntry: ${JSON.stringify(options.clientEntry)}${logLevelOption}${routingOptions} },
4216
5122
  );
4217
5123
 
4218
5124
  Bun.serve({
@@ -4268,11 +5174,20 @@ var init_bun = __esmMin((() => {
4268
5174
  //#region src/adapters/node.ts
4269
5175
  var node_exports = /* @__PURE__ */ __exportAll({ nodeAdapter: () => nodeAdapter });
4270
5176
  function buildNodeServerSource(outDirUrl, options) {
5177
+ const logLevelOption = options.logLevel ? `, logLevel: ${JSON.stringify(options.logLevel)}` : "";
5178
+ const routingOptions = [
5179
+ "redirects",
5180
+ "rewrites",
5181
+ "routeHeaders"
5182
+ ].map((key) => {
5183
+ const rules = options[key];
5184
+ return rules && rules.length > 0 ? `, ${key}: ${JSON.stringify(rules)}` : "";
5185
+ }).join("");
4271
5186
  return `// AUTO-GENERATED by @elurjs/kit. Do not edit.
4272
5187
  import { createServer } from "node:http";
4273
5188
  import { fileURLToPath } from "node:url";
4274
5189
  import { createWebHandler } from "@elurjs/kit/runtime";
4275
- import { incomingMessageToRequest } from "@elurjs/kit/runtime";
5190
+ import { incomingMessageToRequest, sendWebResponse } from "@elurjs/kit/runtime";
4276
5191
  import handler from "./node-index.ts";
4277
5192
 
4278
5193
  const outDir = fileURLToPath(new URL(${JSON.stringify(outDirUrl)}, import.meta.url));
@@ -4284,7 +5199,7 @@ const port = Number(process.env.PORT) || ${options.port ?? 3e3};
4284
5199
  const webHandler = createWebHandler(
4285
5200
  { pages: [], api: [], error404: undefined, error500: undefined },
4286
5201
  {},
4287
- { staticRoot: outDir, lang: ${JSON.stringify(options.lang)}, clientEntry: ${JSON.stringify(options.clientEntry)} },
5202
+ { staticRoot: outDir, lang: ${JSON.stringify(options.lang)}, clientEntry: ${JSON.stringify(options.clientEntry)}${logLevelOption}${routingOptions} },
4288
5203
  );
4289
5204
 
4290
5205
  createServer(async (req, res) => {
@@ -4298,8 +5213,8 @@ createServer(async (req, res) => {
4298
5213
  if (response.status === 404) {
4299
5214
  response = await handler(request);
4300
5215
  }
4301
- res.writeHead(response.status, Object.fromEntries(response.headers.entries()));
4302
- res.end(Buffer.from(await response.arrayBuffer()));
5216
+ // Streams the body: streaming SSR chunks flush as they are produced.
5217
+ await sendWebResponse(res, response);
4303
5218
  }).listen(port, () => {
4304
5219
  console.log(\`Node server running at http://localhost:\${port}\`);
4305
5220
  });
@@ -4621,6 +5536,7 @@ var init_commands = __esmMin((() => {
4621
5536
  //#region src/cli.ts
4622
5537
  init_scan();
4623
5538
  init_route_scanner();
5539
+ init_logger();
4624
5540
  init_capabilities();
4625
5541
  function parseArgs(argv) {
4626
5542
  const args = argv.slice(2);
@@ -4649,6 +5565,7 @@ function parseArgs(argv) {
4649
5565
  let cacheDir;
4650
5566
  let defaultRevalidate;
4651
5567
  let configFile;
5568
+ let logLevel;
4652
5569
  for (let i = optionStart; i < args.length; i++) {
4653
5570
  const arg = args[i];
4654
5571
  const next = args[i + 1];
@@ -4716,6 +5633,12 @@ function parseArgs(argv) {
4716
5633
  defaultRevalidate = Number(next);
4717
5634
  i++;
4718
5635
  break;
5636
+ case "--verbose":
5637
+ if (logLevel !== "error") logLevel = "debug";
5638
+ break;
5639
+ case "--quiet":
5640
+ logLevel = "error";
5641
+ break;
4719
5642
  case "--help":
4720
5643
  case "-?":
4721
5644
  printHelp();
@@ -4741,7 +5664,8 @@ function parseArgs(argv) {
4741
5664
  clientConfig: clientConfig ? (0, node_path.resolve)(root, clientConfig) : void 0,
4742
5665
  cacheDir: cacheDir ? (0, node_path.resolve)(root, cacheDir) : void 0,
4743
5666
  defaultRevalidate,
4744
- configFile: configFile ? (0, node_path.resolve)(root, configFile) : void 0
5667
+ configFile: configFile ? (0, node_path.resolve)(root, configFile) : void 0,
5668
+ logLevel
4745
5669
  };
4746
5670
  }
4747
5671
  function printHelp() {
@@ -4773,8 +5697,23 @@ Options:
4773
5697
  --config <path> Elur config file (default: elur.config.ts/js/mjs)
4774
5698
  --cache-dir <dir> Directory for ISR cache (only used by start)
4775
5699
  --default-revalidate <s> Default ISR revalidate interval in seconds
5700
+ --verbose Debug logging (overrides logger.level)
5701
+ --quiet Only errors are printed (overrides logger.level)
4776
5702
  `);
4777
5703
  }
5704
+ /**
5705
+ * Reads the kit's own version from package.json. The CLI runs from two
5706
+ * layouts: src/cli.ts in the repo (../package.json) and dist/lib/cli.js when
5707
+ * installed (../../package.json).
5708
+ */
5709
+ function getKitVersion() {
5710
+ const require$2 = (0, node_module.createRequire)(require("url").pathToFileURL(__filename).href);
5711
+ for (const rel of ["../package.json", "../../package.json"]) try {
5712
+ const pkg = require$2(rel);
5713
+ if (typeof pkg.version === "string") return pkg.version;
5714
+ } catch {}
5715
+ return "unknown";
5716
+ }
4778
5717
  function toBuildConfig(options) {
4779
5718
  return {
4780
5719
  root: options.root,
@@ -4788,58 +5727,96 @@ function toBuildConfig(options) {
4788
5727
  hydrateImport: options.hydrateImport,
4789
5728
  routerImport: options.routerImport,
4790
5729
  imageFormats: options.resolvedConfig?.images.formats,
4791
- integrations: options.resolvedConfig?.integrations
5730
+ integrations: options.resolvedConfig?.integrations,
5731
+ site: options.resolvedConfig?.site,
5732
+ js: options.resolvedConfig?.js,
5733
+ router: options.resolvedConfig ? {
5734
+ enabled: options.resolvedConfig.router.enabled,
5735
+ prefetch: options.resolvedConfig.router.prefetch,
5736
+ morph: options.resolvedConfig.router.morph,
5737
+ loadingIndicator: options.resolvedConfig.router.loadingIndicator,
5738
+ speculation: options.resolvedConfig.router.speculation,
5739
+ separate: options.routerSeparate,
5740
+ entry: "/_elur/router.js",
5741
+ outFile: (0, node_path.join)((0, node_path.dirname)(options.generatedEntry), "router.ts")
5742
+ } : void 0
4792
5743
  };
4793
5744
  }
4794
5745
  async function doBuild(options) {
5746
+ const buildStart = Date.now();
4795
5747
  const transformedRoot = (0, node_path.join)(options.root, ".elur", "transformed");
4796
5748
  const transformedAppDir$1 = transformedAppDir(options.root, options.appDir, options.islandsDir, transformedRoot);
5749
+ let phaseStart = performance.now();
4797
5750
  await transformProjectFiles({
4798
5751
  root: options.root,
4799
5752
  appDir: options.appDir,
4800
5753
  islandsDir: options.islandsDir,
4801
5754
  outDir: transformedRoot
4802
5755
  });
5756
+ phase("transform", performance.now() - phaseStart);
4803
5757
  const { beginAtomicStage } = await Promise.resolve().then(() => (init_vite_build(), vite_build_exports));
4804
5758
  const stage = await beginAtomicStage({ outDir: options.outDir });
4805
5759
  const tempOutDir = stage.tempDir;
5760
+ if (options.islandsDir && !options.clientConfig) {
5761
+ const autoConfig = await findClientConfig(options.root);
5762
+ if (autoConfig) options.clientConfig = autoConfig;
5763
+ }
5764
+ options.routerSeparate = await resolveRouterSeparate(options);
4806
5765
  try {
4807
5766
  const buildConfig = toBuildConfig(options);
4808
5767
  buildConfig.appDir = transformedAppDir$1;
4809
5768
  buildConfig.outDir = tempOutDir;
5769
+ buildConfig.onPhase = (name, ms) => phase(name, ms);
4810
5770
  const result = await build$3(buildConfig);
4811
- if (options.resolvedConfig) try {
4812
- const manifest = await createAppManifest(options.resolvedConfig);
4813
- await writeAppManifest(manifest, (0, node_path.join)(tempOutDir, ".elur", "manifest.json"));
4814
- await writeRouteTypes(manifest, (0, node_path.join)(options.root, ".elur", "routes.d.ts"));
4815
- console.log(` - manifest: ${(0, node_path.relative)(options.root, (0, node_path.join)(options.outDir, ".elur", "manifest.json"))}`);
4816
- } catch (err) {
4817
- console.warn("[elur-kit] manifest generation failed:", err);
4818
- }
4819
- if (options.islandsDir && !options.clientConfig) {
4820
- const autoConfig = await findClientConfig(options.root);
4821
- if (autoConfig) options.clientConfig = autoConfig;
5771
+ options.hasIslands = result.islands.length > 0;
5772
+ if (options.resolvedConfig) {
5773
+ phaseStart = performance.now();
5774
+ try {
5775
+ const manifest = await createAppManifest(options.resolvedConfig);
5776
+ await writeAppManifest(manifest, (0, node_path.join)(tempOutDir, ".elur", "manifest.json"));
5777
+ await writeRouteTypes(manifest, (0, node_path.join)(options.root, ".elur", "routes.d.ts"));
5778
+ phase("manifest", performance.now() - phaseStart);
5779
+ } catch (err) {
5780
+ warn(`manifest generation failed: ${err instanceof Error ? err.message : String(err)}`);
5781
+ }
4822
5782
  }
4823
- if (options.clientConfig) {
5783
+ const routerEnabled = options.resolvedConfig?.router.enabled !== false;
5784
+ if (options.clientConfig || options.hasIslands || routerEnabled) {
4824
5785
  const originalOutDir = options.outDir;
4825
5786
  options.outDir = tempOutDir;
4826
5787
  try {
5788
+ phaseStart = performance.now();
4827
5789
  await buildClient(options);
5790
+ phase("client bundle", performance.now() - phaseStart);
4828
5791
  } finally {
4829
5792
  options.outDir = originalOutDir;
4830
5793
  }
4831
5794
  }
4832
5795
  await stage.commit();
4833
- console.log(`✓ Build completo: ${result.pages} páginas generadas`);
4834
- for (const file of result.files) console.log(" -", (0, node_path.relative)(options.root, file));
5796
+ const elapsed = ((Date.now() - buildStart) / 1e3).toFixed(2);
5797
+ success(`${bold("Build completo")} ${dim(`en ${elapsed}s`)}`);
5798
+ info(`${result.pages} página(s), ${result.islands.length} island(s), ${result.files.length} archivo(s)`);
5799
+ const fileEntries = [];
5800
+ for (const file of result.files) {
5801
+ const finalPath = (0, node_path.join)(options.outDir, (0, node_path.relative)(tempOutDir, file));
5802
+ let bytes = 0;
5803
+ try {
5804
+ bytes = (await (0, node_fs_promises.stat)(finalPath)).size;
5805
+ } catch {}
5806
+ fileEntries.push({
5807
+ path: (0, node_path.relative)(options.root, finalPath),
5808
+ bytes
5809
+ });
5810
+ }
5811
+ fileList(fileEntries);
4835
5812
  if (result.islands.length > 0) {
4836
- console.log(`\n✓ ${result.islands.length} island(s) detectada(s):`);
4837
- for (const island of result.islands) console.log(" -", island.name);
4838
- if (result.generatedEntry) console.log(" entry:", (0, node_path.relative)(options.root, result.generatedEntry));
5813
+ success(`${result.islands.length} island(s) detectada(s):`);
5814
+ for (const island of result.islands) detail(island.name);
5815
+ if (result.generatedEntry) detail(`entry: ${(0, node_path.relative)(options.root, result.generatedEntry)}`);
4839
5816
  }
4840
5817
  if (result.skipped.length > 0) {
4841
- console.log("\nRutas dinámicas omitidas (necesitan generateStaticParams):");
4842
- for (const path of result.skipped) console.log(" -", path);
5818
+ warn("Rutas dinámicas omitidas (necesitan generateStaticParams):");
5819
+ for (const path of result.skipped) detail(path);
4843
5820
  }
4844
5821
  } catch (err) {
4845
5822
  await stage.rollback();
@@ -4859,16 +5836,32 @@ async function doDev(options) {
4859
5836
  });
4860
5837
  const actions = await scanActions(transformedAppDir$2);
4861
5838
  const routes = await scanRoutes(transformedAppDir$2);
4862
- const server = (0, node_http.createServer)((req, res) => handleRequest(req, res, options, actions, routes, true));
5839
+ const middleware = await loadUserMiddleware(options.root);
5840
+ options.routerEntry = detectRouterEntry(options);
5841
+ const server = (0, node_http.createServer)((req, res) => handleRequest(req, res, options, actions, routes, true, middleware));
4863
5842
  const shutdown = () => {
4864
5843
  server.close(() => process.exit(0));
4865
5844
  setTimeout(() => process.exit(0), 2e3).unref();
4866
5845
  };
4867
5846
  process.on("SIGTERM", shutdown);
4868
5847
  process.on("SIGINT", shutdown);
4869
- server.listen(options.port, options.host, () => {
4870
- console.log(`\n → Dev server http://${options.host}:${options.port}`);
4871
- });
5848
+ try {
5849
+ const usedPort = await listenWithFallback(server, options.host, options.port, { onFallback: (busyPort, nextPort) => warn(`Puerto ${busyPort} ocupado, usando ${nextPort}`) });
5850
+ const network = getNetworkAddress();
5851
+ serverBanner({
5852
+ name: "elur-kit",
5853
+ version: `v${getKitVersion()}`,
5854
+ command: "dev",
5855
+ localUrl: `http://${options.host}:${usedPort}/`,
5856
+ networkUrl: network ? `http://${network}:${usedPort}/` : void 0
5857
+ });
5858
+ } catch (err) {
5859
+ if (err.code === "EADDRINUSE") {
5860
+ error(`No hay puerto disponible entre ${options.port} y ${options.port + 20}.`);
5861
+ process.exit(78);
5862
+ }
5863
+ throw err;
5864
+ }
4872
5865
  }
4873
5866
  /**
4874
5867
  * Dev supervisor: runs the actual dev server in a child process and restarts
@@ -4886,7 +5879,8 @@ async function doDevSupervisor(options) {
4886
5879
  let respawnTimer = null;
4887
5880
  const startWorker = () => {
4888
5881
  intentional = false;
4889
- console.log("\n[dev] Starting dev server...");
5882
+ console.log();
5883
+ event("dev", "Starting dev server...");
4890
5884
  child = (0, node_child_process.spawn)(process.execPath, [spawnPath, ...args], {
4891
5885
  env: {
4892
5886
  ...process.env,
@@ -4902,7 +5896,11 @@ async function doDevSupervisor(options) {
4902
5896
  return;
4903
5897
  }
4904
5898
  if (code !== 0) {
4905
- console.error(`[dev] Dev server exited with code ${code}; restarting...`);
5899
+ if (code === 78) {
5900
+ error("[dev] Stopping: no available port.");
5901
+ process.exit(code);
5902
+ }
5903
+ error(`[dev] Dev server exited with code ${code}; restarting...`);
4906
5904
  respawnTimer = setTimeout(startWorker, 600);
4907
5905
  }
4908
5906
  });
@@ -4916,7 +5914,8 @@ async function doDevSupervisor(options) {
4916
5914
  if (watchedDirs.length > 0) {
4917
5915
  let timer = null;
4918
5916
  const scheduleRestart = () => {
4919
- console.log("\n[change] Restarting dev server...");
5917
+ console.log();
5918
+ event("change", "Restarting dev server...");
4920
5919
  if (timer) clearTimeout(timer);
4921
5920
  timer = setTimeout(() => restart(), 150);
4922
5921
  };
@@ -4926,7 +5925,7 @@ async function doDevSupervisor(options) {
4926
5925
  else if (filename && /\.ts$/.test(filename)) scheduleRestart();
4927
5926
  });
4928
5927
  } catch (err) {
4929
- console.error(`[dev] failed to watch ${dir}:`, err);
5928
+ error(`[dev] failed to watch ${dir}: ${err instanceof Error ? err.message : String(err)}`);
4930
5929
  }
4931
5930
  }
4932
5931
  const cleanup = () => {
@@ -4940,14 +5939,20 @@ async function doDevSupervisor(options) {
4940
5939
  process.on("SIGTERM", cleanup);
4941
5940
  startWorker();
4942
5941
  }
4943
- async function doPreview(options) {
5942
+ /**
5943
+ * Shared production-serving path for `preview` and `start`: serves the build
5944
+ * output plus dynamic SSR through the unified Web handler, with middleware,
5945
+ * streaming, port fallback and the startup banner. Both commands behave
5946
+ * identically; the label only differs in the banner.
5947
+ */
5948
+ async function startProductionServer(options, command) {
4944
5949
  try {
4945
5950
  if (!(await (0, node_fs_promises.stat)(options.outDir)).isDirectory()) throw new Error(`Output path is not a directory: ${options.outDir}`);
4946
5951
  } catch (err) {
4947
5952
  if (err.code === "ENOENT") throw new Error(`No build output found at ${options.outDir}. Run \`elur-kit build\` first.`);
4948
5953
  throw err;
4949
5954
  }
4950
- const transformedRoot = (0, node_path.join)(options.root, ".elur", "preview-transformed");
5955
+ const transformedRoot = (0, node_path.join)(options.root, ".elur", command === "preview" ? "preview-transformed" : "transformed");
4951
5956
  const transformedAppDir$3 = transformedAppDir(options.root, options.appDir, options.islandsDir, transformedRoot);
4952
5957
  await transformProjectFiles({
4953
5958
  root: options.root,
@@ -4957,32 +5962,25 @@ async function doPreview(options) {
4957
5962
  });
4958
5963
  const actions = await scanActions(transformedAppDir$3);
4959
5964
  const routes = await scanRoutes(transformedAppDir$3);
4960
- const server = (0, node_http.createServer)((req, res) => handleRequest(req, res, options, actions, routes));
4961
- server.listen(options.port, options.host, () => {
4962
- console.log(`\n → Preview server http://${options.host}:${options.port}`);
5965
+ const middleware = await loadUserMiddleware(options.root);
5966
+ options.routerEntry = detectRouterEntry(options);
5967
+ const server = (0, node_http.createServer)((req, res) => handleRequest(req, res, options, actions, routes, false, middleware));
5968
+ const usedPort = await listenWithFallback(server, options.host, options.port, { onFallback: (busyPort, nextPort) => warn(`Puerto ${busyPort} ocupado, usando ${nextPort}`) });
5969
+ const network = getNetworkAddress();
5970
+ serverBanner({
5971
+ name: "elur-kit",
5972
+ version: `v${getKitVersion()}`,
5973
+ command,
5974
+ localUrl: `http://${options.host}:${usedPort}/`,
5975
+ networkUrl: network ? `http://${network}:${usedPort}/` : void 0
4963
5976
  });
4964
5977
  return server;
4965
5978
  }
5979
+ async function doPreview(options) {
5980
+ return startProductionServer(options, "preview");
5981
+ }
4966
5982
  async function doStart(options) {
4967
- const transformedRoot = (0, node_path.join)(options.root, ".elur", "transformed");
4968
- const transformedAppDir$4 = transformedAppDir(options.root, options.appDir, options.islandsDir, transformedRoot);
4969
- await transformProjectFiles({
4970
- root: options.root,
4971
- appDir: options.appDir,
4972
- islandsDir: options.islandsDir,
4973
- outDir: transformedRoot
4974
- });
4975
- await (await createSsrServer({
4976
- root: options.root,
4977
- appDir: transformedAppDir$4,
4978
- publicDir: options.outDir,
4979
- clientEntry: options.clientEntry,
4980
- lang: options.lang,
4981
- port: options.port,
4982
- host: options.host,
4983
- cacheDir: options.cacheDir,
4984
- defaultRevalidate: options.defaultRevalidate
4985
- })).listen();
5983
+ await startProductionServer(options, "start");
4986
5984
  }
4987
5985
  async function findClientConfig(root) {
4988
5986
  for (const name of [
@@ -4996,21 +5994,71 @@ async function findClientConfig(root) {
4996
5994
  } catch {}
4997
5995
  }
4998
5996
  }
5997
+ /**
5998
+ * Decides whether the client bundle emits the router as its own chunk.
5999
+ *
6000
+ * - `js: "legacy"` or `router.enabled: false` → never split (the entry is
6001
+ * hydrate-only when the router is off; legacy embeds the router).
6002
+ * - A user-provided client config splits only when it declares the generated
6003
+ * router module (`.elur/router.ts`) as a bundle input — a single input is
6004
+ * "legacy de facto": the router stays embedded in `entry-client.js`.
6005
+ * - Without a user config, the kit synthesizes a default two-input config →
6006
+ * split.
6007
+ */
6008
+ async function resolveRouterSeparate(options) {
6009
+ const rc = options.resolvedConfig;
6010
+ if (!rc || rc.js === "legacy" || rc.router.enabled === false) return false;
6011
+ const routerFile = (0, node_path.join)((0, node_path.dirname)(options.generatedEntry), "router.ts");
6012
+ if (!options.clientConfig) return true;
6013
+ try {
6014
+ const { resolveClientInputs } = await Promise.resolve().then(() => (init_vite_build(), vite_build_exports));
6015
+ return (await resolveClientInputs(options.clientConfig, options.root)).includes(routerFile);
6016
+ } catch {
6017
+ return false;
6018
+ }
6019
+ }
6020
+ /**
6021
+ * Public URL of the router chunk when the built bundle actually contains it.
6022
+ * The file check keeps `preview`/`start` consistent with whatever layout the
6023
+ * last build produced (split or legacy single-entry).
6024
+ */
6025
+ function detectRouterEntry(options) {
6026
+ const rc = options.resolvedConfig;
6027
+ if (!rc || rc.js === "legacy" || rc.router.enabled === false) return void 0;
6028
+ return (0, node_fs.existsSync)((0, node_path.join)(options.outDir, "_elur", "router.js")) ? "/_elur/router.js" : void 0;
6029
+ }
4999
6030
  async function buildClient(options) {
5000
- if (!options.clientConfig) return;
5001
6031
  const { buildClientBundle } = await Promise.resolve().then(() => (init_vite_build(), vite_build_exports));
5002
6032
  const clientOutDir = (0, node_path.join)(options.outDir, "_elur");
6033
+ const clientBase = "/_elur/";
6034
+ const defaultInputs = { "entry-client": options.generatedEntry };
6035
+ if (options.routerSeparate) defaultInputs.router = (0, node_path.join)((0, node_path.dirname)(options.generatedEntry), "router.ts");
5003
6036
  await buildClientBundle({
5004
6037
  root: options.root,
5005
- userConfigPath: (0, node_path.resolve)(options.clientConfig),
6038
+ userConfigPath: options.clientConfig ? (0, node_path.resolve)(options.clientConfig) : void 0,
6039
+ defaultInputs,
5006
6040
  appDir: (0, node_path.join)(options.root, "src", "app"),
5007
6041
  islandsDir: (0, node_path.join)(options.root, "src", "islands"),
5008
6042
  outDir: clientOutDir,
5009
- base: "/_elur/",
5010
- logPrefix: "[client]"
6043
+ base: clientBase,
6044
+ logPrefix: "[client]",
6045
+ quiet: options.logLevel === "error"
5011
6046
  });
5012
6047
  }
5013
- async function handleRequest(req, res, options, actions, routes, noCache = false) {
6048
+ /**
6049
+ * Loads the project's `src/middleware.ts` for dev/preview/start. A missing
6050
+ * file is fine; a broken one warns but does not stop the server.
6051
+ */
6052
+ async function loadUserMiddleware(root) {
6053
+ const { loadMiddleware } = await Promise.resolve().then(() => (init_middleware(), middleware_exports));
6054
+ try {
6055
+ return await loadMiddleware(root);
6056
+ } catch (err) {
6057
+ warn(err instanceof Error ? err.message : String(err));
6058
+ return null;
6059
+ }
6060
+ }
6061
+ async function handleRequest(req, res, options, actions, routes, noCache = false, middleware) {
5014
6062
  const { createWebHandler } = await Promise.resolve().then(() => (init_handler(), handler_exports));
5015
6063
  const securityHeaders = options.resolvedConfig?.security?.headers;
5016
6064
  const webHandler = createWebHandler(routes, actions, {
@@ -5021,20 +6069,36 @@ async function handleRequest(req, res, options, actions, routes, noCache = false
5021
6069
  lang: options.lang,
5022
6070
  clientEntry: options.clientEntry,
5023
6071
  renderEndpoint: true,
5024
- securityHeaders: securityHeaders === void 0 ? false : securityHeaders
6072
+ router: {
6073
+ enabled: options.resolvedConfig?.router.enabled ?? true,
6074
+ entry: options.routerEntry
6075
+ },
6076
+ js: options.resolvedConfig?.js,
6077
+ securityHeaders: securityHeaders === void 0 ? false : securityHeaders,
6078
+ logLevel: options.resolvedConfig?.logger?.level,
6079
+ cacheAdapter: options.resolvedConfig?.cache?.adapter,
6080
+ redirects: options.resolvedConfig?.redirects,
6081
+ rewrites: options.resolvedConfig?.rewrites,
6082
+ routeHeaders: options.resolvedConfig?.headers,
6083
+ streaming: options.resolvedConfig?.streaming,
6084
+ middleware: middleware ?? void 0
5025
6085
  });
5026
6086
  const request = incomingMessageToRequest(req, req.method && req.method !== "GET" && req.method !== "HEAD" ? await readRequestBody(req) : void 0);
5027
6087
  let response;
5028
6088
  try {
5029
6089
  response = await webHandler(request);
5030
6090
  } catch (err) {
5031
- console.error("[elur-kit] request error:", err);
6091
+ createRequestLogger(request, options.resolvedConfig?.logger?.level).error("[elur-kit] request error", {
6092
+ path: new URL(request.url).pathname,
6093
+ method: request.method,
6094
+ error: err instanceof Error ? err.message : String(err),
6095
+ stack: err instanceof Error ? err.stack : void 0
6096
+ });
5032
6097
  res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
5033
6098
  res.end("Internal Server Error");
5034
6099
  return;
5035
6100
  }
5036
- res.writeHead(response.status, Object.fromEntries(response.headers.entries()));
5037
- res.end(Buffer.from(await response.arrayBuffer()));
6101
+ await sendWebResponse(res, response);
5038
6102
  }
5039
6103
  function readRequestBody(req) {
5040
6104
  return new Promise((resolve, reject) => {
@@ -5056,34 +6120,47 @@ async function doAdapter(options) {
5056
6120
  publicDir: options.publicDir,
5057
6121
  clientEntry: options.clientEntry,
5058
6122
  lang: options.lang,
5059
- hydrateImport: options.hydrateImport
6123
+ hydrateImport: options.hydrateImport,
6124
+ logLevel: options.resolvedConfig?.logger?.level,
6125
+ cacheAdapter: options.resolvedConfig?.cache?.adapter,
6126
+ redirects: options.resolvedConfig?.redirects,
6127
+ rewrites: options.resolvedConfig?.rewrites,
6128
+ routeHeaders: options.resolvedConfig?.headers,
6129
+ streaming: options.resolvedConfig?.streaming,
6130
+ router: { enabled: options.resolvedConfig?.router.enabled ?? true },
6131
+ js: options.resolvedConfig?.js
5060
6132
  };
5061
6133
  const resolvedConfig = options.resolvedConfig;
5062
6134
  const features = {
5063
6135
  isr: typeof resolvedConfig?.cache?.defaultRevalidate === "number" && resolvedConfig.cache.defaultRevalidate > 0,
5064
- images: resolvedConfig?.images?.strict === true
6136
+ images: resolvedConfig?.images?.strict === true,
6137
+ streaming: resolvedConfig?.streaming === true
5065
6138
  };
5066
6139
  let adapterName = options.adapterName;
5067
6140
  if (adapterName === "vercel") {
5068
6141
  const { vercelAdapter } = await Promise.resolve().then(() => (init_vercel(), vercel_exports));
5069
6142
  assertCapabilities(vercelAdapter, features, adapterName);
5070
6143
  await vercelAdapter.build(adapterOptions);
5071
- console.log("\n → Vercel output generated at .vercel/output");
6144
+ console.log();
6145
+ info("Vercel output generated at .vercel/output");
5072
6146
  } else if (adapterName === "netlify") {
5073
6147
  const { netlifyAdapter } = await Promise.resolve().then(() => (init_netlify(), netlify_exports));
5074
6148
  assertCapabilities(netlifyAdapter, features, adapterName);
5075
6149
  await netlifyAdapter.build(adapterOptions);
5076
- console.log("\n → Netlify output generated at netlify/functions/__elur-js-kit.mjs");
6150
+ console.log();
6151
+ info("Netlify output generated at netlify/functions/__elur-js-kit.mjs");
5077
6152
  } else if (adapterName === "bun") {
5078
6153
  const { bunAdapter } = await Promise.resolve().then(() => (init_bun(), bun_exports));
5079
6154
  assertCapabilities(bunAdapter, features, adapterName);
5080
6155
  await bunAdapter.build(adapterOptions);
5081
- console.log("\n → Bun server generated at .elur/bun-server.ts");
6156
+ console.log();
6157
+ info("Bun server generated at .elur/bun-server.ts");
5082
6158
  } else if (adapterName === "node") {
5083
6159
  const { nodeAdapter } = await Promise.resolve().then(() => (init_node(), node_exports));
5084
6160
  assertCapabilities(nodeAdapter, features, adapterName);
5085
6161
  await nodeAdapter.build(adapterOptions);
5086
- console.log("\n → Node server generated at .elur/node-server.mjs");
6162
+ console.log();
6163
+ info("Node server generated at .elur/node-server.mjs");
5087
6164
  }
5088
6165
  }
5089
6166
  function assertCapabilities(adapter, features, adapterName) {
@@ -5107,11 +6184,13 @@ async function applyProjectConfig(options, argv) {
5107
6184
  if (!has("--public")) options.publicDir = config.publicDir;
5108
6185
  if (!has("--cache-dir")) options.cacheDir = config.cache.dir;
5109
6186
  if (!has("--default-revalidate")) options.defaultRevalidate = config.cache.defaultRevalidate;
6187
+ if (options.logLevel) config.logger.level = options.logLevel;
5110
6188
  options.generatedEntry = (0, node_path.resolve)(config.root, ".elur/entry-client.ts");
5111
6189
  options.resolvedConfig = config;
5112
6190
  }
5113
6191
  async function run(argv) {
5114
6192
  const options = parseArgs(argv);
6193
+ if (options.logLevel === "error") setQuiet(true);
5115
6194
  if (options.command === "doctor") {
5116
6195
  const { doDoctor } = await Promise.resolve().then(() => (init_commands(), commands_exports));
5117
6196
  const code = await doDoctor(options);