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