@elurjs/kit 2.4.10 → 2.5.1

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