@elurjs/kit 2.4.9 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/CHANGELOG.md +306 -0
  2. package/README.md +195 -9
  3. package/dist/lib/action/index.cjs +1 -1
  4. package/dist/lib/action/index.js +1 -1
  5. package/dist/lib/{action-DZHihU3Z.cjs → action-BiE4tEty.cjs} +2 -2
  6. package/dist/lib/{action-DZHihU3Z.cjs.map → action-BiE4tEty.cjs.map} +1 -1
  7. package/dist/lib/{action-BB6N5yD-.js → action-CXVk8rWt.js} +3 -3
  8. package/dist/lib/{action-BB6N5yD-.js.map → action-CXVk8rWt.js.map} +1 -1
  9. package/dist/lib/adapter-DNsjXvq5.js +160 -0
  10. package/dist/lib/adapter-DNsjXvq5.js.map +1 -0
  11. package/dist/lib/adapter-Dz2hzR36.cjs +3 -0
  12. package/dist/lib/adapter-Dz2hzR36.cjs.map +1 -0
  13. package/dist/lib/adapters/bun.cjs +2 -2
  14. package/dist/lib/adapters/bun.cjs.map +1 -1
  15. package/dist/lib/adapters/bun.js +17 -9
  16. package/dist/lib/adapters/bun.js.map +1 -1
  17. package/dist/lib/adapters/index.d.cts +34 -0
  18. package/dist/lib/adapters/index.d.ts +34 -0
  19. package/dist/lib/adapters/netlify.cjs +1 -1
  20. package/dist/lib/adapters/netlify.js +7 -7
  21. package/dist/lib/adapters/node.cjs +5 -5
  22. package/dist/lib/adapters/node.cjs.map +1 -1
  23. package/dist/lib/adapters/node.js +20 -12
  24. package/dist/lib/adapters/node.js.map +1 -1
  25. package/dist/lib/adapters/vercel.cjs +1 -1
  26. package/dist/lib/adapters/vercel.js +8 -8
  27. package/dist/lib/build/build.d.cts +40 -0
  28. package/dist/lib/build/build.d.ts +40 -0
  29. package/dist/lib/build/document-shell.d.cts +47 -1
  30. package/dist/lib/build/document-shell.d.ts +47 -1
  31. package/dist/lib/build/vite-build.d.cts +25 -2
  32. package/dist/lib/build/vite-build.d.ts +25 -2
  33. package/dist/lib/cache/adapter.d.cts +41 -0
  34. package/dist/lib/cache/adapter.d.ts +41 -0
  35. package/dist/lib/cache/index.cjs +2 -0
  36. package/dist/lib/cache/index.cjs.map +1 -0
  37. package/dist/lib/cache/index.d.cts +5 -0
  38. package/dist/lib/cache/index.d.ts +5 -0
  39. package/dist/lib/cache/index.js +113 -0
  40. package/dist/lib/cache/index.js.map +1 -0
  41. package/dist/lib/cache/invalidation.d.cts +36 -0
  42. package/dist/lib/cache/invalidation.d.ts +36 -0
  43. package/dist/lib/cache/redis-adapter.d.cts +49 -0
  44. package/dist/lib/cache/redis-adapter.d.ts +49 -0
  45. package/dist/lib/cache-DGhTYyqh.js +50 -0
  46. package/dist/lib/cache-DGhTYyqh.js.map +1 -0
  47. package/dist/lib/cache-X1n4Ossu.cjs +2 -0
  48. package/dist/lib/cache-X1n4Ossu.cjs.map +1 -0
  49. package/dist/lib/cli/output.d.cts +58 -0
  50. package/dist/lib/cli/output.d.ts +58 -0
  51. package/dist/lib/cli/ports.d.cts +20 -0
  52. package/dist/lib/cli/ports.d.ts +20 -0
  53. package/dist/lib/cli.cjs +2568 -1489
  54. package/dist/lib/cli.cjs.map +1 -1
  55. package/dist/lib/cli.d.cts +21 -0
  56. package/dist/lib/cli.d.ts +21 -0
  57. package/dist/lib/cli.js +2567 -1489
  58. package/dist/lib/cli.js.map +1 -1
  59. package/dist/lib/config/index.cjs +1 -1
  60. package/dist/lib/config/index.cjs.map +1 -1
  61. package/dist/lib/config/index.d.cts +79 -0
  62. package/dist/lib/config/index.d.ts +79 -0
  63. package/dist/lib/config/index.js +21 -14
  64. package/dist/lib/config/index.js.map +1 -1
  65. package/dist/lib/{errors-ePYxDlhU.js → errors-j4cg9VN3.js} +24 -30
  66. package/dist/lib/{errors-ePYxDlhU.js.map → errors-j4cg9VN3.js.map} +1 -1
  67. package/dist/lib/errors-kSl9yLkg.cjs +2 -0
  68. package/dist/lib/{errors-0X7pCFNU.cjs.map → errors-kSl9yLkg.cjs.map} +1 -1
  69. package/dist/lib/hydrate-BSLuI0Nh.cjs +2 -0
  70. package/dist/lib/hydrate-BSLuI0Nh.cjs.map +1 -0
  71. package/dist/lib/hydrate-BaS7-d8C.js +133 -0
  72. package/dist/lib/hydrate-BaS7-d8C.js.map +1 -0
  73. package/dist/lib/idiomorph.esm-BOOTOg1r.cjs +2 -0
  74. package/dist/lib/idiomorph.esm-BOOTOg1r.cjs.map +1 -0
  75. package/dist/lib/idiomorph.esm-CXuY-EHB.js +371 -0
  76. package/dist/lib/idiomorph.esm-CXuY-EHB.js.map +1 -0
  77. package/dist/lib/index.cjs +1 -1
  78. package/dist/lib/index.cjs.map +1 -1
  79. package/dist/lib/index.d.cts +8 -6
  80. package/dist/lib/index.d.ts +8 -6
  81. package/dist/lib/index.js +1 -1
  82. package/dist/lib/index.js.map +1 -1
  83. package/dist/lib/interpolation-plugin-C0dTd69H.cjs +58 -0
  84. package/dist/lib/interpolation-plugin-C0dTd69H.cjs.map +1 -0
  85. package/dist/lib/{interpolation-plugin-Wgb1j4pT.js → interpolation-plugin-K2qE69tF.js} +95 -126
  86. package/dist/lib/interpolation-plugin-K2qE69tF.js.map +1 -0
  87. package/dist/lib/invalidation-CFpNQUv6.cjs +2 -0
  88. package/dist/lib/invalidation-CFpNQUv6.cjs.map +1 -0
  89. package/dist/lib/invalidation-DXhdE9wj.js +64 -0
  90. package/dist/lib/invalidation-DXhdE9wj.js.map +1 -0
  91. package/dist/lib/island/generate-entry.d.cts +40 -2
  92. package/dist/lib/island/generate-entry.d.ts +40 -2
  93. package/dist/lib/island/hydrate.d.cts +19 -6
  94. package/dist/lib/island/hydrate.d.ts +19 -6
  95. package/dist/lib/island/index.cjs +1 -1
  96. package/dist/lib/island/index.d.cts +2 -1
  97. package/dist/lib/island/index.d.ts +2 -1
  98. package/dist/lib/island/index.js +1 -1
  99. package/dist/lib/island/island.d.cts +15 -0
  100. package/dist/lib/island/island.d.ts +15 -0
  101. package/dist/lib/island-CTBnsOhI.js +59 -0
  102. package/dist/lib/island-CTBnsOhI.js.map +1 -0
  103. package/dist/lib/island-kfQEKRe_.cjs +2 -0
  104. package/dist/lib/island-kfQEKRe_.cjs.map +1 -0
  105. package/dist/lib/node-http-DVFcYeBS.js +723 -0
  106. package/dist/lib/node-http-DVFcYeBS.js.map +1 -0
  107. package/dist/lib/node-http-Uct7DhmN.cjs +13 -0
  108. package/dist/lib/node-http-Uct7DhmN.cjs.map +1 -0
  109. package/dist/lib/rolldown-runtime-BIInC43l.cjs +1 -0
  110. package/dist/lib/rolldown-runtime-Dy4uBu1J.js +11 -0
  111. package/dist/lib/router/client.cjs +1 -1
  112. package/dist/lib/router/client.cjs.map +1 -1
  113. package/dist/lib/router/client.d.cts +55 -8
  114. package/dist/lib/router/client.d.ts +55 -8
  115. package/dist/lib/router/client.js +298 -97
  116. package/dist/lib/router/client.js.map +1 -1
  117. package/dist/lib/router/redirects.d.cts +34 -0
  118. package/dist/lib/router/redirects.d.ts +34 -0
  119. package/dist/lib/runtime/handler.d.cts +59 -0
  120. package/dist/lib/runtime/handler.d.ts +59 -0
  121. package/dist/lib/runtime/index.cjs +1 -1
  122. package/dist/lib/runtime/index.d.cts +2 -1
  123. package/dist/lib/runtime/index.d.ts +2 -1
  124. package/dist/lib/runtime/index.js +4 -4
  125. package/dist/lib/runtime/logger.d.cts +47 -0
  126. package/dist/lib/runtime/logger.d.ts +47 -0
  127. package/dist/lib/runtime/node-http.d.cts +11 -1
  128. package/dist/lib/runtime/node-http.d.ts +11 -1
  129. package/dist/lib/runtime-ChE4flX3.cjs +2 -0
  130. package/dist/lib/runtime-ChE4flX3.cjs.map +1 -0
  131. package/dist/lib/runtime-OMyz5yE-.js +905 -0
  132. package/dist/lib/runtime-OMyz5yE-.js.map +1 -0
  133. package/dist/lib/seo/index.cjs +1 -10
  134. package/dist/lib/seo/index.d.cts +1 -0
  135. package/dist/lib/seo/index.d.ts +1 -0
  136. package/dist/lib/seo/index.js +2 -41
  137. package/dist/lib/seo/sitemap-from-routes.d.cts +24 -0
  138. package/dist/lib/seo/sitemap-from-routes.d.ts +24 -0
  139. package/dist/lib/{shared-CfqOLsKj.cjs → shared-CP0Dc3cp.cjs} +72 -47
  140. package/dist/lib/shared-CP0Dc3cp.cjs.map +1 -0
  141. package/dist/lib/{shared-DU3iQgr7.js → shared-D8w-neq2.js} +87 -58
  142. package/dist/lib/shared-D8w-neq2.js.map +1 -0
  143. package/dist/lib/sitemap-from-routes-CaBoiGvE.js +77 -0
  144. package/dist/lib/sitemap-from-routes-CaBoiGvE.js.map +1 -0
  145. package/dist/lib/sitemap-from-routes-DbcLLL4G.cjs +13 -0
  146. package/dist/lib/sitemap-from-routes-DbcLLL4G.cjs.map +1 -0
  147. package/dist/lib/ssr/render.d.cts +8 -2
  148. package/dist/lib/ssr/render.d.ts +8 -2
  149. package/dist/lib/ssr/server.d.cts +7 -0
  150. package/dist/lib/ssr/server.d.ts +7 -0
  151. package/dist/lib/ssr/stream-response.d.cts +30 -0
  152. package/dist/lib/ssr/stream-response.d.ts +30 -0
  153. package/dist/lib/ssr/stream.d.cts +16 -1
  154. package/dist/lib/ssr/stream.d.ts +16 -1
  155. package/dist/lib/{stream-DmZjYqHm.cjs → stream-Cg-xYG82.cjs} +3 -3
  156. package/dist/lib/stream-Cg-xYG82.cjs.map +1 -0
  157. package/dist/lib/stream-D-ytogdu.js +106 -0
  158. package/dist/lib/stream-D-ytogdu.js.map +1 -0
  159. package/dist/lib/vite/index.cjs +1 -1
  160. package/dist/lib/vite/index.cjs.map +1 -1
  161. package/dist/lib/vite/index.d.cts +11 -0
  162. package/dist/lib/vite/index.d.ts +11 -0
  163. package/dist/lib/vite/index.js +101 -94
  164. package/dist/lib/vite/index.js.map +1 -1
  165. package/package.json +26 -9
  166. package/dist/lib/errors-0X7pCFNU.cjs +0 -2
  167. package/dist/lib/hydrate-CJbUWdXu.js +0 -126
  168. package/dist/lib/hydrate-CJbUWdXu.js.map +0 -1
  169. package/dist/lib/hydrate-D6MR24mz.cjs +0 -2
  170. package/dist/lib/hydrate-D6MR24mz.cjs.map +0 -1
  171. package/dist/lib/interpolation-plugin-TWDch8nn.cjs +0 -39
  172. package/dist/lib/interpolation-plugin-TWDch8nn.cjs.map +0 -1
  173. package/dist/lib/interpolation-plugin-Wgb1j4pT.js.map +0 -1
  174. package/dist/lib/node-http-DRAUhO0c.js +0 -617
  175. package/dist/lib/node-http-DRAUhO0c.js.map +0 -1
  176. package/dist/lib/node-http-DhxguYyz.cjs +0 -13
  177. package/dist/lib/node-http-DhxguYyz.cjs.map +0 -1
  178. package/dist/lib/runtime-CrH5X-Es.cjs +0 -2
  179. package/dist/lib/runtime-CrH5X-Es.cjs.map +0 -1
  180. package/dist/lib/runtime-Ds2-wH6P.js +0 -520
  181. package/dist/lib/runtime-Ds2-wH6P.js.map +0 -1
  182. package/dist/lib/seo/index.cjs.map +0 -1
  183. package/dist/lib/seo/index.js.map +0 -1
  184. package/dist/lib/shared-CfqOLsKj.cjs.map +0 -1
  185. package/dist/lib/shared-DU3iQgr7.js.map +0 -1
  186. package/dist/lib/stream-CpOG052V.js +0 -104
  187. package/dist/lib/stream-CpOG052V.js.map +0 -1
  188. package/dist/lib/stream-DmZjYqHm.cjs.map +0 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,312 @@ All notable changes to Elur Kit are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [2.5.0]
9
+
10
+ All changes are additive and backwards-compatible: existing projects work
11
+ unchanged without touching their config.
12
+
13
+ ### Added
14
+
15
+ - **Structured request logging** — the unified web handler
16
+ (`createWebHandler`, used by the dev server, preview server, and the
17
+ generated Node/Bun servers) now creates a `StructuredLogger` per request
18
+ instead of bare `console.error` calls. Error logs are emitted as JSON in
19
+ production (readable `[LEVEL]` text in dev) with structured fields
20
+ (`path`, `method`, `route`, `error`, `stack`), sensitive-header
21
+ redaction, and a request ID taken from the incoming `X-Request-ID`
22
+ header or generated per request. Configure via
23
+ `defineConfig({ logger: { level: "debug" | "info" | "warn" | "error" } })`
24
+ (default: `debug` in dev, `info` in production).
25
+ - **`X-Request-ID` response header** on every handler response, enabling
26
+ request correlation between clients, proxies, and logs.
27
+ - **`Server-Timing` response header** with per-phase timings (`action`,
28
+ `render-endpoint`, `api`, `ssr`), visible in browser DevTools → Network
29
+ → Server Timing.
30
+ - **Redirects, rewrites, and route headers** — the previously internal
31
+ `src/router/redirects.ts` module is now wired into the handler pipeline.
32
+ Configure via `defineConfig({ redirects, rewrites, headers })`:
33
+ - `redirects: [{ from, to, status? }]` are evaluated before all routing
34
+ and return a `Location` response (default status `308`; also `301`,
35
+ `302`, `307`).
36
+ - `rewrites: [{ from, to }]` rewrite the pathname transparently (the
37
+ browser URL does not change); the rewritten path drives static
38
+ serving, SSR, API routes, and the ISR cache key.
39
+ - `headers: [{ path, headers }]` merge custom headers into responses
40
+ matching the public (pre-rewrite) path; they may override security
41
+ headers, while `Server-Timing`/`X-Request-ID` always win.
42
+ - Patterns support literal segments, `:param`, `*` wildcards, and
43
+ `:param*` catch-alls, with `:param` interpolation in destinations.
44
+ - Works in dev, preview, and the generated Node/Bun servers. The
45
+ Vercel/Netlify bundled handlers do not pick this up yet.
46
+ - **Public exports** — `StructuredLogger`, `createRequestLogger`, and the
47
+ `LogLevel`/`LogEntry`/`ServerTimingMetric` types are now exported from
48
+ the package root.
49
+ - **Pluggable ISR cache adapters in the handler** — the unified web
50
+ handler now stores rendered pages through the `CacheAdapter` interface
51
+ instead of the legacy cache module. Configure via
52
+ `defineConfig({ cache: { adapter } })` (e.g. Redis or Cloudflare KV);
53
+ when omitted, a filesystem adapter rooted at `cache.dir` is created and
54
+ shared per directory. Page entries are keyed with `cacheKey(pathname)`
55
+ (SHA-256), the same scheme used by path-based invalidation.
56
+ - **Cache policy tags are now honored** — `tags` declared in a route's
57
+ `cache` policy (`page.data.ts`) are stored with the cache entry, so
58
+ tag-based invalidation works end to end.
59
+ - **Automatic cache invalidation from actions** — after a successful
60
+ action run, `invalidateTags`/`invalidatePaths` declared via
61
+ `defineAction()` are dispatched to the connected cache adapter
62
+ (`defaultInvalidator`). Actions that return `fail(...)` do not
63
+ invalidate anything.
64
+ - **Stale-while-revalidate for ISR pages** — stale cache entries are
65
+ served immediately while the page re-renders in the background, instead
66
+ of blocking the request on a full re-render.
67
+ - **New `@elurjs/kit/cache` subpath** — exports `CacheAdapter`,
68
+ `createFsCacheAdapter`, `createRedisCacheAdapter`,
69
+ `createCloudflareKVCacheAdapter`, `getWithSWR`, `cacheKey`, the cache
70
+ policy helpers, and the invalidation primitives.
71
+ - **Automatic sitemap generation in `build`** — when `site` is configured
72
+ in `defineConfig`, the build now writes `sitemap.xml` from the scanned
73
+ route manifest (dynamic routes, error pages and internal namespaces are
74
+ excluded; sites over 50,000 URLs are split into `sitemap-N.xml` files
75
+ with a `sitemap.xml` index). An existing `sitemap.xml` — copied from
76
+ `public/` or written by an integration `build` hook — always takes
77
+ precedence and is never overwritten. `generateSitemapFromRoutes` and its
78
+ options type are re-exported from `@elurjs/kit/seo`.
79
+ - **`--verbose` / `--quiet` CLI flags** — override `logger.level` from the
80
+ config: `--verbose` maps to `debug`, `--quiet` maps to `error` (only
81
+ errors are printed; `--quiet` wins if both are passed).
82
+ - **Real streaming SSR in the unified handler (opt-in, experimental)** —
83
+ `defineConfig({ streaming: true })` enables true streaming for dynamic
84
+ routes that declare a `loading` boundary: the document shell + loading
85
+ fallback are flushed immediately, the page render continues in the
86
+ background, and the resolved content arrives as a `<template>` chunk that
87
+ swaps the boundary in-place. Details:
88
+ - Wired into `createWebHandler` (new `streaming` and `capabilities`
89
+ options), so `dev`, `preview`, `start` and the generated Node/Bun
90
+ servers all stream through the same code path. Hosts that declare
91
+ `capabilities.streaming: false` degrade to buffered rendering, and the
92
+ CLI `adapter` command validates the combination at build time.
93
+ - Streamed responses send `Content-Type: text/html` early, no
94
+ `Content-Length`, `X-Accel-Buffering: no` (nginx must not buffer) and
95
+ `Cache-Control: no-store`. `Server-Timing`'s `ssr` metric measures
96
+ time-to-shell.
97
+ - **ISR interaction**: streamed pages bypass the cache entirely (never
98
+ read, never written); buffered routes keep the existing ISR behavior.
99
+ - **Abort handling**: client disconnects cancel the stream via
100
+ `request.signal`; late background renders are discarded. The Node
101
+ server now writes responses with the new `sendWebResponse()` helper
102
+ (`@elurjs/kit/runtime`), which forwards chunks as they are produced
103
+ with backpressure and cancels the upstream stream on socket close.
104
+ - **Mid-stream failures**: a loader redirect `Response` navigates via an
105
+ inline script chunk; a render error swaps the boundary for an
106
+ inline-styled `role="alert"` notice instead of a dead spinner.
107
+ - `documentShell` now delimits the `#app` content with explicit
108
+ `<!--elur:app:start-->`/`<!--elur:app:end-->` markers (invisible HTML
109
+ comments), replacing the fragile `<div id="app">` regex extraction;
110
+ `extractAppBody()` is exported for adapters. `createStreamingResponse`
111
+ and `createBufferedResponse` are now exported from the package root.
112
+ - **User middleware in the unified handler** — `src/middleware.ts` now runs
113
+ in `dev`, `preview` and `start` (previously only the legacy SSR server ran
114
+ it). Semantics: after redirects/rewrites and the internal endpoints,
115
+ before routing; a returned `Response` short-circuits through the standard
116
+ finalize step (security headers, `X-Request-ID`, `Server-Timing` still
117
+ apply); `next({ headers })` merges into the downstream request and
118
+ `next({ locals })` is exposed to API routes as `ctx.locals`. Middleware
119
+ errors return a sanitized 500 instead of crashing the request. The
120
+ generated Node/Bun adapter servers do not run the middleware file yet.
121
+ - **`RedirectRule` / `RewriteRule` / `RouteHeadersRule` type exports** from
122
+ the package root, so config rule arrays can be annotated outside
123
+ `defineConfig`.
124
+ - **0% JavaScript by default (per-page script gating)** — `documentShell`
125
+ no longer emits the client entry unconditionally. In the default
126
+ `js: "modern"` mode the shell inspects the rendered body for
127
+ `data-elur-island` markers: pages without islands ship **no client JS**
128
+ when the router is disabled, or **only the router chunk** when it is on.
129
+ Streaming shells are exempt (the body is not known when the shell is
130
+ sent, so streamed routes always emit the entry). Opt out entirely with
131
+ `defineConfig({ js: "legacy" })`, which restores the unconditional
132
+ combined entry.
133
+ - **Split client entries (hydration vs router)** — the generated client
134
+ entry is now hydrate-only and the router lives in its own module
135
+ (`.elur/router.ts` → `/_elur/router.js`). The CLI decides the layout
136
+ before rendering pages: projects with a two-input
137
+ `vite.client.config.*` (or no client config at all — a default
138
+ two-input config is now synthesized) get the split; single-input
139
+ configs keep the router embedded in `entry-client.js` and the shell
140
+ emits it whenever islands exist *or* the router is enabled. Wired
141
+ through `ElurConfig.router`, `BuildConfig.router`,
142
+ `GenerateEntryOptions.router`, `WebHandlerOptions.router`,
143
+ `AdapterOptions.router`/`js`, `documentShell` (`routerEntry`,
144
+ `routerEnabled`), and the generated adapter SSR entries. The example
145
+ `vite.client.config.ts` now uses two named inputs.
146
+ - **`<link rel="modulepreload">` for emitted entries** — every module
147
+ script the shell emits (hydration entry and/or router chunk) also gets
148
+ a `modulepreload` link so the fetch starts during HTML parsing.
149
+ - **Navigation lifecycle events** — the client router dispatches
150
+ `elur:navigate-start`, `elur:navigate-end` and `elur:navigate-error`
151
+ with `{ pathname, search, fromCache, popstate }`, plus
152
+ `elur:before-render` right before the `#app` swap (with
153
+ `detail.persisted` listing the nodes that will survive). Islands are
154
+ now disposed on `elur:before-render` — while still attached — instead
155
+ of on `elur:rendered`.
156
+ - **`data-elur-persist` element persistence** — mark an element with
157
+ `data-elur-persist="key"` and the router moves the *same live node*
158
+ into the matching position of the new page (matched by attribute
159
+ value) using `Element.moveBefore()` when available
160
+ (`replaceWith` fallback). Island state, media playback and scroll
161
+ positions survive navigations; islands inside persisted subtrees are
162
+ skipped by the navigation cleanup. When an island inside a persisted
163
+ node would receive different props, an `elur:persist-props-changed`
164
+ event bubbles from its marker. `cleanupHydratedIslands({ except })`
165
+ supports exclusion lists.
166
+ - **Complete SPA render payload** — `/__elur-js/render` now returns
167
+ `{ title, body, head, data, actions, clearActionErrorCookie }` in every
168
+ runtime (unified handler, generated Node/Bun adapters, dev server and
169
+ the legacy `createSsrServer`): `head` carries the managed
170
+ `data-elur-head` tags (title/meta/OG/Twitter), `data`/`actions` carry
171
+ the serialized contents of `#elur-data`/`#elur-actions`, and
172
+ `clearActionErrorCookie` is also relayed as the
173
+ `X-Elur-Action-Clear-Cookie` response header. Loader-thrown
174
+ `Response`s propagate as first-class responses. After a navigation the
175
+ router refreshes the inert `#elur-data`/`#elur-actions` scripts, merges
176
+ the managed head tags, and re-executes inline `<script>` elements in
177
+ the new body (external `src` scripts are deduplicated across
178
+ navigations; `data-elur-no-reload` opts out).
179
+ - **Router robustness** — the prefetch cache is now a bounded LRU (32
180
+ entries, 30 s TTL), `history.scrollRestoration` is set to `manual`
181
+ with per-entry scroll save/restore on back/forward, prefetching starts
182
+ on `pointerdown` in addition to hover/focus (and opt-in viewport via
183
+ `data-prefetch="viewport"`), and constrained networks are respected:
184
+ `Save-Data` and `slow-2g`/`2g` effective connection types skip prefetch
185
+ unless the link forces it with `data-prefetch="always"` (or
186
+ `prefetch(path, search, { force: true })` is called directly).
187
+ - **Optional DOM morphing (`router.morph`)** — `defineConfig({ router: {
188
+ morph: true } })` swaps `#app` with idiomorph-based DOM morphing
189
+ instead of a wholesale `replaceChildren` (experimental). Hydrated
190
+ islands and `data-elur-persist` subtrees are treated as opaque. New
191
+ dependency: `idiomorph` — loaded via dynamic `import()` only when the
192
+ flag is on, so it stays out of the served bytes when morph is off
193
+ (a hard dependency is required because bundlers must resolve the
194
+ specifier at build time even when the chunk is lazy).
195
+ - **Optional loading indicator (`router.loadingIndicator`)** — a minimal
196
+ top progress bar appears only when a navigation takes longer than
197
+ ~200 ms, driven by the lifecycle events; it honors
198
+ `prefers-reduced-motion` (static bar, no trickle) and never flashes on
199
+ cache hits.
200
+ - **Speculation Rules (`router.speculation`)** —
201
+ `defineConfig({ router: { speculation: "prefetch" | "prerender" } })`
202
+ emits a `<script type="speculationrules">` block with document rules
203
+ (`eagerness: "moderate"`, internal same-origin links excluding actions,
204
+ downloads, `target`/`data-no-router`/`data-no-speculation` links) on
205
+ statically built pages. Chromium-only progressive enhancement; other
206
+ browsers ignore it.
207
+
208
+ ### Deprecated
209
+
210
+ - **`createSsrServer`** (`@elurjs/kit`) — the legacy standalone SSR server
211
+ pipeline. All CLI commands (`dev`, `preview`, `start`) now run through
212
+ `createWebHandler`; the legacy server remains exported for backward
213
+ compatibility and will be removed in a future major release.
214
+ - **`renderStreamingPage`** — the legacy shell + client-fetch streaming
215
+ approach, superseded by `createStreamingResponse` (real streaming).
216
+
217
+ ### Changed
218
+
219
+ - **`elur-kit start` runs on the unified Web handler** — it previously used
220
+ the legacy `createSsrServer` pipeline. It now shares the dev/preview path:
221
+ startup banner with Local/Network URLs, busy-port fallback, per-request
222
+ structured logging with `Server-Timing`/`X-Request-ID`, security headers,
223
+ redirects/rewrites/route headers, pluggable ISR cache, user middleware, and
224
+ opt-in streaming SSR. `start` now requires a previous `elur-kit build`
225
+ (it fails fast with a clear message when `dist/` is missing) instead of
226
+ silently rendering everything on demand.
227
+ - **CLI output formatting** — build, dev, preview, and adapter messages
228
+ now use a consistent format (`✓` success, `→` info, `!` warning, `✗`
229
+ error, `[tag]` lifecycle events) with ANSI colors when stdout is a TTY
230
+ (disabled via `NO_COLOR` or when piped; no new dependencies). The build
231
+ summary now includes the total duration plus page/island/file counts.
232
+ The fatal request-error catch in the dev/preview server now logs through
233
+ the structured logger (with `path`, `method`, `error`, `stack`) instead
234
+ of a bare `console.error`.
235
+ - **Dev/preview/start startup banner** — the dev, preview and start servers
236
+ now print an Astro-style banner with the kit name and version and the
237
+ bound URLs:
238
+ `→ Local: http://…` plus `→ Network: http://<LAN-IP>:…` when the
239
+ machine has a non-internal IPv4 address. The banner reflects the port
240
+ actually bound after the busy-port fallback.
241
+ - **Build phase timings and file sizes** — `elur-kit build` now reports
242
+ each phase with a checkmark and its duration (`transform`, `scan`,
243
+ `pages`, `images`, `integrations`, `sitemap`, `manifest`,
244
+ `client bundle`; phases that don't run are omitted) via a new optional
245
+ `BuildConfig.onPhase(name, durationMs)` observer, and lists the
246
+ generated files with their sizes (aligned, Vite-style). With more than
247
+ 20 files, only the 10 largest are shown followed by a
248
+ `… and N more` summary. With `--quiet`, the client bundle step is now
249
+ silent too (new `quiet` option on `buildClientBundle`, which also sets
250
+ Vite's `logLevel: "silent"`).
251
+ - **ISR storage backend** — `createWebHandler` no longer uses the legacy
252
+ `getCachedHtml`/`setCachedHtml` module for page caching (it remains
253
+ exported for backward compatibility). Existing on-disk cache entries use
254
+ the same SHA-256-of-pathname key, but entries written by older versions
255
+ lack tag metadata, so they simply miss tag-based invalidation until
256
+ re-rendered. What gets cached (cacheability gates, TTL rules) is
257
+ unchanged.
258
+
259
+ ### Fixed
260
+
261
+ - **Dead legacy config lookup removed** — `findConfigFile` carried a legacy
262
+ config-name fallback that could never fire (its file list was identical to
263
+ the preferred one) and referenced pre-rename names that are no longer
264
+ supported. Config files are now exclusively `elur.config.ts/js/mjs`.
265
+ - **Missing type declarations for `@elurjs/kit/cache`** — the subpath
266
+ shipped its runtime bundle but no `index.d.ts`/`index.d.cts` because
267
+ `src/cache/index.ts` was absent from the declaration build inputs
268
+ (publint error). The `./cache` subpath is now fully typed (including
269
+ `redis-adapter`).
270
+ - **Infinite restart loop when the dev port is busy** — with port 3000
271
+ occupied, the dev supervisor restarted the crashed worker forever on the
272
+ same `EADDRINUSE`. The dev and preview servers now retry on the next
273
+ port (up to 20 candidates) with a clear warning
274
+ (`! Puerto 3000 ocupado, usando 3001`), and the startup message shows
275
+ the port actually bound. If the whole range is busy, the worker exits
276
+ with a dedicated code (78) that the supervisor treats as fatal instead
277
+ of restarting. Fallback also applies to an explicit `--port` (with the
278
+ same warning).
279
+ - **Actions defined with `defineAction()` crashed at the action
280
+ endpoint** — `handleActionRequest` invoked actions as `action(...args)`
281
+ without building an `ActionContext`, so any action using the default
282
+ `concurrency: "latest"` failed with `ctx undefined` (`ctx.signal`
283
+ access). Defined actions (detected via their `__elurAction` metadata)
284
+ now receive `(input, ctx)` with `ctx.request`, `ctx.signal` (from the
285
+ request), `ctx.idempotencyKey` (from the `Idempotency-Key` header),
286
+ and empty `params`/`locals`. Legacy plain actions keep the `(...args)`
287
+ call convention unchanged.
288
+ - **Head metadata lost on SPA navigations in production** — the
289
+ production render endpoint (`/__elur-js/render`) only returned
290
+ `{ title, body }`, so pages navigated via the client router kept stale
291
+ `<head>` tags and the consumed action-error cookie was never cleared.
292
+ The endpoint now ships the full payload and the cookie is relayed as
293
+ `Set-Cookie` (HTML mode) / `X-Elur-Action-Clear-Cookie` (JSON mode).
294
+ - **`directive: "load"` did not hydrate on load** — the generated client
295
+ entry wrapped the whole hydration pass in `requestIdleCallback`, so
296
+ `load` islands were really `idle` islands. The entry now calls
297
+ `hydrateIslands()` immediately (module scripts are already deferred);
298
+ `idle`/`visible` keep their deferred scheduling inside the hydrator.
299
+ - **Islands were disposed after the DOM swap** — the generated entry
300
+ listened only for `elur:rendered` (post-swap), so disposers ran on
301
+ detached DOM and could not read live state. Cleanup now runs on
302
+ `elur:before-render`, dispatched before `#app` is replaced, with a
303
+ compat fallback on `elur:rendered` for hosts that only emit the old
304
+ event (e.g. the streaming swap chunk).
305
+
306
+ ## [2.4.10]
307
+
308
+ ### Fixed
309
+
310
+ - **Republish of 2.4.9** — npm metadata showed 2.4.9 but the tarball
311
+ returned 404 from the CDN. Same content as 2.4.9 (static hydrator
312
+ import restored, requestIdleCallback deferral kept).
313
+
8
314
  ## [2.4.9]
9
315
 
10
316
  ### Fixed
package/README.md CHANGED
@@ -21,7 +21,7 @@ Elur Kit is a framework built on top of [Elur](https://elur.dev/). It brings con
21
21
  ### Key features
22
22
 
23
23
  - **Routing**: file-based with dynamic segments, optional catch-all `[[...slug]]`, route conflict detection, safe URL decoding, redirects/rewrites/route headers
24
- - **Rendering**: SSG, SSR, ISR with explicit cache policy (public/private/dynamic), streaming with `ReadableStream` (**experimental** — fallback buffered por adapter; ver nota de streaming)
24
+ - **Rendering**: SSG, SSR, ISR with explicit cache policy (public/private/dynamic), opt-in streaming SSR with `loading.ts` boundaries (**experimental** — ver [Streaming SSR](#streaming-ssr-experimental))
25
25
  - **Build-time compiler** (optional, recommended): integrates [`@elurjs/core-compiler`](https://www.npmjs.com/package/@elurjs/core-compiler) via [`@elurjs/vite-plugin-elur`](https://www.npmjs.com/package/@elurjs/vite-plugin-elur) to lower `html\`\`` templates to imperative DOM code at build time — eliminates `detectContext`, `buildHTML`, and both `TreeWalker` passes in runtime
26
26
  - **Partial attribute interpolation**: `class="btn ${size}"` works out of the box via the Vite plugin's state-machine lexer (or the kit's legacy transform as fallback)
27
27
  - **Actions**: typed `defineAction()` with input validation, AbortSignal, idempotency, concurrency modes (latest/queue/parallel)
@@ -160,10 +160,10 @@ Options:
160
160
 
161
161
  - **File-based routing** — `src/app/page.ts` maps to URLs with dynamic segments (`[slug]`), catch-all (`[...slug]`), optional catch-all (`[[...slug]]`), route groups `(group)`, and route conflict detection.
162
162
  - **SSG, SSR, ISR** — static generation, on-demand SSR, and incremental static regeneration with explicit cache policy (`public`/`private`/`dynamic`), SHA-256 cache keys, atomic writes, single-flight, and tag-based invalidation.
163
- - **Streaming (experimental)** — `ReadableStream`-based streaming with `loading.ts` boundaries, `createStreamingResponse()`, and `createBufferedResponse()` fallback for adapters without streaming. **Etiquetado como experimental** hasta completar la matriz de paridad streaming/buffered cross-host y la implementación de `renderToChunks()` en el core.
163
+ - **Streaming (experimental)** — real streaming SSR in the unified handler: opt-in via `defineConfig({ streaming: true })`, routes with `loading.ts` stream the shell + fallback immediately and swap in the resolved content with a `<template>` chunk. See [Streaming SSR (experimental)](#streaming-ssr-experimental).
164
164
  - **Server actions** — typed `defineAction()` with input validation (`.parse()`), AbortSignal propagation, idempotency metadata, concurrency modes (`latest`/`queue`/`parallel`), and progressive enhancement (plain HTML forms).
165
165
  - **RequestContext** — per-request context with `params`, `locals`, `cookies` (CookieJar), `signal` (AbortSignal), `requestId`, `platform`, `route`, and mutable `response` state (headers, Set-Cookie, status). Aligned with runtime-security §4.
166
- - **Unified Web handler** — `createWebHandler()` is the single entry point for all runtimes (Node, Bun, Vercel, Netlify, Vite dev **y el CLI `dev`/`preview`**). Every runtime is a thin wrapper; no duplicated routing/actions/static pipelines.
166
+ - **Unified Web handler** — `createWebHandler()` is the single entry point for all runtimes (Node, Bun, Vercel, Netlify, Vite dev **y el CLI `dev`/`preview`/`start`**). Every runtime is a thin wrapper; no duplicated routing/actions/static pipelines.
167
167
  - **Cache security** — `shouldCachePublic()` rejects requests with cookies/Authorization. `isResultCacheable()` rejects HTML with action error markers. No personalized ISR cache leakage.
168
168
  - **Public error sanitization** — production 500s use `toPublicErrorInfo()`/`publicErrorResponse()` (JSON, `no-store`), never exposing stacks, paths or secrets; request id is kept in internal logs.
169
169
  - **CSRF protection** — `verifyOrigin()` checks `Origin`, `Referer`, `Host`, and `Sec-Fetch-Site` with allow-list and `strictOrigin` mode.
@@ -173,8 +173,8 @@ Options:
173
173
  - **SEO** — sitemap generation from route manifest, sitemap index for >50,000 URLs, robots.txt, JSON-LD with safe escaping (`<`, `>`, `&`, U+2028, U+2029).
174
174
  - **Image optimization** — manifest-driven `<picture>` with content-addressed hashed variants, `<source>` per format, real dimensions, no upscales, Sharp optional.
175
175
  - **Islands** — lazy `import()` per island, null/error isolation, `load`/`idle`/`visible` directives, auto-scan of `src/islands/`.
176
- - **Client router** — AbortController + navigation token (no races), head/assets merge, aria-live announcer, canonical URL, View Transitions with reduced-motion fallback.
177
- - **Middleware** — `src/middleware.ts` with path matchers, `next()` carries params/locals, cleanup in `finally`, runs in dev/preview/adapters.
176
+ - **Client router** — navigation lifecycle events (`elur:navigate-*`, `elur:before-render`), per-page JS gating (0 KB on island-free pages), `data-elur-persist` element persistence, bounded LRU prefetch cache (network-aware, `pointerdown`), `history.scrollRestoration = "manual"`, head/payload/script refresh on navigation, optional idiomorph morphing (`router.morph`), optional loading indicator, Speculation Rules (`router.speculation`), head/assets merge, aria-live announcer, canonical URL, View Transitions with reduced-motion fallback.
177
+ - **Middleware** — `src/middleware.ts` with path matchers, `next()` carries headers/locals into the request pipeline, cleanup in `finally`, runs in `dev`/`preview`/`start` (the unified Web handler).
178
178
  - **Integrations** — typed hooks for `elur-i18n`, `elur-auth`, `elur-query`, `elur-testing` without adding them as dependencies.
179
179
  - **CLI** — `dev`, `build`, `preview`, `start`, `check`, `routes`, `doctor`, `adapter` with reliable exit codes.
180
180
  - **Observability** — structured logger with request ID, Server-Timing, sensitive data redaction (cookies, auth, tokens).
@@ -183,6 +183,89 @@ Options:
183
183
  - **`throw new Response()`** — first-class HTTP control flow from loaders and layout loaders (redirects, 404, etc.).
184
184
  - **HMAC-signed action errors** — action error cookies signed with SHA-256, rejects tampered/forged values.
185
185
 
186
+ ## Streaming SSR (experimental)
187
+
188
+ Real streaming for dynamic routes, wired into the unified Web handler
189
+ (`createWebHandler`) — so it works identically in `dev`, `preview`, `start`
190
+ and the generated Node/Bun servers.
191
+
192
+ ### Enabling it
193
+
194
+ Streaming is **opt-in** (default `false` — every route renders buffered
195
+ exactly as before unless you enable it):
196
+
197
+ ```ts
198
+ // elur.config.ts
199
+ import { defineConfig } from "@elurjs/kit";
200
+
201
+ export default defineConfig({
202
+ streaming: true,
203
+ });
204
+ ```
205
+
206
+ Requirements:
207
+
208
+ - The route must define a **loading boundary** (`src/app/<route>/loading.ts`).
209
+ Routes without one render buffered, unchanged.
210
+ - The host adapter must declare `capabilities.streaming: true`. Node, Bun and
211
+ the CLI dev/preview servers do; hosts that cannot stream fall back to
212
+ buffered rendering automatically instead of breaking. The CLI `adapter`
213
+ command validates the combination at build time.
214
+
215
+ ### What you get
216
+
217
+ 1. The browser receives the **document shell + loading fallback immediately**
218
+ (low TTFB), with status 200 and `Content-Type: text/html; charset=utf-8`.
219
+ 2. The page render (loaders included) runs **in the background**.
220
+ 3. When it finishes, the server appends a `<template>` chunk plus a small
221
+ script that **swaps the loading boundary in-place** (`replaceWith`), then
222
+ dispatches `elur:rendered`. No full re-render, no client fetch.
223
+
224
+ Emitted headers on streamed responses:
225
+
226
+ | Header | Why |
227
+ | --- | --- |
228
+ | `Content-Type: text/html; charset=utf-8` | sent early so the browser parses progressively |
229
+ | `Transfer-Encoding: chunked` | no `Content-Length`; each chunk flushes as produced |
230
+ | `X-Accel-Buffering: no` | tells nginx and similar reverse proxies not to buffer the stream |
231
+ | `Cache-Control: no-store` | a half-sent dynamic stream must never be cached by intermediaries |
232
+
233
+ The usual observability headers (`X-Request-ID`, `Server-Timing`,
234
+ security headers, route headers) apply as always. Note that `Server-Timing`'s
235
+ `ssr` metric measures **time-to-shell**, not the full background render.
236
+
237
+ ### Redirects, errors and disconnects mid-stream
238
+
239
+ - A loader throwing a redirect `Response` emits a
240
+ `<script>window.location.href=…</script>` chunk — the browser navigates
241
+ even though the shell was already sent.
242
+ - A loader failure after the shell went out swaps the boundary for a sober,
243
+ inline-styled error notice (`role="alert"`) and logs the detail via
244
+ `console.error`.
245
+ - If the client disconnects, the request's `AbortSignal` cancels the stream;
246
+ a render completing late is discarded and never writes to a dead stream.
247
+ The Node server also cancels the upstream stream when the socket closes.
248
+
249
+ ### Interaction with the ISR cache
250
+
251
+ **Streamed pages bypass the ISR cache entirely** — they are not read from it
252
+ nor written to it, and render live on every request. Nobody caches a stream
253
+ mid-flight; if a route is cacheable, buffered + ISR is the better delivery
254
+ mode. Keep `streaming: false` (or remove the `loading.ts` boundary) for
255
+ routes you want cached.
256
+
257
+ ### Limitations
258
+
259
+ - **Experimental**: the streaming/buffered parity matrix across hosts is not
260
+ complete yet, and the API may change.
261
+ - The swap script is inline: a strict `Content-Security-Policy` without
262
+ `unsafe-inline` (or a nonce) blocks it. Configure CSP accordingly.
263
+ - Verified on Node (dev/preview/`start`, generated Node server) and Bun
264
+ (`Bun.serve` streams natively). The Vercel/Netlify adapters are out of
265
+ scope for now.
266
+ - Behind a reverse proxy, make sure buffering and gzip buffering are disabled
267
+ for streamed routes (the `X-Accel-Buffering: no` header covers nginx).
268
+
186
269
  ## What's new in v2.4
187
270
 
188
271
  - **Fixed: image pipeline silently no-op** — the CLI bundle was
@@ -234,6 +317,14 @@ Options:
234
317
 
235
318
  #### Using the `build` hook for sitemaps
236
319
 
320
+ When `site` is set in your config, the build **already generates
321
+ `sitemap.xml` automatically** from the scanned routes (dynamic routes,
322
+ error pages and internal namespaces excluded; sites over 50,000 URLs are
323
+ split into a sitemap index). You only need the manual hook below for full
324
+ control over the entries (per-URL `priority`/`changefreq`, dynamic-route
325
+ URLs, extra files). An existing `sitemap.xml` — from `public/` or written
326
+ by an integration — always takes precedence over the automatic one.
327
+
237
328
  ```ts
238
329
  // elur.config.ts
239
330
  import { defineConfig } from "@elurjs/kit";
@@ -1291,16 +1382,111 @@ export const config = {
1291
1382
  };
1292
1383
  ```
1293
1384
 
1294
- ### Prefetch and View Transitions
1385
+ The middleware runs in `dev`, `preview` and `start` through the unified Web
1386
+ handler: after redirects/rewrites and the internal endpoints, before routing.
1387
+ Returning a `Response` short-circuits the pipeline (security headers and
1388
+ `X-Request-ID` still apply); `next({ headers })` merges headers into the
1389
+ downstream request and `next({ locals })` is exposed to API routes as
1390
+ `ctx.locals`. The generated Node/Bun adapter servers do not run the
1391
+ middleware file yet.
1295
1392
 
1296
- The SPA router automatically prefetches pages when links enter the viewport
1297
- (IntersectionObserver) and on hover/focus. Prefetched pages are cached for
1298
- 30 seconds. Add `data-no-prefetch` to any link to opt out.
1393
+ ### Redirects, rewrites and route headers
1394
+
1395
+ Declare redirects, rewrites and extra response headers in `elur.config.ts`.
1396
+ All patterns support `:param` segments, `*` wildcards and `:name*` catch-alls,
1397
+ with `:param` interpolation in destinations:
1398
+
1399
+ ```ts
1400
+ import { defineConfig } from "@elurjs/kit";
1401
+
1402
+ export default defineConfig({
1403
+ redirects: [
1404
+ { from: "/old-blog/:slug", to: "/blog/:slug", status: 301 },
1405
+ ],
1406
+ rewrites: [
1407
+ { from: "/api/legacy/*", to: "/api/v2/*" },
1408
+ ],
1409
+ headers: [
1410
+ { path: "/admin/*", headers: { "X-Robots-Tag": "noindex" } },
1411
+ ],
1412
+ });
1413
+ ```
1414
+
1415
+ Redirects are evaluated before any routing (default status `308`). Rewrites
1416
+ transparently change the pathname used for routing — API routes, static files
1417
+ and SSR all see the rewritten path. Route headers are matched against the
1418
+ original request path and applied to the response.
1419
+
1420
+ ### Router, prefetch and navigation lifecycle
1421
+
1422
+ The SPA router prefetches pages on `pointerenter`, `focus` and `pointerdown`
1423
+ (the "tap" strategy — earlier than the click on touch devices), and on links
1424
+ that opt into viewport prefetching with `data-prefetch="viewport"`
1425
+ (IntersectionObserver, 200px root margin). Prefetched pages live in a
1426
+ bounded LRU cache (32 entries, 30 s TTL). Prefetching is skipped on
1427
+ constrained networks — `Save-Data` or `slow-2g`/`2g` effective types —
1428
+ unless a link forces it with `data-prefetch="always"`. Add
1429
+ `data-no-prefetch` to any link to opt out entirely.
1430
+
1431
+ Navigation lifecycle events (on `document`) carry
1432
+ `{ pathname, search, fromCache, popstate }`:
1433
+
1434
+ - `elur:navigate-start` — a navigation begins.
1435
+ - `elur:navigate-end` — the DOM swap completed.
1436
+ - `elur:navigate-error` — the navigation failed (e.g. render endpoint
1437
+ unavailable); the router falls back to a full page load.
1438
+ - `elur:before-render` — fires *before* the `#app` swap; the generated
1439
+ entry disposes islands here (except islands inside persisted nodes,
1440
+ listed in `detail.persisted`).
1441
+ - `elur:rendered` — fires after the swap; islands re-hydrate here.
1442
+
1443
+ Elements marked `data-elur-persist="key"` survive SPA navigations: the
1444
+ router moves the *same live DOM node* into its position in the new page
1445
+ (matched by the attribute value), using `Element.moveBefore()` when
1446
+ available — media playback, island state and scroll positions are
1447
+ preserved. Islands inside persisted subtrees are not disposed/rehydrated;
1448
+ if their incoming props differ, an `elur:persist-props-changed` event
1449
+ bubbles from the island marker.
1450
+
1451
+ Scroll is managed by the router (`history.scrollRestoration = "manual"`):
1452
+ the position is saved per history entry and restored on back/forward.
1299
1453
 
1300
1454
  When the browser supports the View Transitions API, page transitions use
1301
1455
  `document.startViewTransition()` for smooth cross-fade animations. This is
1302
1456
  automatically disabled when the user has `prefers-reduced-motion: reduce`.
1303
1457
 
1458
+ Router options (`defineConfig`):
1459
+
1460
+ ```ts
1461
+ export default defineConfig({
1462
+ router: {
1463
+ enabled: true, // SPA navigation; false → MPA + 0 KB JS on island-free pages
1464
+ prefetch: true, // link prefetching (network-aware)
1465
+ morph: false, // experimental: idiomorph DOM morphing for the swap
1466
+ loadingIndicator: false,// top progress bar on navigations slower than ~200 ms
1467
+ speculation: undefined, // "prefetch" | "prerender" → Speculation Rules on static pages
1468
+ },
1469
+ js: "modern", // "legacy" restores the unconditional combined entry
1470
+ });
1471
+ ```
1472
+
1473
+ ### Per-page JavaScript emission
1474
+
1475
+ With `js: "modern"` (default) the document shell inspects the rendered body
1476
+ and only ships what the page needs:
1477
+
1478
+ | Page contents | `router.enabled: true` | `router.enabled: false` |
1479
+ |---|---|---|
1480
+ | With islands | `entry-client.js` + `router.js` | `entry-client.js` only |
1481
+ | Without islands | `router.js` only | **0 KB of JS** |
1482
+
1483
+ The split requires the router to be its own bundle chunk — automatic when
1484
+ the project has no `vite.client.config.*` (the CLI synthesizes a default
1485
+ two-input config) or when that config declares `.elur/router.ts` as a
1486
+ second input. A single-input config keeps the router embedded in
1487
+ `entry-client.js` (emitted whenever islands exist *or* the router is on).
1488
+ Set `js: "legacy"` to restore the pre-split behavior unconditionally.
1489
+
1304
1490
  ## Project conventions
1305
1491
 
1306
1492
  ```text
@@ -1 +1 @@
1
- Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});const e=require("../action-DZHihU3Z.cjs");exports.callAction=e.t,exports.defineAction=e.r,exports.elurJsAction=e.n;
1
+ Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});const e=require("../action-BiE4tEty.cjs");exports.callAction=e.t,exports.defineAction=e.r,exports.elurJsAction=e.n;
@@ -1,2 +1,2 @@
1
- import { n as e, r as t, t as n } from "../action-BB6N5yD-.js";
1
+ import { n as e, r as t, t as n } from "../action-CXVk8rWt.js";
2
2
  export { n as callAction, t as defineAction, e as elurJsAction };
@@ -1,2 +1,2 @@
1
- const e=require("./errors-0X7pCFNU.cjs");let t=require("@elurjs/core");var n=globalThis.AbortController;function r(e,t){let r=e.concurrency??`latest`,a=e.idempotent??!1,o=e.invalidateTags??[],s=e.invalidatePaths??[],c=null,l=[],u=0;async function d(n,r){if(e.input)try{n=e.input.parse(n)}catch(e){let t=e instanceof Error?e.message:String(e),{fail:n}=await Promise.resolve().then(()=>require("./errors-0X7pCFNU.cjs")).then(e=>e.r);return n(400,{validation:t})}return t(n,r)}let f=async(e,t)=>{if(r===`latest`){c&&c.abort(),c=new n;let r=i(t.signal,c.signal);return d(e,{...t,signal:r})}if(r===`queue`){u>0&&await new Promise(e=>l.push(e)),u++;try{return await d(e,t)}finally{u--;let e=l.shift();e&&e()}}return d(e,t)};return f.__elurAction={name:t.name||`anonymous`,concurrency:r,idempotent:a,invalidateTags:o,invalidatePaths:s},f}function i(...e){let t=new n;for(let n of e)if(n){if(n.aborted){t.abort();break}n.addEventListener(`abort`,()=>t.abort(),{once:!0})}return t.signal}function a(e){return typeof e==`object`&&!!e&&e.__elur_js_action_failure===!0}function o(e){return typeof e==`object`&&!!e&&e.__elur_js_action_redirect===!0}async function s(t,n=[],r={}){let i=Array.isArray(n)?n:[n],s=await fetch(`/__elur-js/actions`,{method:`POST`,headers:{"Content-Type":`application/json`,Accept:`application/json`},body:JSON.stringify({name:t,page:r.page,args:i})}),c=await s.text();if(!s.ok){let n;try{n=JSON.parse(c)}catch{}if(a(n)&&n.status!==void 0)return new e.t(n.status,n.data);throw Error(`Action "${t}" failed: ${c}`)}let l=JSON.parse(c);return o(l)&&l.status!==void 0&&l.location!==void 0?new e.n(l.status,l.location):l}function c(e,n={}){let r=(0,t.signal)(!1),i=(0,t.signal)(null),a=(0,t.signal)(null);async function o(t){r.value=!0,i.value=null;try{let r=await s(e,t,n);return a.value=r,r}catch(e){throw i.value=e instanceof Error?e:Error(String(e)),e}finally{r.value=!1}}return{submit:o,pending:r,data:a,error:i}}Object.defineProperty(exports,"n",{enumerable:!0,get:function(){return c}}),Object.defineProperty(exports,"r",{enumerable:!0,get:function(){return r}}),Object.defineProperty(exports,"t",{enumerable:!0,get:function(){return s}});
2
- //# sourceMappingURL=action-DZHihU3Z.cjs.map
1
+ const e=require("./errors-kSl9yLkg.cjs");let t=require("@elurjs/core");var n=globalThis.AbortController;function r(e,t){let r=e.concurrency??`latest`,a=e.idempotent??!1,o=e.invalidateTags??[],s=e.invalidatePaths??[],c=null,l=[],u=0;async function d(n,r){if(e.input)try{n=e.input.parse(n)}catch(e){let t=e instanceof Error?e.message:String(e),{fail:n}=await Promise.resolve().then(()=>require("./errors-kSl9yLkg.cjs")).then(e=>e.r);return n(400,{validation:t})}return t(n,r)}let f=async(e,t)=>{if(r===`latest`){c&&c.abort(),c=new n;let r=i(t.signal,c.signal);return d(e,{...t,signal:r})}if(r===`queue`){u>0&&await new Promise(e=>l.push(e)),u++;try{return await d(e,t)}finally{u--;let e=l.shift();e&&e()}}return d(e,t)};return f.__elurAction={name:t.name||`anonymous`,concurrency:r,idempotent:a,invalidateTags:o,invalidatePaths:s},f}function i(...e){let t=new n;for(let n of e)if(n){if(n.aborted){t.abort();break}n.addEventListener(`abort`,()=>t.abort(),{once:!0})}return t.signal}function a(e){return typeof e==`object`&&!!e&&e.__elur_js_action_failure===!0}function o(e){return typeof e==`object`&&!!e&&e.__elur_js_action_redirect===!0}async function s(t,n=[],r={}){let i=Array.isArray(n)?n:[n],s=await fetch(`/__elur-js/actions`,{method:`POST`,headers:{"Content-Type":`application/json`,Accept:`application/json`},body:JSON.stringify({name:t,page:r.page,args:i})}),c=await s.text();if(!s.ok){let n;try{n=JSON.parse(c)}catch{}if(a(n)&&n.status!==void 0)return new e.t(n.status,n.data);throw Error(`Action "${t}" failed: ${c}`)}let l=JSON.parse(c);return o(l)&&l.status!==void 0&&l.location!==void 0?new e.n(l.status,l.location):l}function c(e,n={}){let r=(0,t.signal)(!1),i=(0,t.signal)(null),a=(0,t.signal)(null);async function o(t){r.value=!0,i.value=null;try{let r=await s(e,t,n);return a.value=r,r}catch(e){throw i.value=e instanceof Error?e:Error(String(e)),e}finally{r.value=!1}}return{submit:o,pending:r,data:a,error:i}}Object.defineProperty(exports,"n",{enumerable:!0,get:function(){return c}}),Object.defineProperty(exports,"r",{enumerable:!0,get:function(){return r}}),Object.defineProperty(exports,"t",{enumerable:!0,get:function(){return s}});
2
+ //# sourceMappingURL=action-BiE4tEty.cjs.map