@ogygia/core 0.12.2 → 0.13.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 (227) hide show
  1. package/dist/LateIsland.svelte +3 -2
  2. package/dist/LateIslandAwait.svelte +6 -2
  3. package/dist/Region.svelte +22 -33
  4. package/dist/boundary.d.ts +0 -2
  5. package/dist/boundary.js +8 -6
  6. package/dist/compiler/debarrel/barrel.d.ts +15 -8
  7. package/dist/compiler/debarrel/barrel.js +92 -45
  8. package/dist/compiler/debarrel/plugin.js +41 -17
  9. package/dist/compiler/dev/dev-hmr.d.ts +3 -0
  10. package/dist/compiler/dev/dev-hmr.js +3 -0
  11. package/dist/compiler/driver.js +11 -4
  12. package/dist/compiler/flags.js +21 -7
  13. package/dist/compiler/fouc-css.js +5 -1
  14. package/dist/compiler/kit.js +5 -1
  15. package/dist/compiler/link/build-output.js +12 -3
  16. package/dist/compiler/macros/bake.js +18 -4
  17. package/dist/compiler/macros/dollar.js +6 -1
  18. package/dist/compiler/macros/source.js +6 -1
  19. package/dist/compiler/macros/store.js +10 -2
  20. package/dist/compiler/macros/wire.js +6 -1
  21. package/dist/compiler/parse/scan.js +10 -3
  22. package/dist/compiler/region/transform.js +188 -74
  23. package/dist/context-bridge.js +13 -5
  24. package/dist/context.d.ts +9 -0
  25. package/dist/context.js +30 -1
  26. package/dist/document.d.ts +1 -1
  27. package/dist/document.js +7 -1
  28. package/dist/early-holes-head.d.ts +5 -0
  29. package/dist/early-holes-head.js +20 -0
  30. package/dist/early-holes.d.ts +34 -0
  31. package/dist/early-holes.js +51 -0
  32. package/dist/entry-hydrate.js +9 -12
  33. package/dist/events/bus.js +7 -2
  34. package/dist/events/schema.d.ts +2 -2
  35. package/dist/fn-transport.js +4 -3
  36. package/dist/hooks.js +94 -163
  37. package/dist/insights/page-assets.js +11 -3
  38. package/dist/insights/page-insights.d.ts +0 -2
  39. package/dist/insights/page-insights.js +14 -10
  40. package/dist/insights/session-insights.js +2 -2
  41. package/dist/internal-instruments.d.ts +3 -1
  42. package/dist/internal-instruments.js +3 -1
  43. package/dist/internal-server.d.ts +3 -2
  44. package/dist/internal-server.js +3 -2
  45. package/dist/internal.d.ts +1 -2
  46. package/dist/internal.js +1 -2
  47. package/dist/keep-fallback.d.ts +0 -2
  48. package/dist/keep-fallback.js +4 -5
  49. package/dist/live-transport.js +3 -2
  50. package/dist/messages/build.d.ts +585 -0
  51. package/dist/messages/build.js +800 -0
  52. package/dist/messages/core.d.ts +464 -0
  53. package/dist/messages/core.js +843 -0
  54. package/dist/messages/define.d.ts +62 -0
  55. package/dist/messages/define.js +49 -0
  56. package/dist/messages/index.d.ts +6 -0
  57. package/dist/messages/index.js +6 -0
  58. package/dist/messages/island.d.ts +4 -0
  59. package/dist/messages/island.js +34 -0
  60. package/dist/messages/lazy.d.ts +3 -0
  61. package/dist/messages/lazy.js +22 -0
  62. package/dist/messages/prose.d.ts +15 -0
  63. package/dist/messages/prose.js +43 -0
  64. package/dist/messages/server.d.ts +142 -0
  65. package/dist/messages/server.js +246 -0
  66. package/dist/nested.d.ts +3 -0
  67. package/dist/nested.js +8 -1
  68. package/dist/plugin/css.js +14 -13
  69. package/dist/plugin/facts.d.ts +2 -0
  70. package/dist/plugin/facts.js +10 -6
  71. package/dist/plugin/hooks.d.ts +7 -17
  72. package/dist/plugin/hooks.js +32 -4
  73. package/dist/plugin/index.d.ts +9 -2
  74. package/dist/plugin/request-scope.d.ts +20 -0
  75. package/dist/plugin/request-scope.js +149 -0
  76. package/dist/plugin/server.d.ts +20 -2
  77. package/dist/plugin/server.js +98 -28
  78. package/dist/plugin/testing.d.ts +6 -0
  79. package/dist/plugin/testing.js +14 -2
  80. package/dist/policies.d.ts +21 -0
  81. package/dist/policies.js +66 -0
  82. package/dist/preference.js +6 -1
  83. package/dist/preload.js +6 -9
  84. package/dist/ref-watch.d.ts +0 -10
  85. package/dist/ref-watch.js +14 -1
  86. package/dist/ref.js +17 -5
  87. package/dist/region-snippet.js +9 -3
  88. package/dist/region.js +2 -1
  89. package/dist/report-thrown.d.ts +9 -0
  90. package/dist/report-thrown.js +15 -0
  91. package/dist/rules.d.ts +17 -0
  92. package/dist/rules.js +57 -0
  93. package/dist/runtime/beacon.d.ts +0 -4
  94. package/dist/runtime/beacon.js +58 -43
  95. package/dist/runtime/boot-link.js +19 -2
  96. package/dist/runtime/client-hooks.js +2 -1
  97. package/dist/runtime/concurrency.d.ts +16 -5
  98. package/dist/runtime/concurrency.js +123 -7
  99. package/dist/runtime/core.d.ts +3 -1
  100. package/dist/runtime/core.js +718 -384
  101. package/dist/runtime/devalue-lane.d.ts +13 -0
  102. package/dist/runtime/devalue-lane.js +49 -0
  103. package/dist/runtime/disclose-version.d.ts +1 -0
  104. package/dist/runtime/disclose-version.js +14 -0
  105. package/dist/runtime/early-holes.d.ts +4 -0
  106. package/dist/runtime/early-holes.js +40 -0
  107. package/dist/runtime/entry-locations.js +40 -49
  108. package/dist/runtime/foreign-props.d.ts +0 -7
  109. package/dist/runtime/foreign-props.js +2 -1
  110. package/dist/runtime/form-continuity.d.ts +2 -2
  111. package/dist/runtime/form-continuity.js +30 -8
  112. package/dist/runtime/frame-nav.d.ts +7 -1
  113. package/dist/runtime/frame-nav.js +84 -18
  114. package/dist/runtime/frame-store.d.ts +25 -16
  115. package/dist/runtime/frame-store.js +124 -98
  116. package/dist/runtime/frames.js +8 -4
  117. package/dist/runtime/hole-facts.js +6 -7
  118. package/dist/runtime/hole-fetch.d.ts +17 -0
  119. package/dist/runtime/hole-fetch.js +47 -0
  120. package/dist/runtime/hydrate-core.d.ts +4 -4
  121. package/dist/runtime/hydrate-core.js +80 -64
  122. package/dist/runtime/import-again.d.ts +33 -0
  123. package/dist/runtime/import-again.js +217 -0
  124. package/dist/runtime/interaction-replay.d.ts +5 -3
  125. package/dist/runtime/interaction-replay.js +6 -6
  126. package/dist/runtime/interaction.d.ts +14 -3
  127. package/dist/runtime/interaction.js +46 -23
  128. package/dist/runtime/kit-page-thread.svelte.d.ts +2 -1
  129. package/dist/runtime/kit-page-thread.svelte.js +44 -20
  130. package/dist/runtime/lakes.d.ts +1 -1
  131. package/dist/runtime/lakes.js +18 -5
  132. package/dist/runtime/live.d.ts +1 -2
  133. package/dist/runtime/live.js +6 -8
  134. package/dist/runtime/load-scheduler.d.ts +29 -18
  135. package/dist/runtime/load-scheduler.js +236 -136
  136. package/dist/runtime/nav-handle.js +25 -6
  137. package/dist/runtime/navigation.d.ts +24 -0
  138. package/dist/runtime/navigation.js +24 -0
  139. package/dist/runtime/ownership.d.ts +1 -1
  140. package/dist/runtime/page-defer.js +3 -3
  141. package/dist/runtime/parse-html.d.ts +15 -4
  142. package/dist/runtime/parse-html.js +24 -5
  143. package/dist/runtime/pushable-props.svelte.d.ts +21 -0
  144. package/dist/runtime/pushable-props.svelte.js +37 -0
  145. package/dist/runtime/region-endpoint-url.js +4 -6
  146. package/dist/runtime/region-machines.d.ts +111 -0
  147. package/dist/runtime/region-machines.js +97 -0
  148. package/dist/runtime/region-sheets.d.ts +31 -0
  149. package/dist/runtime/region-sheets.js +49 -0
  150. package/dist/runtime/restore-report.d.ts +1 -0
  151. package/dist/runtime/restore-report.js +36 -0
  152. package/dist/runtime/restore.d.ts +5 -2
  153. package/dist/runtime/restore.js +33 -14
  154. package/dist/runtime/router-nav.d.ts +32 -12
  155. package/dist/runtime/router-nav.js +320 -228
  156. package/dist/runtime/router.d.ts +83 -19
  157. package/dist/runtime/router.js +297 -129
  158. package/dist/runtime/rules.d.ts +35 -0
  159. package/dist/runtime/rules.js +137 -0
  160. package/dist/runtime/schedule.d.ts +6 -3
  161. package/dist/runtime/schedule.js +172 -57
  162. package/dist/runtime/seeds.d.ts +5 -1
  163. package/dist/runtime/seeds.js +11 -5
  164. package/dist/runtime/session.d.ts +46 -7
  165. package/dist/runtime/session.js +78 -10
  166. package/dist/runtime/slots.d.ts +53 -20
  167. package/dist/runtime/slots.js +2 -3
  168. package/dist/runtime/soft-refresh.d.ts +16 -0
  169. package/dist/runtime/soft-refresh.js +14 -0
  170. package/dist/runtime/svelte-warnings.d.ts +4 -10
  171. package/dist/runtime/svelte-warnings.js +7 -4
  172. package/dist/runtime/wire-classes.js +21 -8
  173. package/dist/runtime/wire-format.js +6 -3
  174. package/dist/script.js +8 -7
  175. package/dist/seed-ref-reviver.d.ts +0 -9
  176. package/dist/seed-ref-reviver.js +2 -1
  177. package/dist/seed-refs.d.ts +46 -36
  178. package/dist/seed-refs.js +131 -92
  179. package/dist/server/document-tail.d.ts +6 -0
  180. package/dist/server/document-tail.js +7 -1
  181. package/dist/server/early-holes-csp.d.ts +14 -0
  182. package/dist/server/early-holes-csp.js +73 -0
  183. package/dist/server/page-facts.d.ts +4 -1
  184. package/dist/server/page-facts.js +4 -1
  185. package/dist/server/page-seed.d.ts +2 -2
  186. package/dist/server/page-seed.js +104 -11
  187. package/dist/server/page-stream.d.ts +14 -1
  188. package/dist/server/page-stream.js +119 -2
  189. package/dist/server/personal-response.d.ts +0 -5
  190. package/dist/server/personal-response.js +6 -2
  191. package/dist/server/props-wire.js +5 -1
  192. package/dist/server/region-endpoint.js +10 -2
  193. package/dist/server/render-cache.d.ts +24 -20
  194. package/dist/server/render-cache.js +99 -40
  195. package/dist/server/render-page.js +46 -15
  196. package/dist/server/request-stats.d.ts +10 -8
  197. package/dist/server/request-stats.js +20 -12
  198. package/dist/server/rules.d.ts +11 -0
  199. package/dist/server/rules.js +64 -0
  200. package/dist/server/signing-key.js +31 -18
  201. package/dist/server/stream-regions.d.ts +14 -1
  202. package/dist/server/stream-regions.js +38 -2
  203. package/dist/shared-state.js +5 -1
  204. package/dist/shims/app-navigation.js +3 -2
  205. package/dist/shims/page-store.svelte.js +16 -2
  206. package/dist/source.js +7 -2
  207. package/dist/store-transport.js +12 -5
  208. package/dist/transport.js +3 -2
  209. package/dist/vite/index.js +83 -74
  210. package/dist/vite/kit-dirs.js +7 -1
  211. package/dist/vite/options.d.ts +2 -1
  212. package/dist/vite/options.js +11 -20
  213. package/dist/vite/package-files.js +13 -3
  214. package/dist/vite/paths.d.ts +2 -0
  215. package/dist/vite/paths.js +4 -4
  216. package/dist/vite/plugin-api.d.ts +6 -5
  217. package/dist/vite/plugin-api.js +42 -14
  218. package/dist/vite/style-preprocess.js +10 -3
  219. package/package.json +8 -1
  220. package/dist/KeepHost.svelte +0 -19
  221. package/dist/KeepHost.svelte.d.ts +0 -10
  222. package/dist/LiveHost.svelte +0 -19
  223. package/dist/LiveHost.svelte.d.ts +0 -10
  224. package/dist/NestedProvider.svelte +0 -13
  225. package/dist/NestedProvider.svelte.d.ts +0 -8
  226. package/dist/runtime/page-cache.d.ts +0 -23
  227. package/dist/runtime/page-cache.js +0 -74
@@ -1,4 +1,4 @@
1
- import { PAGE_DEFER_KEY, PAGE_SETTLED_KEY } from "../page-defer.js";
1
+ import { PAGE_DEFER_GLOBAL, PAGE_DEFER_KEY, PAGE_SETTLED_KEY } from "../page-defer.js";
2
2
  import { escape_script_text } from "../escape.js";
3
3
  import { stringify } from "devalue";
4
4
  // src/server/page-stream.ts
@@ -118,4 +118,121 @@ function normalize_error(err) {
118
118
  if (err && typeof err === "object" && "message" in err) return { message: String(err.message) };
119
119
  return { message: String(err) };
120
120
  }
121
- export { DeferRef, SettledRef, defer_reducer, is_thenable, normalize_error, page_seed_reducers, resolve_script, settle_deferred, stage_deferred };
121
+ /** `</body>` as bytes (ASCII: the same in every UTF-8 document). */
122
+ const BODY_CLOSE_BYTES = [
123
+ 60,
124
+ 47,
125
+ 98,
126
+ 111,
127
+ 100,
128
+ 121,
129
+ 62
130
+ ];
131
+ /** Does `</body>` end in `chunk` — searched backwards from its end, where it sits — or straddle the
132
+ * boundary with the previous chunk's last bytes (`carry`, at most 6)? No decode. */
133
+ function has_body_close(carry, chunk) {
134
+ const at_bytes = (buf, i) => {
135
+ for (let k = 0; k < 7; k++) if (buf[i + k] !== BODY_CLOSE_BYTES[k]) return false;
136
+ return true;
137
+ };
138
+ for (let i = chunk.lastIndexOf(60); i !== -1; i = i === 0 ? -1 : chunk.lastIndexOf(60, i - 1)) if (at_bytes(chunk, i)) return true;
139
+ if (!carry.length) return false;
140
+ const seam = new Uint8Array(carry.length + Math.min(6, chunk.length));
141
+ seam.set(carry);
142
+ seam.set(chunk.subarray(0, seam.length - carry.length), carry.length);
143
+ for (let i = 0; i + 7 <= seam.length; i++) if (at_bytes(seam, i)) return true;
144
+ return false;
145
+ }
146
+ /**
147
+ * Stream the captured `$page.data` promises into islands (csr=false, real browser load). The
148
+ * document — carrying the pending seed + resolve-global bootstrap — is already built by `resolve`;
149
+ * here we (1) forward it, (2) DRAIN Kit's own dead csr=false resolve tail so its
150
+ * `__sveltekit_<hash> is not defined` never reaches the browser, and (3) emit one
151
+ * `<script>__ogygia_page_resolve(id, ok, value)<\/script>` per promise AS IT SETTLES — completion
152
+ * order, non-blocking. Each island's `{#await page.data.x}` flips pending → resolved live.
153
+ *
154
+ * The stream is the request's (`request`, its owner): the visitor leaving, or the body cancelled,
155
+ * stops Kit's reader and drops what settles after.
156
+ */
157
+ function stream_page_deferred(request, response, deferred, initial_next_id, reducers) {
158
+ const source = response.body;
159
+ if (!source) return response;
160
+ const reader = source.getReader();
161
+ const encoder = new TextEncoder();
162
+ const owner = request.child();
163
+ owner.defer(() => void reader.cancel().catch(() => {}));
164
+ const stream = new ReadableStream({
165
+ async start(controller) {
166
+ let carry = /* @__PURE__ */ new Uint8Array(0);
167
+ try {
168
+ for (;;) {
169
+ const { value, done } = await reader.read();
170
+ if (done) break;
171
+ controller.enqueue(value);
172
+ if (has_body_close(carry, value)) break;
173
+ carry = value.length >= 6 ? value.subarray(value.length - 6) : value;
174
+ }
175
+ } catch {}
176
+ (async () => {
177
+ try {
178
+ for (;;) {
179
+ const { done } = await reader.read();
180
+ if (done) break;
181
+ }
182
+ } catch {}
183
+ })();
184
+ let pending = deferred.length;
185
+ let next_id = initial_next_id;
186
+ let closed = false;
187
+ const maybe_close = () => {
188
+ if (pending === 0 && !closed) {
189
+ closed = true;
190
+ try {
191
+ controller.close();
192
+ } catch {}
193
+ }
194
+ };
195
+ const push = (id, ok, value) => {
196
+ if (owner.closed) return;
197
+ try {
198
+ controller.enqueue(encoder.encode(resolve_script(PAGE_DEFER_GLOBAL, id, {
199
+ ok,
200
+ value
201
+ }, reducers)));
202
+ } catch {}
203
+ };
204
+ const settled = (id, ok, value) => {
205
+ push(id, ok, value);
206
+ pending -= 1;
207
+ maybe_close();
208
+ };
209
+ const stream_one = (id, promise) => {
210
+ Promise.resolve(promise).then((value) => {
211
+ let staged;
212
+ try {
213
+ staged = stage_deferred(value, next_id);
214
+ } catch (error) {
215
+ return settled(id, false, error);
216
+ }
217
+ next_id = staged.next_id;
218
+ pending += staged.deferred.length;
219
+ for (const n of staged.deferred) stream_one(n.id, n.promise);
220
+ settled(id, true, staged.staged);
221
+ }, (error) => settled(id, false, error));
222
+ };
223
+ for (const { id, promise } of deferred) stream_one(id, promise);
224
+ maybe_close();
225
+ },
226
+ cancel() {
227
+ owner.close();
228
+ }
229
+ });
230
+ const headers = new Headers(response.headers);
231
+ headers.delete("content-length");
232
+ return new Response(stream, {
233
+ status: response.status,
234
+ statusText: response.statusText,
235
+ headers
236
+ });
237
+ }
238
+ export { DeferRef, SettledRef, defer_reducer, is_thenable, normalize_error, page_seed_reducers, resolve_script, settle_deferred, stage_deferred, stream_page_deferred };
@@ -1,6 +1 @@
1
- /**
2
- * A page made for ONE browser — its devtools events (hooks.ts), the profiler's beacon (profiler/), the
3
- * switch's Set-Cookie — must never reach a shared cache, whatever `cache-control` the app set: a CDN that
4
- * keeps HTML would hand that browser's page to every visitor.
5
- */
6
1
  export declare function mark_personal(response: Response): void;
@@ -1,13 +1,17 @@
1
+ import { warn } from "../messages/server.js";
1
2
  // src/server/personal-response.ts
2
3
  /**
3
4
  * A page made for ONE browser — its devtools events (hooks.ts), the profiler's beacon (profiler/), the
4
5
  * switch's Set-Cookie — must never reach a shared cache, whatever `cache-control` the app set: a CDN that
5
- * keeps HTML would hand that browser's page to every visitor.
6
+ * keeps HTML would hand that browser's page to every visitor. A response whose headers cannot be
7
+ * changed (a fetched or redirect Response handed back as it is) is left as it is, and said.
6
8
  */
7
9
  function mark_personal(response) {
8
10
  try {
9
11
  response.headers.set("cache-control", "private, no-store");
10
12
  response.headers.append("vary", "Cookie");
11
- } catch {}
13
+ } catch (error) {
14
+ warn("personal_response_unmarked", { status: response.status }, error);
15
+ }
12
16
  }
13
17
  export { mark_personal };
@@ -5,6 +5,7 @@ import { register_wire_kind } from "../live-transport.js";
5
5
  import { register_derived_kind, register_store_kind } from "../store-transport.js";
6
6
  import { register_snippet_kind } from "../region-snippet.js";
7
7
  import { register_fn_kind } from "../fn-transport.js";
8
+ import { OgygiaError } from "../messages/server.js";
8
9
  import { analyze, json_culprit, plan_seed_refs } from "../seed-refs.js";
9
10
  import { stringify } from "devalue";
10
11
  // src/server/props-wire.ts
@@ -85,7 +86,10 @@ function stringify_props(value, entry, seed_refs = null, wire_out = null) {
85
86
  return stringify(value, reducers);
86
87
  } catch (e) {
87
88
  const detail = e instanceof Error ? e.message : String(e);
88
- throw new Error(`[ogygia] island "${entry}": a captured prop is not serializable — ${detail}. Captured host values cross the boundary via devalue; functions/Promises cannot, and a class instance only can when the class declares a static [ogygia.wire] codec. Pass a serializable value, add a codec, or move that logic inside the island component.`);
89
+ throw new OgygiaError("prop_not_serializable", {
90
+ entry,
91
+ detail
92
+ });
89
93
  }
90
94
  }
91
95
  /**
@@ -5,6 +5,7 @@ import { encode_region_props, ensure_prop_kinds } from "./region-props.js";
5
5
  import { record_server_event } from "../events/server-registry.js";
6
6
  import { B64Url } from "./payload.js";
7
7
  import { REF_WIRE_KEY, ref_reducer } from "../ref.js";
8
+ import { OgygiaError, warn } from "../messages/server.js";
8
9
  import { island_fingerprint } from "./fingerprint.js";
9
10
  import { stringify } from "devalue";
10
11
  import { getRequestEvent } from "virtual:ogygia/request-event";
@@ -127,14 +128,21 @@ function mint_region_capability(entry, payload, ttl = 0) {
127
128
  function mintServerIsland(entry, props, ttl = 0) {
128
129
  ensure_prop_kinds();
129
130
  const payload = B64Url.encode(stringify(props, { [REF_WIRE_KEY]: ref_reducer(PROP_FAMILIES) }));
130
- if (payload.length > 8192) throw new Error(`[ogygia] server island "${entry}": props payload is ${payload.length} b64 chars (max ${MAX_REGION_PROPS_LEN}). Shrink what you pass into the deferred region — the handle would reject this capability anyway.`);
131
+ if (payload.length > 8192) throw new OgygiaError("hole_props_too_large", {
132
+ entry,
133
+ length: payload.length,
134
+ max: MAX_REGION_PROPS_LEN
135
+ });
131
136
  return mint_region_capability(entry, payload, ttl);
132
137
  }
133
138
  /** HMAC-signed `<endpoint>?id&props&exp&sig` (`handle({ endpoint })`, default `/__ogygia__`) for re-rendering `entry` with `props`. */
134
139
  function makeRegionEndpoint(entry, props = {}) {
135
140
  const payload = encode_region_props(props);
136
141
  if (payload == null) {
137
- console.warn(`[ogygia] render:'live' region ${entry} has props that cannot cross the wire (non-serializable or >${MAX_REGION_PROPS_LEN} b64 chars) — no revalidate endpoint minted (behaves like a static lake).`);
142
+ warn("live_props_not_serializable", {
143
+ entry,
144
+ max: MAX_REGION_PROPS_LEN
145
+ });
138
146
  return "";
139
147
  }
140
148
  return mint_region_capability(entry, payload);
@@ -1,26 +1,30 @@
1
- /** Cache key for a region render — id + props payload + session seal. `exp`/`sig` (which rotate per
2
- * mint) and `ttl` (a cache-window declaration, not a render input) are DELIBERATELY excluded so
3
- * two renders of the same region+props+user hit; session is INCLUDED so users never cross. */
4
- export declare function render_cache_key(id: string, props_payload: string, session: string): string;
5
- /** Cached HTML for `key`, or null on miss/expiry. A live hit is an LRU touch. */
6
- export declare function render_cache_get(key: string, now: number): string | null;
7
- /** Cache `html` for `ttl_sec` seconds. No-op for `ttl_sec <= 0` (dynamic holes are never cached). */
8
- export declare function render_cache_set(key: string, html: string, ttl_sec: number, now: number): void;
9
- /** Test/ops hook: drop everything. */
10
- export declare function render_cache_clear(): void;
11
- /** Test/ops hook: live entries + resident bytes. */
12
- export declare function render_cache_stats(): {
13
- entries: number;
14
- bytes: number;
1
+ /** What a region render is made from: the region, its props, the visitor's session seal, and (when
2
+ * its tree reads `page.data`) the page it renders for. */
3
+ export type RenderKey = {
4
+ id: string;
5
+ payload: string;
6
+ session: string;
7
+ page: string | null;
15
8
  };
9
+ /** Cache key for a region render — id + props payload + session seal (+ the page, for a tree that
10
+ * reads `page.data`). `exp`/`sig` (which rotate per mint) and `ttl` (a cache-window declaration,
11
+ * not a render input) are DELIBERATELY excluded so two renders of the same region+props+user hit;
12
+ * session is INCLUDED so users never cross. Each part is its own key field, so two calls whose
13
+ * parts differ never share an entry, whatever the parts hold. */
14
+ export declare function render_cache_key(id: string, payload: string, session: string, page?: string | null): RenderKey;
15
+ /** Test/ops hook: the HTML kept for `key` now, or null. */
16
+ export declare function render_cache_get(key: RenderKey): string | null;
17
+ /** Test/ops hook: drop everything, the flights in progress too (what they render is not kept, and
18
+ * the calls waiting on them render afresh). */
19
+ export declare function render_cache_clear(): void;
16
20
  /**
17
21
  * THE shared cache-fronted render (G2) — the ONE seam both the deferred-region endpoint
18
22
  * (#render_component) and the inline-island page render (Region.svelte) call. A hit returns the
19
- * memo; a miss renders via `render_body()` (the caller supplies it — the endpoint wraps its render
20
- * in a concurrency gate + timeout, the inline path renders directly), then caches. `cache` is
21
- * `undefined`/`ttl<=0` for a dynamic hole → always renders, never stores. `now` injected for tests.
23
+ * memo; a miss renders via `render_body(signal)` (the caller supplies it — the endpoint wraps its
24
+ * render in a concurrency gate + timeout, the inline path renders directly), then caches. `cache`
25
+ * is `undefined`/`ttl<=0` for a dynamic hole → always renders, never stores.
22
26
  */
23
- export declare function cached_render(render_body: () => string | null | Promise<string | null>, cache: {
24
- key: string;
27
+ export declare function cached_render(render_body: (signal?: AbortSignal) => string | null | Promise<string | null>, cache: {
28
+ key: RenderKey;
25
29
  ttl: number;
26
- } | undefined, now: number, report?: (outcome: "hit" | "miss" | "none") => void): Promise<string | null>;
30
+ } | undefined, report?: (outcome: "hit" | "miss" | "none") => void, signal?: AbortSignal): Promise<string | null>;
@@ -1,51 +1,110 @@
1
- import { SizedLru } from "./sized-lru.js";
2
- const store = new SizedLru(67108864, 500);
3
- /** Cache key for a region render — id + props payload + session seal. `exp`/`sig` (which rotate per
4
- * mint) and `ttl` (a cache-window declaration, not a render input) are DELIBERATELY excluded so
5
- * two renders of the same region+props+user hit; session is INCLUDED so users never cross. */
6
- function render_cache_key(id, props_payload, session) {
7
- return id + " " + props_payload + " " + session;
8
- }
9
- /** Cached HTML for `key`, or null on miss/expiry. A live hit is an LRU touch. */
10
- function render_cache_get(key, now) {
11
- return store.get(key, now);
1
+ import { STALE, flights } from "@ogygia/machine";
2
+ // src/server/render-cache.ts
3
+ /**
4
+ * Server-side per-region HTML render cache (reconciler R6 — the SSR-compute lever).
5
+ *
6
+ * The deferred/held-region endpoint (`#render_component` in hooks.ts) is the ONE place ogygia
7
+ * renders a region out-of-band as (id + props) → HTML. Today it re-renders fresh every request.
8
+ * When a region opts into a positive `maxAge` (a signed `ttl` on its capability URL), the author
9
+ * has DECLARED its output stable for that window — so memoizing the render for `ttl` seconds turns
10
+ * an expensive region into a one-render-then-serve-cached region across ALL requests. That is the
11
+ * only server-compute win reachable without making the monolithic page render region-granular
12
+ * (which would need the off-limits compiler); a plain inline island never reaches this seam.
13
+ *
14
+ * CORRECTNESS: the cache key includes the SESSION seal, so a per-user (`private, max-age`) render
15
+ * is never served to another user — exactly matching the browser Cache-Control the same `ttl`
16
+ * produces. `ttl <= 0` (the default `no-store` hole) is never cached. Bounded in BYTES (a region
17
+ * render can be up to `MAX_REGION_BODY`, 2 MB) and in entries, TTL-expiring, so it can't grow
18
+ * unbounded across a long-lived server process.
19
+ *
20
+ * ONE RENDER PER KEY IN FLIGHT (#244): the renders are a `flights()`, one flight per key, and every
21
+ * call for that key while it renders joins it instead of rendering again, so a burst of one cached
22
+ * hole takes one render gate slot, not one per request.
23
+ *
24
+ * - A failed render fails the calls that joined it too, and is not kept: the next call renders
25
+ * afresh. They asked for the same render (same region, props and visitor), so it failed for them
26
+ * as it would have on their own; each trying again in turn would be the burst this stops, on the
27
+ * path that is already failing (a hung upstream would time each of them out, each holding a slot).
28
+ * - Each call joins with its own request's signal. A request that leaves (a cancelled batch, #245)
29
+ * leaves the flight alone: the render goes on, in its place in the render queue, for the calls
30
+ * still waiting on it, and is stopped only once every one of them has left. The render is handed
31
+ * the flight's signal, not its opener's.
32
+ */
33
+ /** Resident budget for cached region HTML. */
34
+ const MAX_BYTES = 67108864;
35
+ const ENTRY_SHARE = Math.floor(MAX_BYTES / 500);
36
+ const renders = flights.keyed()({
37
+ name: "render cache",
38
+ key: [
39
+ "id",
40
+ "payload",
41
+ "session",
42
+ "page"
43
+ ],
44
+ ignore: {
45
+ render_body: "the render of the key: the same region, props, visitor and page",
46
+ ttl: "how long the HTML stays, not what is rendered"
47
+ },
48
+ run: async (ask, signal) => ask.render_body(signal),
49
+ keep: (html, ask) => html === null ? 0 : ask.ttl * 1e3,
50
+ max: MAX_BYTES,
51
+ weigh: (html) => Math.max(html?.length ?? 0, ENTRY_SHARE)
52
+ });
53
+ /** Cache key for a region render — id + props payload + session seal (+ the page, for a tree that
54
+ * reads `page.data`). `exp`/`sig` (which rotate per mint) and `ttl` (a cache-window declaration,
55
+ * not a render input) are DELIBERATELY excluded so two renders of the same region+props+user hit;
56
+ * session is INCLUDED so users never cross. Each part is its own key field, so two calls whose
57
+ * parts differ never share an entry, whatever the parts hold. */
58
+ function render_cache_key(id, payload, session, page = null) {
59
+ return {
60
+ id,
61
+ payload,
62
+ session,
63
+ page
64
+ };
12
65
  }
13
- /** Cache `html` for `ttl_sec` seconds. No-op for `ttl_sec <= 0` (dynamic holes are never cached). */
14
- function render_cache_set(key, html, ttl_sec, now) {
15
- if (ttl_sec <= 0) return;
16
- store.set(key, html, html.length, now + ttl_sec * 1e3);
66
+ /** Test/ops hook: the HTML kept for `key` now, or null. */
67
+ function render_cache_get(key) {
68
+ const seen = renders.peek(key);
69
+ return seen?.kind === "kept" ? seen.answer : null;
17
70
  }
18
- /** Test/ops hook: drop everything. */
71
+ /** Test/ops hook: drop everything, the flights in progress too (what they render is not kept, and
72
+ * the calls waiting on them render afresh). */
19
73
  function render_cache_clear() {
20
- store.clear();
21
- }
22
- /** Test/ops hook: live entries + resident bytes. */
23
- function render_cache_stats() {
24
- return {
25
- entries: store.size,
26
- bytes: store.bytes
27
- };
74
+ renders.drop(() => true);
28
75
  }
29
76
  /**
30
77
  * THE shared cache-fronted render (G2) — the ONE seam both the deferred-region endpoint
31
78
  * (#render_component) and the inline-island page render (Region.svelte) call. A hit returns the
32
- * memo; a miss renders via `render_body()` (the caller supplies it — the endpoint wraps its render
33
- * in a concurrency gate + timeout, the inline path renders directly), then caches. `cache` is
34
- * `undefined`/`ttl<=0` for a dynamic hole → always renders, never stores. `now` injected for tests.
79
+ * memo; a miss renders via `render_body(signal)` (the caller supplies it — the endpoint wraps its
80
+ * render in a concurrency gate + timeout, the inline path renders directly), then caches. `cache`
81
+ * is `undefined`/`ttl<=0` for a dynamic hole → always renders, never stores.
35
82
  */
36
- async function cached_render(render_body, cache, now, report) {
37
- if (cache !== void 0 && cache.ttl > 0) {
38
- const hit = render_cache_get(cache.key, now);
39
- if (hit !== null) {
40
- report?.("hit");
41
- return hit;
83
+ async function cached_render(render_body, cache, report, signal) {
84
+ if (cache === void 0 || cache.ttl <= 0) {
85
+ const body = await render_body(signal);
86
+ report?.("none");
87
+ return body;
88
+ }
89
+ const ask = {
90
+ ...cache.key,
91
+ render_body,
92
+ ttl: cache.ttl
93
+ };
94
+ for (;;) {
95
+ const opens = renders.peek(cache.key) === void 0;
96
+ let html;
97
+ try {
98
+ html = await renders.get(ask, { signal });
99
+ } catch (error) {
100
+ report?.("none");
101
+ throw error;
102
+ }
103
+ if (html !== STALE) {
104
+ report?.(html === null ? "none" : opens ? "miss" : "hit");
105
+ return html;
42
106
  }
107
+ if (signal?.aborted) throw signal.reason;
43
108
  }
44
- const body = await render_body();
45
- if (cache !== void 0 && cache.ttl > 0 && body !== null) {
46
- render_cache_set(cache.key, body, cache.ttl, now);
47
- report?.("miss");
48
- } else report?.("none");
49
- return body;
50
109
  }
51
- export { cached_render, render_cache_clear, render_cache_get, render_cache_key, render_cache_set, render_cache_stats };
110
+ export { cached_render, render_cache_clear, render_cache_get, render_cache_key };
@@ -1,7 +1,10 @@
1
+ import { request_local } from "../plugin/request-scope.js";
1
2
  import { islands_endpoint } from "./endpoint.js";
2
3
  import { look_up_page } from "./page-facts.js";
3
4
  import { merge_seed_ask, shape_page_data } from "./seed-shape.js";
4
5
  import { PAGE_FACTS_SCRIPT_TYPE } from "../page-facts-script.js";
6
+ import { line } from "../messages/server.js";
7
+ import { flights } from "@ogygia/machine";
5
8
  import * as devalue from "devalue";
6
9
  // src/server/render-page.ts
7
10
  let warn = null;
@@ -25,8 +28,35 @@ function page_url_of(event) {
25
28
  } catch {}
26
29
  return event.url;
27
30
  }
28
- const looked_up = /* @__PURE__ */ new WeakMap();
29
- const facts_of = /* @__PURE__ */ new WeakMap();
31
+ /** Why a page-facts look-up's inputs are not its key: a request has one look-up. */
32
+ const ONE_PER_REQUEST = "a request has one look-up";
33
+ /**
34
+ * Each request's look-up of its page's facts, a flight of one key the request owns: looked up once
35
+ * per request (a failed one is not kept), its answer the page's facts (null: unavailable), its
36
+ * streamed reader let go when the request ends.
37
+ */
38
+ const looked_up = request_local((owner) => flights.keyed()({
39
+ name: "page facts",
40
+ key: [],
41
+ ignore: {
42
+ page: ONE_PER_REQUEST,
43
+ fetch: ONE_PER_REQUEST,
44
+ decoders: ONE_PER_REQUEST
45
+ },
46
+ run: ({ page, fetch, decoders }) => look_up_page(page, fetch, decoders, owner).then((facts) => {
47
+ if (!("miss" in facts)) return facts;
48
+ warn?.(line("outside_page_data_unavailable", {
49
+ path: page.pathname,
50
+ miss: facts.miss
51
+ }));
52
+ return null;
53
+ })
54
+ }));
55
+ /** The page's facts this request looked up, or undefined. */
56
+ function facts_of(request) {
57
+ const entry = looked_up(request).peek({});
58
+ return entry?.kind === "kept" ? entry.answer ?? void 0 : void 0;
59
+ }
30
60
  /**
31
61
  * Before an async render root starts: in a request rendering FOR a page, that page's facts, looked
32
62
  * up once. `reads_data`: does this render read `page.data` (`null`: unknown here — a remote call
@@ -36,19 +66,17 @@ const facts_of = /* @__PURE__ */ new WeakMap();
36
66
  function prepare_render_page(event, transport, reads_data) {
37
67
  if (!event || !renders_for_a_page(event)) return;
38
68
  if (!(reads_data ?? event.isRemoteRequest === true)) return;
39
- const request = event.request;
40
- const known = looked_up.get(request);
41
- if (known) return known;
42
69
  const page = page_url_of(event);
43
70
  if (page === event.url) return;
71
+ const look_up = looked_up(event.request);
72
+ if (look_up.peek({})?.kind === "kept") return;
44
73
  const decoders = {};
45
74
  for (const name in transport ?? {}) decoders[name] = transport[name].decode;
46
- const p = look_up_page(page, event.fetch, decoders).then((facts) => {
47
- if ("miss" in facts) warn?.(`[ogygia] ${page.pathname}: the page's data for a render outside it is unavailable: ${facts.miss}. The render sees an empty page.data.`);
48
- else facts_of.set(request, facts);
49
- });
50
- looked_up.set(request, p);
51
- return p;
75
+ return look_up.get({
76
+ page,
77
+ fetch: event.fetch,
78
+ decoders
79
+ }).then(() => {});
52
80
  }
53
81
  /**
54
82
  * The page a render sees. `snapshot`: what this request's page render recorded, if anything. `null`
@@ -57,7 +85,7 @@ function prepare_render_page(event, transport, reads_data) {
57
85
  function render_page(snapshot, event) {
58
86
  if (!snapshot && !event) return null;
59
87
  if (event && renders_for_a_page(event)) {
60
- const facts = facts_of.get(event.request);
88
+ const facts = facts_of(event.request);
61
89
  return {
62
90
  url: facts?.url ?? page_url_of(event),
63
91
  params: facts?.params ?? {},
@@ -105,7 +133,10 @@ function watched_data(facts, say) {
105
133
  if (!seen) warned_keys.set(facts, seen = /* @__PURE__ */ new Set());
106
134
  if (!seen.has(key)) {
107
135
  seen.add(key);
108
- say(`[ogygia] ${facts.url.pathname}: a region rendered outside its page read page.data.${key}, which this page's server loads do not produce. A key a universal load (+page.ts / +layout.ts) returns exists only inside a full page render: return it from a server load, or pass it as a prop.`);
136
+ say(line("outside_page_data_key", {
137
+ path: facts.url.pathname,
138
+ key
139
+ }));
109
140
  }
110
141
  }
111
142
  return Reflect.get(target, key, receiver);
@@ -123,7 +154,7 @@ function render_page_version(event, snapshot) {
123
154
  if (!event) return "";
124
155
  const known = versions.get(event.request);
125
156
  if (known !== void 0) return known;
126
- const data = renders_for_a_page(event) ? facts_of.get(event.request)?.data : snapshot?.data;
157
+ const data = renders_for_a_page(event) ? facts_of(event.request)?.data : snapshot?.data;
127
158
  let v = "";
128
159
  if (data && typeof data === "object") try {
129
160
  v = fnv1a(JSON.stringify(data));
@@ -162,7 +193,7 @@ function note_render_page_ask(event, seed) {
162
193
  */
163
194
  function render_page_facts_script(event, transport) {
164
195
  if (!event || !renders_for_a_page(event)) return "";
165
- const facts = facts_of.get(event.request);
196
+ const facts = facts_of(event.request);
166
197
  const keys = asks_of.get(event.request);
167
198
  if (!facts || !keys) return "";
168
199
  const encoders = {};
@@ -9,7 +9,7 @@
9
9
  * WeakMap — the record dies with the request, nothing is deleted, and the two modules never
10
10
  * import each other (hooks.ts imports the profiler lazily; the profiler must not import hooks).
11
11
  *
12
- * DETAIL is opt-in (`set_request_stats_detail`): the profiler turns it on for the length of a
12
+ * DETAIL is opt-in (`hold_request_stats_detail`): the profiler holds it on for the length of a
13
13
  * recording, and only then does the handle spend anything on the per-island rows, the seed
14
14
  * explainer and the hole notes below. Off, a request pays one WeakMap write of the totals.
15
15
  */
@@ -163,12 +163,14 @@ export declare function set_batch_hole_listener(fn: ((request: Request, s: Batch
163
163
  export declare function has_batch_hole_listener(): boolean;
164
164
  export declare function record_batch_hole_stats(request: Request, s: BatchHoleStats): void;
165
165
  /**
166
- * The profiler turns detail on for a recording; off, the handle records totals only. COUNTED, not a
167
- * switch: the flag is the process's, while a recorder lock is one profiler instance's — and two can
168
- * live at once (the dev server re-runs the app's hooks on an edit and `ogygia.handle()` builds a new
169
- * profiler, while the old one's sample and trap timers still record). The first window to end then
170
- * turned detail off under the other's render: its report kept no island rows ("islands 1, rows 0")
171
- * and named its islands by their files. On while any window records.
166
+ * The profiler asks for detail for a recording; with nobody asking, the handle records totals only.
167
+ * HELD, not a switch: the flag is the process's, while a recorder lock is one profiler instance's —
168
+ * and two can live at once (the dev server re-runs the app's hooks on an edit and `ogygia.handle()`
169
+ * builds a new profiler, while the old one's sample and trap timers still record). The first window
170
+ * to end once turned detail off under the other's render: its report kept no island rows ("islands
171
+ * 1, rows 0") and named its islands by their files. On while any hold stands; each hold is its
172
+ * caller's, and its release lets go of that hold only, once (a second call does nothing), so an
173
+ * extra "off" can never end someone else's.
172
174
  */
173
- export declare function set_request_stats_detail(on: boolean): void;
175
+ export declare function hold_request_stats_detail(): () => void;
174
176
  export declare function request_stats_detailed(): boolean;
@@ -36,19 +36,27 @@ function record_batch_hole_stats(request, s) {
36
36
  batch_listener?.(request, s);
37
37
  }
38
38
  /**
39
- * The profiler turns detail on for a recording; off, the handle records totals only. COUNTED, not a
40
- * switch: the flag is the process's, while a recorder lock is one profiler instance's — and two can
41
- * live at once (the dev server re-runs the app's hooks on an edit and `ogygia.handle()` builds a new
42
- * profiler, while the old one's sample and trap timers still record). The first window to end then
43
- * turned detail off under the other's render: its report kept no island rows ("islands 1, rows 0")
44
- * and named its islands by their files. On while any window records.
39
+ * The profiler asks for detail for a recording; with nobody asking, the handle records totals only.
40
+ * HELD, not a switch: the flag is the process's, while a recorder lock is one profiler instance's —
41
+ * and two can live at once (the dev server re-runs the app's hooks on an edit and `ogygia.handle()`
42
+ * builds a new profiler, while the old one's sample and trap timers still record). The first window
43
+ * to end once turned detail off under the other's render: its report kept no island rows ("islands
44
+ * 1, rows 0") and named its islands by their files. On while any hold stands; each hold is its
45
+ * caller's, and its release lets go of that hold only, once (a second call does nothing), so an
46
+ * extra "off" can never end someone else's.
45
47
  */
46
- function set_request_stats_detail(on) {
47
- detail_depth = on ? detail_depth + 1 : Math.max(0, detail_depth - 1);
48
- detailed = detail_depth > 0;
49
- }
50
- let detail_depth = 0;
48
+ function hold_request_stats_detail() {
49
+ const hold = {};
50
+ detail_holds.add(hold);
51
+ detailed = true;
52
+ return () => {
53
+ if (!detail_holds.delete(hold)) return;
54
+ detailed = detail_holds.size > 0;
55
+ };
56
+ }
57
+ /** Who asked for detail, each until its own release. */
58
+ const detail_holds = /* @__PURE__ */ new Set();
51
59
  function request_stats_detailed() {
52
60
  return detailed;
53
61
  }
54
- export { has_batch_hole_listener, hole_stats_of, record_batch_hole_stats, record_hole_stats, record_request_stats, request_stats_detailed, request_stats_of, request_stats_wanted, set_batch_hole_listener, set_request_stats_detail, want_request_stats };
62
+ export { has_batch_hole_listener, hold_request_stats_detail, hole_stats_of, record_batch_hole_stats, record_hole_stats, record_request_stats, request_stats_detailed, request_stats_of, request_stats_wanted, set_batch_hole_listener, want_request_stats };
@@ -0,0 +1,11 @@
1
+ import { Rules } from "../rules.js";
2
+ import { Scope } from "@ogygia/machine";
3
+ type ServerRule = "rule_request_scope_open" | "rule_flight_kept_failure";
4
+ /** The server's rules. */
5
+ export declare const server_rules: Rules<ServerRule>;
6
+ /** A response ended (its body read to the end, cancelled, or none): its request's scope must be
7
+ * closed by the time the next check runs. */
8
+ export declare function note_response_end(owner: Scope): void;
9
+ /** Rules on: checked after each response, and what broke is said with its code. */
10
+ export declare function install_server_rules(): void;
11
+ export {};