@moku-labs/web 2.0.0 → 2.1.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.
- package/README.md +45 -0
- package/dist/browser.d.mts +207 -10
- package/dist/browser.mjs +439 -26
- package/dist/index.cjs +439 -25
- package/dist/index.d.cts +207 -10
- package/dist/index.d.mts +207 -10
- package/dist/index.mjs +439 -26
- package/dist/{render-yXHc9BWI.mjs → render-CkdqOmsm.mjs} +7 -1
- package/dist/{render-DHUcHCYs.cjs → render-rf8FAg_Z.cjs} +7 -1
- package/dist/testing.d.mts +36 -1
- package/dist/testing.mjs +18 -3
- package/package.json +3 -3
package/dist/index.cjs
CHANGED
|
@@ -1420,6 +1420,22 @@ function route(pattern) {
|
|
|
1420
1420
|
const builder = {
|
|
1421
1421
|
pattern: carrier.pattern,
|
|
1422
1422
|
_meta: carrier._meta,
|
|
1423
|
+
/**
|
|
1424
|
+
* The named view-transition behaviour set via `.transition()` (live read).
|
|
1425
|
+
*
|
|
1426
|
+
* @returns The transition mode, or undefined when unset (app default applies).
|
|
1427
|
+
*/
|
|
1428
|
+
get _transition() {
|
|
1429
|
+
return carrier._transition;
|
|
1430
|
+
},
|
|
1431
|
+
/**
|
|
1432
|
+
* The scroll behaviour set via `.scroll()` (live read).
|
|
1433
|
+
*
|
|
1434
|
+
* @returns The scroll mode, or undefined when unset (app default applies).
|
|
1435
|
+
*/
|
|
1436
|
+
get _scroll() {
|
|
1437
|
+
return carrier._scroll;
|
|
1438
|
+
},
|
|
1423
1439
|
_handlers: carrier._handlers,
|
|
1424
1440
|
...createBuilderMethods(set),
|
|
1425
1441
|
/**
|
|
@@ -1437,6 +1453,38 @@ function route(pattern) {
|
|
|
1437
1453
|
meta(meta) {
|
|
1438
1454
|
Object.assign(carrier._meta, meta);
|
|
1439
1455
|
return builder;
|
|
1456
|
+
},
|
|
1457
|
+
/**
|
|
1458
|
+
* Declare the view-transition behaviour for navigations TO this route — a typed
|
|
1459
|
+
* framework directive read by the SPA kernel, overriding the app-wide
|
|
1460
|
+
* `spa.viewTransitions` default.
|
|
1461
|
+
*
|
|
1462
|
+
* @param mode - The {@link TransitionMode} (`none`/`crossfade`/`slide`/`morph`).
|
|
1463
|
+
* @returns The same builder for chaining.
|
|
1464
|
+
* @example
|
|
1465
|
+
* ```ts
|
|
1466
|
+
* route("/board/{id}/issue/{issueId}").transition("morph");
|
|
1467
|
+
* ```
|
|
1468
|
+
*/
|
|
1469
|
+
transition(mode) {
|
|
1470
|
+
carrier._transition = mode;
|
|
1471
|
+
return builder;
|
|
1472
|
+
},
|
|
1473
|
+
/**
|
|
1474
|
+
* Declare the scroll behaviour for navigations TO this route — a typed framework
|
|
1475
|
+
* directive read by the SPA kernel, overriding the app-wide `spa.scrollRestoration`
|
|
1476
|
+
* default.
|
|
1477
|
+
*
|
|
1478
|
+
* @param mode - The {@link ScrollMode} (`top`/`preserve`).
|
|
1479
|
+
* @returns The same builder for chaining.
|
|
1480
|
+
* @example
|
|
1481
|
+
* ```ts
|
|
1482
|
+
* route("/board/{id}/issue/{issueId}").scroll("preserve");
|
|
1483
|
+
* ```
|
|
1484
|
+
*/
|
|
1485
|
+
scroll(mode) {
|
|
1486
|
+
carrier._scroll = mode;
|
|
1487
|
+
return builder;
|
|
1440
1488
|
}
|
|
1441
1489
|
};
|
|
1442
1490
|
return builder;
|
|
@@ -9161,11 +9209,12 @@ function createApi(ctx) {
|
|
|
9161
9209
|
* Programmatically navigate to a path (client runtime; no-op without a DOM).
|
|
9162
9210
|
*
|
|
9163
9211
|
* @param path - Target path (pathname, optionally with search/hash).
|
|
9212
|
+
* @param options - Optional per-navigation overrides (e.g. `{ scroll: "preserve" }`).
|
|
9164
9213
|
* @example
|
|
9165
9214
|
* app.spa.navigate("/about");
|
|
9166
9215
|
*/
|
|
9167
|
-
navigate(path) {
|
|
9168
|
-
ctx.state.kernel?.processNav(path);
|
|
9216
|
+
navigate(path, options) {
|
|
9217
|
+
ctx.state.kernel?.processNav(path, options);
|
|
9169
9218
|
},
|
|
9170
9219
|
/**
|
|
9171
9220
|
* Read the current resolved URL.
|
|
@@ -9356,13 +9405,15 @@ let commitVNodeFunction;
|
|
|
9356
9405
|
* await loadRenderChunk();
|
|
9357
9406
|
*/
|
|
9358
9407
|
async function loadRenderChunk() {
|
|
9359
|
-
renderChunk ??= Promise.resolve().then(() => require("./render-
|
|
9408
|
+
renderChunk ??= Promise.resolve().then(() => require("./render-rf8FAg_Z.cjs"));
|
|
9360
9409
|
commitVNodeFunction = (await renderChunk).commitVNode;
|
|
9361
9410
|
}
|
|
9362
9411
|
/**
|
|
9363
9412
|
* Commit a {@link RenderResult} into a host: `string` → `innerHTML`, `Node` →
|
|
9364
|
-
* `replaceChildren`, `
|
|
9365
|
-
*
|
|
9413
|
+
* `replaceChildren`, `null` → Preact unmount via the lazy gate (`render(null, host)` —
|
|
9414
|
+
* stays re-mountable, the correct "empty" for a persistent render-island), `void`/`undefined`
|
|
9415
|
+
* → no-op (the render mutated the DOM itself), and a Preact `VNode` → committed through the
|
|
9416
|
+
* lazy gate (loading it on demand if needed).
|
|
9366
9417
|
*
|
|
9367
9418
|
* @param host - The island host element to render into.
|
|
9368
9419
|
* @param result - The value returned by the island's `render`.
|
|
@@ -9489,6 +9540,18 @@ function buildContext(state, instance) {
|
|
|
9489
9540
|
return instance.route.url;
|
|
9490
9541
|
},
|
|
9491
9542
|
/**
|
|
9543
|
+
* Programmatically navigate the SPA (delegates to the kernel's navigator through
|
|
9544
|
+
* `state.navigate` — the same decoupled seam as `island`). No-op before the kernel binds.
|
|
9545
|
+
*
|
|
9546
|
+
* @param path - The internal destination path.
|
|
9547
|
+
* @param options - Optional per-navigation overrides (e.g. `{ scroll: "preserve" }`).
|
|
9548
|
+
* @example
|
|
9549
|
+
* ctx.navigate(ctx.url("issue", { id, issueId }));
|
|
9550
|
+
*/
|
|
9551
|
+
navigate(path, options) {
|
|
9552
|
+
state.navigate?.(path, options);
|
|
9553
|
+
},
|
|
9554
|
+
/**
|
|
9492
9555
|
* The live per-instance state (`undefined` for legacy hooks-only islands).
|
|
9493
9556
|
*
|
|
9494
9557
|
* @returns The current state.
|
|
@@ -10108,20 +10171,48 @@ function prefersReducedMotion() {
|
|
|
10108
10171
|
* Because the swap is post-fetch, doing it here also avoids the visible "scroll up,
|
|
10109
10172
|
* THEN the page loads" pause that scrolling in the router (pre-fetch) produced.
|
|
10110
10173
|
*
|
|
10174
|
+
* Named transitions (`transition.types`, e.g. `["slide"]`) are exposed to CSS two ways
|
|
10175
|
+
* for the transition's lifetime, so a consumer can style the motion per navigation:
|
|
10176
|
+
* • a `:root[data-view-transition~="slide"]` attribute (works on EVERY View-Transitions
|
|
10177
|
+
* engine, including those without the `types` dictionary form);
|
|
10178
|
+
* • the standards-track `types` option (`:active-view-transition-type(slide)`) when the
|
|
10179
|
+
* engine supports the `startViewTransition({ update, types })` dictionary form.
|
|
10180
|
+
* Empty `types` ⇒ the default root crossfade. A shared `view-transition-name` left on an
|
|
10181
|
+
* element across the swap (e.g. a card and the panel it opens) morphs one into the other.
|
|
10182
|
+
*
|
|
10111
10183
|
* @param doSwap - The synchronous DOM mutation to perform.
|
|
10112
|
-
* @param
|
|
10184
|
+
* @param transition - The resolved {@link SwapTransition} (enabled + named types).
|
|
10113
10185
|
* @param beforeCapture - Optional hook run synchronously just before the swap/capture
|
|
10114
10186
|
* (e.g. scroll to the destination position).
|
|
10115
10187
|
* @example
|
|
10116
|
-
* runSwap(() => current.replaceWith(next),
|
|
10188
|
+
* runSwap(() => current.replaceWith(next), { enabled: true, types: ["slide"] });
|
|
10117
10189
|
*/
|
|
10118
|
-
function runSwap(doSwap,
|
|
10190
|
+
function runSwap(doSwap, transition, beforeCapture) {
|
|
10119
10191
|
const reduced = prefersReducedMotion();
|
|
10120
10192
|
const docWithVt = document;
|
|
10121
|
-
const canUseViewTransitions =
|
|
10193
|
+
const canUseViewTransitions = transition.enabled && !reduced && typeof docWithVt.startViewTransition === "function";
|
|
10122
10194
|
beforeCapture?.();
|
|
10123
|
-
if (canUseViewTransitions)
|
|
10124
|
-
|
|
10195
|
+
if (!canUseViewTransitions) {
|
|
10196
|
+
doSwap();
|
|
10197
|
+
return;
|
|
10198
|
+
}
|
|
10199
|
+
const root = typeof document === "undefined" ? void 0 : document.documentElement;
|
|
10200
|
+
const hasTypes = transition.types.length > 0;
|
|
10201
|
+
if (hasTypes && root) root.dataset.viewTransition = transition.types.join(" ");
|
|
10202
|
+
const clearMarker = () => {
|
|
10203
|
+
if (hasTypes && root) delete root.dataset.viewTransition;
|
|
10204
|
+
};
|
|
10205
|
+
let result;
|
|
10206
|
+
if (hasTypes) try {
|
|
10207
|
+
result = docWithVt.startViewTransition({
|
|
10208
|
+
update: doSwap,
|
|
10209
|
+
types: transition.types
|
|
10210
|
+
});
|
|
10211
|
+
} catch {
|
|
10212
|
+
result = docWithVt.startViewTransition(doSwap);
|
|
10213
|
+
}
|
|
10214
|
+
else result = docWithVt.startViewTransition(doSwap);
|
|
10215
|
+
Promise.resolve(result?.finished).then(clearMarker).catch(clearMarker);
|
|
10125
10216
|
}
|
|
10126
10217
|
/**
|
|
10127
10218
|
* Replace the `swapSelector` region of the live document with the matching
|
|
@@ -10135,22 +10226,22 @@ function runSwap(doSwap, viewTransitions, beforeCapture) {
|
|
|
10135
10226
|
*
|
|
10136
10227
|
* @param doc - The fetched document (DOMParser-parsed) holding the new region.
|
|
10137
10228
|
* @param swapSelector - CSS selector for the region to replace.
|
|
10138
|
-
* @param
|
|
10229
|
+
* @param transition - The resolved {@link SwapTransition} (enabled + named types).
|
|
10139
10230
|
* @param onSwapped - Callback run after the DOM mutation (mount/notify/scroll).
|
|
10140
10231
|
* @param beforeCapture - Optional hook run synchronously just before the swap/capture
|
|
10141
10232
|
* (forwarded to {@link runSwap} — e.g. scroll to the destination position).
|
|
10142
10233
|
* @returns `true` when the swap was dispatched, `false` when either document lacks the region.
|
|
10143
10234
|
* @example
|
|
10144
|
-
* swapRegion(doc, "main > section", false, () => mountNew());
|
|
10235
|
+
* swapRegion(doc, "main > section", { enabled: false, types: [] }, () => mountNew());
|
|
10145
10236
|
*/
|
|
10146
|
-
function swapRegion(doc, swapSelector,
|
|
10237
|
+
function swapRegion(doc, swapSelector, transition, onSwapped, beforeCapture) {
|
|
10147
10238
|
const newContent = doc.querySelector(swapSelector);
|
|
10148
10239
|
const currentContent = document.querySelector(swapSelector);
|
|
10149
10240
|
if (!newContent || !currentContent) return false;
|
|
10150
10241
|
runSwap(() => {
|
|
10151
10242
|
currentContent.replaceWith(newContent);
|
|
10152
10243
|
onSwapped();
|
|
10153
|
-
},
|
|
10244
|
+
}, transition, beforeCapture);
|
|
10154
10245
|
return true;
|
|
10155
10246
|
}
|
|
10156
10247
|
/**
|
|
@@ -10307,10 +10398,25 @@ function attachRouter(handlers, navigate) {
|
|
|
10307
10398
|
const ERROR_PREFIX$1 = "[web]";
|
|
10308
10399
|
/** Last-resort `swapSelector` when neither config nor defaults supply one. */
|
|
10309
10400
|
const FALLBACK_SWAP_SELECTOR = "main > section";
|
|
10401
|
+
/**
|
|
10402
|
+
* Resolve the `viewTransitions` config value into the default {@link TransitionMode}:
|
|
10403
|
+
* `true` → `"crossfade"`, `false` → `"none"`, a named mode → itself.
|
|
10404
|
+
*
|
|
10405
|
+
* @param raw - The raw `viewTransitions` config value (boolean or named mode).
|
|
10406
|
+
* @returns The resolved default transition mode.
|
|
10407
|
+
* @example
|
|
10408
|
+
* resolveDefaultTransition(true); // "crossfade"
|
|
10409
|
+
*/
|
|
10410
|
+
function resolveDefaultTransition(raw) {
|
|
10411
|
+
if (raw === true) return "crossfade";
|
|
10412
|
+
if (raw === false) return "none";
|
|
10413
|
+
return raw;
|
|
10414
|
+
}
|
|
10310
10415
|
/** Default SPA config (declared as a value — no inline assertion). */
|
|
10311
10416
|
const defaultSpaConfig = {
|
|
10312
10417
|
swapSelector: "main > section",
|
|
10313
10418
|
viewTransitions: false,
|
|
10419
|
+
scrollRestoration: "top",
|
|
10314
10420
|
progressBar: true,
|
|
10315
10421
|
islands: []
|
|
10316
10422
|
};
|
|
@@ -10347,9 +10453,12 @@ function resolveSpaConfig(config) {
|
|
|
10347
10453
|
const swapSelector = config.swapSelector ?? defaultSpaConfig.swapSelector ?? FALLBACK_SWAP_SELECTOR;
|
|
10348
10454
|
if (swapSelector.trim() === "") throw new Error(`${ERROR_PREFIX$1} spa.swapSelector must be a non-empty string.\n Set a CSS selector for the page region to swap (e.g. "main > section").`);
|
|
10349
10455
|
if (!isValidSelector(swapSelector)) throw new Error(`${ERROR_PREFIX$1} spa.swapSelector is not a valid CSS selector: "${swapSelector}".\n Provide a syntactically valid selector.`);
|
|
10456
|
+
const defaultTransition = resolveDefaultTransition(config.viewTransitions ?? false);
|
|
10350
10457
|
return {
|
|
10351
10458
|
swapSelector,
|
|
10352
|
-
viewTransitions:
|
|
10459
|
+
viewTransitions: defaultTransition !== "none",
|
|
10460
|
+
defaultTransition,
|
|
10461
|
+
scrollRestoration: config.scrollRestoration ?? "top",
|
|
10353
10462
|
progressBar: config.progressBar ?? true,
|
|
10354
10463
|
islands: config.islands ?? []
|
|
10355
10464
|
};
|
|
@@ -10374,6 +10483,7 @@ function createState(_ctx) {
|
|
|
10374
10483
|
currentUrl: "",
|
|
10375
10484
|
destroyRouter: null,
|
|
10376
10485
|
started: false,
|
|
10486
|
+
navigate: null,
|
|
10377
10487
|
kernel: null
|
|
10378
10488
|
};
|
|
10379
10489
|
}
|
|
@@ -10444,6 +10554,52 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
10444
10554
|
const resolved = resolveSpaConfig(config);
|
|
10445
10555
|
let progress;
|
|
10446
10556
|
let pendingScrollToTop = true;
|
|
10557
|
+
let pendingTransition = resolveTransition(resolved.defaultTransition);
|
|
10558
|
+
/**
|
|
10559
|
+
* Resolve a {@link TransitionMode} into the runtime {@link SwapTransition} the swap
|
|
10560
|
+
* consumes: `"none"` ⇒ disabled; `"crossfade"` ⇒ enabled, no named types (default root
|
|
10561
|
+
* crossfade); any other mode ⇒ enabled with that name as the View-Transition type. The
|
|
10562
|
+
* app-wide `viewTransitions` enabled flag gates everything: when it is off AND the route
|
|
10563
|
+
* declares no transition, no transition runs.
|
|
10564
|
+
*
|
|
10565
|
+
* @param mode - The effective transition mode for this navigation.
|
|
10566
|
+
* @returns The resolved swap descriptor (enabled + named types).
|
|
10567
|
+
* @example
|
|
10568
|
+
* pendingTransition = resolveTransition("slide");
|
|
10569
|
+
*/
|
|
10570
|
+
function resolveTransition(mode) {
|
|
10571
|
+
if (mode === "none") return {
|
|
10572
|
+
enabled: false,
|
|
10573
|
+
types: []
|
|
10574
|
+
};
|
|
10575
|
+
if (mode === "crossfade") return {
|
|
10576
|
+
enabled: true,
|
|
10577
|
+
types: []
|
|
10578
|
+
};
|
|
10579
|
+
return {
|
|
10580
|
+
enabled: true,
|
|
10581
|
+
types: [mode]
|
|
10582
|
+
};
|
|
10583
|
+
}
|
|
10584
|
+
/**
|
|
10585
|
+
* Resolve the per-navigation scroll + transition intent from the DESTINATION route,
|
|
10586
|
+
* falling back to the app-wide defaults. A route's `.scroll()`/`.transition()` win; an
|
|
10587
|
+
* explicit per-call `scrollOverride` (from `ctx.navigate`/`app.spa.navigate`) wins over
|
|
10588
|
+
* the route. `forwardScrollToTop` carries the navigation-type intent (forward ⇒ `true`,
|
|
10589
|
+
* traverse ⇒ `false`); a `"preserve"` resolution forces no scroll-to-top either way.
|
|
10590
|
+
*
|
|
10591
|
+
* @param pathname - The destination pathname (search stripped for matching).
|
|
10592
|
+
* @param forwardScrollToTop - Whether this nav would scroll to top by default (forward vs traverse).
|
|
10593
|
+
* @param scrollOverride - An explicit per-call scroll override, if any.
|
|
10594
|
+
* @example
|
|
10595
|
+
* applyNavIntent("/board/abc/issue/1", true, undefined);
|
|
10596
|
+
*/
|
|
10597
|
+
const applyNavIntent = (pathname, forwardScrollToTop, scrollOverride) => {
|
|
10598
|
+
const route = deps.router.match(pathname.split("?")[0] ?? pathname)?.route;
|
|
10599
|
+
const scrollMode = scrollOverride ?? route?._scroll ?? resolved.scrollRestoration;
|
|
10600
|
+
pendingScrollToTop = forwardScrollToTop && scrollMode !== "preserve";
|
|
10601
|
+
pendingTransition = resolveTransition(route?._transition ?? resolved.defaultTransition);
|
|
10602
|
+
};
|
|
10447
10603
|
/**
|
|
10448
10604
|
* Apply the in-flight navigation's scroll intent — the swap's `beforeCapture` hook.
|
|
10449
10605
|
* For a forward nav it scrolls to top BEFORE the swap (and, with view transitions on,
|
|
@@ -10509,7 +10665,7 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
10509
10665
|
const doc = new DOMParser().parseFromString(html, "text/html");
|
|
10510
10666
|
syncHead(deps.head, doc);
|
|
10511
10667
|
unmountPageSpecific(state, emit);
|
|
10512
|
-
if (!swapRegion(doc, resolved.swapSelector,
|
|
10668
|
+
if (!swapRegion(doc, resolved.swapSelector, pendingTransition, () => {
|
|
10513
10669
|
const routeSlice = islandRouteContext(pathname);
|
|
10514
10670
|
scanAndMount(state, emit, resolved.swapSelector, routeSlice);
|
|
10515
10671
|
notifyNavEnd(state, routeSlice);
|
|
@@ -10609,7 +10765,7 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
10609
10765
|
const commitDataRender = async (pathname, resolvedRender, signal) => {
|
|
10610
10766
|
if (signal?.aborted) return;
|
|
10611
10767
|
const { route, vnode, routeContext, region } = resolvedRender;
|
|
10612
|
-
const { renderVNode } = await Promise.resolve().then(() => require("./render-
|
|
10768
|
+
const { renderVNode } = await Promise.resolve().then(() => require("./render-rf8FAg_Z.cjs"));
|
|
10613
10769
|
if (signal?.aborted) return;
|
|
10614
10770
|
syncDataHead(deps.head, route, routeContext);
|
|
10615
10771
|
unmountPageSpecific(state, emit);
|
|
@@ -10628,7 +10784,7 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
10628
10784
|
scanAndMount(state, emit, resolved.swapSelector, routeSlice);
|
|
10629
10785
|
notifyNavEnd(state, routeSlice);
|
|
10630
10786
|
};
|
|
10631
|
-
runSwap(renderAndMount,
|
|
10787
|
+
runSwap(renderAndMount, pendingTransition, applyPendingScroll);
|
|
10632
10788
|
state.currentUrl = pathname;
|
|
10633
10789
|
progress?.done();
|
|
10634
10790
|
emit("spa:navigated", { url: pathname });
|
|
@@ -10678,7 +10834,7 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
10678
10834
|
return;
|
|
10679
10835
|
}
|
|
10680
10836
|
const { vnode, region } = resolvedRender;
|
|
10681
|
-
const { renderVNode } = await Promise.resolve().then(() => require("./render-
|
|
10837
|
+
const { renderVNode } = await Promise.resolve().then(() => require("./render-rf8FAg_Z.cjs"));
|
|
10682
10838
|
renderVNode(vnode, region);
|
|
10683
10839
|
scanAndMount(state, emit, resolved.swapSelector, routeSlice);
|
|
10684
10840
|
};
|
|
@@ -10693,12 +10849,14 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
10693
10849
|
* (default `true`; forward navs). Traverse passes `false` to keep its restored scroll.
|
|
10694
10850
|
* @param signal - Aborts when this navigation is superseded (`navEvent.signal`);
|
|
10695
10851
|
* a superseded navigation never applies its swap (no stale last-write-wins).
|
|
10852
|
+
* @param scrollOverride - An explicit per-call scroll override (from `ctx.navigate`/
|
|
10853
|
+
* `app.spa.navigate`); wins over the destination route's `.scroll()` + the app default.
|
|
10696
10854
|
* @returns A promise resolving once the swap (or fallback) is dispatched.
|
|
10697
10855
|
* @example
|
|
10698
10856
|
* await navigate("/en/world/");
|
|
10699
10857
|
*/
|
|
10700
|
-
const navigate = async (pathname, scrollToTop = true, signal) => {
|
|
10701
|
-
|
|
10858
|
+
const navigate = async (pathname, scrollToTop = true, signal, scrollOverride) => {
|
|
10859
|
+
applyNavIntent(pathname, scrollToTop, scrollOverride);
|
|
10702
10860
|
handleStart(pathname);
|
|
10703
10861
|
if (deps.router.mode() !== "ssg" && await tryDataRender(pathname, signal)) return;
|
|
10704
10862
|
if (signal?.aborted) return;
|
|
@@ -10714,6 +10872,10 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
10714
10872
|
init() {
|
|
10715
10873
|
for (const island of resolved.islands) registerIsland(state, island);
|
|
10716
10874
|
state.currentUrl = currentLocationUrl();
|
|
10875
|
+
state.navigate = (path, options) => {
|
|
10876
|
+
if (typeof document === "undefined") return;
|
|
10877
|
+
navigate(path, true, void 0, options?.scroll).catch(() => {});
|
|
10878
|
+
};
|
|
10717
10879
|
},
|
|
10718
10880
|
/**
|
|
10719
10881
|
* Boot navigation interception + initial scan (throws if already started).
|
|
@@ -10744,15 +10906,17 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
10744
10906
|
registerIsland(state, island);
|
|
10745
10907
|
},
|
|
10746
10908
|
/**
|
|
10747
|
-
* Process a navigation to `path` (fetch then swap; full reload on error).
|
|
10909
|
+
* Process a navigation to `path` (fetch then swap; full reload on error). An optional
|
|
10910
|
+
* per-call `scroll` override flows through to the swap's scroll intent.
|
|
10748
10911
|
*
|
|
10749
10912
|
* @param path - The target path to navigate to.
|
|
10913
|
+
* @param options - Optional per-navigation overrides (e.g. `{ scroll: "preserve" }`).
|
|
10750
10914
|
* @example
|
|
10751
10915
|
* kernel.processNav("/about");
|
|
10752
10916
|
*/
|
|
10753
|
-
processNav(path) {
|
|
10917
|
+
processNav(path, options) {
|
|
10754
10918
|
if (typeof document === "undefined") return;
|
|
10755
|
-
navigate(path).catch(() => {});
|
|
10919
|
+
navigate(path, true, void 0, options?.scroll).catch(() => {});
|
|
10756
10920
|
},
|
|
10757
10921
|
/**
|
|
10758
10922
|
* Scan the swap region and mount islands for matching elements.
|
|
@@ -10844,6 +11008,255 @@ function disposeSpa() {
|
|
|
10844
11008
|
}
|
|
10845
11009
|
}
|
|
10846
11010
|
//#endregion
|
|
11011
|
+
//#region src/plugins/spa/channel.ts
|
|
11012
|
+
/** Default keepalive interval (ms). */
|
|
11013
|
+
const DEFAULT_KEEPALIVE_MS = 3e4;
|
|
11014
|
+
/** Default first reconnect delay (ms). */
|
|
11015
|
+
const DEFAULT_RECONNECT_BASE_MS = 500;
|
|
11016
|
+
/** Default reconnect ceiling (ms). */
|
|
11017
|
+
const DEFAULT_RECONNECT_MAX_MS = 8e3;
|
|
11018
|
+
/**
|
|
11019
|
+
* Default frame parser — `JSON.parse`, dropping frames that fail to parse.
|
|
11020
|
+
*
|
|
11021
|
+
* @param raw - The raw text frame.
|
|
11022
|
+
* @returns The parsed value, or undefined on a parse error.
|
|
11023
|
+
* @example
|
|
11024
|
+
* const msg = defaultParse<BoardPatch>('{"type":"x"}');
|
|
11025
|
+
*/
|
|
11026
|
+
function defaultParse(raw) {
|
|
11027
|
+
try {
|
|
11028
|
+
return JSON.parse(raw);
|
|
11029
|
+
} catch {
|
|
11030
|
+
return;
|
|
11031
|
+
}
|
|
11032
|
+
}
|
|
11033
|
+
/**
|
|
11034
|
+
* Create a live realtime channel — a single self-healing WebSocket bound to one channel
|
|
11035
|
+
* `id` at a time, with refcounted subscription, auto-reconnect, an optional keepalive, a
|
|
11036
|
+
* pre-seed buffer, and an optimistic local-echo path. See the file header for the model.
|
|
11037
|
+
*
|
|
11038
|
+
* @param options - The channel configuration (see {@link ChannelOptions}).
|
|
11039
|
+
* @returns The {@link Channel} control surface.
|
|
11040
|
+
* @example
|
|
11041
|
+
* const board = createChannel<BoardPatch>({ url: (id) => `wss://host/ws/board/${id}` });
|
|
11042
|
+
*/
|
|
11043
|
+
function createChannel(options) {
|
|
11044
|
+
const parse = options.parse ?? defaultParse;
|
|
11045
|
+
const reconnectCfg = options.reconnect === false ? void 0 : options.reconnect ?? {};
|
|
11046
|
+
const baseMs = reconnectCfg?.baseMs ?? DEFAULT_RECONNECT_BASE_MS;
|
|
11047
|
+
const maxMs = reconnectCfg?.maxMs ?? DEFAULT_RECONNECT_MAX_MS;
|
|
11048
|
+
const keepEveryMs = options.keepAlive?.everyMs ?? DEFAULT_KEEPALIVE_MS;
|
|
11049
|
+
/** The live socket, or undefined when disconnected. */
|
|
11050
|
+
let socket;
|
|
11051
|
+
/** The id we WANT to stay subscribed to (drives reconnect); undefined ⇒ intentionally off. */
|
|
11052
|
+
let desiredId;
|
|
11053
|
+
/** Whether the consumer has seeded — gates live delivery vs. buffering. */
|
|
11054
|
+
let seeded = false;
|
|
11055
|
+
/** Frames received before {@link Channel.seed}, replayed in arrival order on seed. */
|
|
11056
|
+
let buffer = [];
|
|
11057
|
+
/** Message handlers fanned out on every delivered frame. */
|
|
11058
|
+
const handlers = /* @__PURE__ */ new Set();
|
|
11059
|
+
/** Consecutive closes since the last clean open — drives the backoff delay. */
|
|
11060
|
+
let reconnectAttempts = 0;
|
|
11061
|
+
/** Pending reconnect timer handle, or undefined when none is scheduled. */
|
|
11062
|
+
let reconnectTimer;
|
|
11063
|
+
/** Keepalive interval handle, or undefined when off. */
|
|
11064
|
+
let keepAliveTimer;
|
|
11065
|
+
/**
|
|
11066
|
+
* Fan a message out to every handler now, or queue it when buffering before seed.
|
|
11067
|
+
*
|
|
11068
|
+
* @param message - The message to deliver or buffer.
|
|
11069
|
+
* @example
|
|
11070
|
+
* deliver(patch);
|
|
11071
|
+
*/
|
|
11072
|
+
function deliver(message) {
|
|
11073
|
+
if (options.bufferUntilSeed && !seeded) {
|
|
11074
|
+
buffer.push(message);
|
|
11075
|
+
return;
|
|
11076
|
+
}
|
|
11077
|
+
for (const handler of handlers) handler(message);
|
|
11078
|
+
}
|
|
11079
|
+
/**
|
|
11080
|
+
* Parse + route an incoming frame, dropping the keepalive reply and unparseable frames.
|
|
11081
|
+
*
|
|
11082
|
+
* @param event - The socket message event.
|
|
11083
|
+
* @example
|
|
11084
|
+
* socket.addEventListener("message", dispatch);
|
|
11085
|
+
*/
|
|
11086
|
+
function dispatch(event) {
|
|
11087
|
+
if (typeof event.data !== "string") return;
|
|
11088
|
+
if (options.keepAlive?.ignore !== void 0 && event.data === options.keepAlive.ignore) return;
|
|
11089
|
+
const message = parse(event.data);
|
|
11090
|
+
if (message !== void 0) deliver(message);
|
|
11091
|
+
}
|
|
11092
|
+
/**
|
|
11093
|
+
* Send the keepalive frame when the socket is open (no-op otherwise).
|
|
11094
|
+
*
|
|
11095
|
+
* @example
|
|
11096
|
+
* ping();
|
|
11097
|
+
*/
|
|
11098
|
+
function ping() {
|
|
11099
|
+
if (options.keepAlive && socket?.readyState === WebSocket.OPEN) socket.send(options.keepAlive.send);
|
|
11100
|
+
}
|
|
11101
|
+
/**
|
|
11102
|
+
* Open the live socket for {@link desiredId}, wiring delivery + auto-reconnect. Shared by
|
|
11103
|
+
* the first connect and every backoff retry.
|
|
11104
|
+
*
|
|
11105
|
+
* @example
|
|
11106
|
+
* openSocket();
|
|
11107
|
+
*/
|
|
11108
|
+
function openSocket() {
|
|
11109
|
+
const id = desiredId;
|
|
11110
|
+
if (id === void 0 || typeof WebSocket === "undefined") return;
|
|
11111
|
+
const ws = new WebSocket(options.url(id));
|
|
11112
|
+
socket = ws;
|
|
11113
|
+
ws.addEventListener("message", dispatch);
|
|
11114
|
+
ws.addEventListener("open", () => {
|
|
11115
|
+
reconnectAttempts = 0;
|
|
11116
|
+
});
|
|
11117
|
+
ws.addEventListener("close", () => {
|
|
11118
|
+
if (socket !== ws || desiredId === void 0) return;
|
|
11119
|
+
socket = void 0;
|
|
11120
|
+
if (reconnectCfg) scheduleReconnect();
|
|
11121
|
+
});
|
|
11122
|
+
}
|
|
11123
|
+
/**
|
|
11124
|
+
* Schedule a reconnect to {@link desiredId} with exponential backoff (capped), unless one
|
|
11125
|
+
* is already pending or reconnection is off.
|
|
11126
|
+
*
|
|
11127
|
+
* @example
|
|
11128
|
+
* scheduleReconnect();
|
|
11129
|
+
*/
|
|
11130
|
+
function scheduleReconnect() {
|
|
11131
|
+
if (reconnectTimer !== void 0 || desiredId === void 0) return;
|
|
11132
|
+
const delay = Math.min(baseMs * 2 ** reconnectAttempts, maxMs);
|
|
11133
|
+
reconnectAttempts += 1;
|
|
11134
|
+
reconnectTimer = setTimeout(() => {
|
|
11135
|
+
reconnectTimer = void 0;
|
|
11136
|
+
if (desiredId !== void 0) openSocket();
|
|
11137
|
+
}, delay);
|
|
11138
|
+
}
|
|
11139
|
+
/**
|
|
11140
|
+
* Tear down the live socket + timers and re-arm the pre-seed buffer, keeping handlers so a
|
|
11141
|
+
* later connect resumes delivering to them. Clearing {@link desiredId} first marks the drop
|
|
11142
|
+
* intentional (no reconnect).
|
|
11143
|
+
*
|
|
11144
|
+
* @example
|
|
11145
|
+
* teardown();
|
|
11146
|
+
*/
|
|
11147
|
+
function teardown() {
|
|
11148
|
+
desiredId = void 0;
|
|
11149
|
+
if (reconnectTimer !== void 0) {
|
|
11150
|
+
clearTimeout(reconnectTimer);
|
|
11151
|
+
reconnectTimer = void 0;
|
|
11152
|
+
}
|
|
11153
|
+
if (keepAliveTimer !== void 0) {
|
|
11154
|
+
clearInterval(keepAliveTimer);
|
|
11155
|
+
keepAliveTimer = void 0;
|
|
11156
|
+
}
|
|
11157
|
+
if (socket) {
|
|
11158
|
+
socket.removeEventListener("message", dispatch);
|
|
11159
|
+
socket.close();
|
|
11160
|
+
socket = void 0;
|
|
11161
|
+
}
|
|
11162
|
+
seeded = false;
|
|
11163
|
+
buffer = [];
|
|
11164
|
+
}
|
|
11165
|
+
/**
|
|
11166
|
+
* Open (or switch) the socket to `id`, re-arming the keepalive + pre-seed buffer.
|
|
11167
|
+
*
|
|
11168
|
+
* @param id - The channel id to connect to.
|
|
11169
|
+
* @example
|
|
11170
|
+
* connect("board-123");
|
|
11171
|
+
*/
|
|
11172
|
+
function connect(id) {
|
|
11173
|
+
const alive = socket?.readyState === WebSocket.CONNECTING || socket?.readyState === WebSocket.OPEN;
|
|
11174
|
+
if (desiredId === id && alive) return;
|
|
11175
|
+
teardown();
|
|
11176
|
+
desiredId = id;
|
|
11177
|
+
seeded = false;
|
|
11178
|
+
buffer = [];
|
|
11179
|
+
reconnectAttempts = 0;
|
|
11180
|
+
openSocket();
|
|
11181
|
+
if (options.keepAlive) keepAliveTimer = setInterval(ping, keepEveryMs);
|
|
11182
|
+
}
|
|
11183
|
+
return {
|
|
11184
|
+
/**
|
|
11185
|
+
* Subscribe to `id`, connecting (or switching) the single active socket. Refcounted.
|
|
11186
|
+
*
|
|
11187
|
+
* @param id - The channel id to subscribe to.
|
|
11188
|
+
* @param handler - Called with each delivered message.
|
|
11189
|
+
* @returns A function that removes this handler (last out disconnects).
|
|
11190
|
+
* @example
|
|
11191
|
+
* const off = board.subscribe(boardId, (patch) => applyPatch(ctx, patch));
|
|
11192
|
+
*/
|
|
11193
|
+
subscribe(id, handler) {
|
|
11194
|
+
handlers.add(handler);
|
|
11195
|
+
connect(id);
|
|
11196
|
+
return () => {
|
|
11197
|
+
handlers.delete(handler);
|
|
11198
|
+
if (handlers.size === 0) teardown();
|
|
11199
|
+
};
|
|
11200
|
+
},
|
|
11201
|
+
/**
|
|
11202
|
+
* Mark `id` seeded and flush the pre-seed buffer (no-op unless it is the active id).
|
|
11203
|
+
*
|
|
11204
|
+
* @param id - The channel id whose buffer to flush.
|
|
11205
|
+
* @example
|
|
11206
|
+
* board.seed(boardId);
|
|
11207
|
+
*/
|
|
11208
|
+
seed(id) {
|
|
11209
|
+
if (id !== desiredId || seeded) return;
|
|
11210
|
+
seeded = true;
|
|
11211
|
+
const queued = buffer;
|
|
11212
|
+
buffer = [];
|
|
11213
|
+
for (const message of queued) for (const handler of handlers) handler(message);
|
|
11214
|
+
},
|
|
11215
|
+
/**
|
|
11216
|
+
* Deliver a message to the local handlers immediately (optimistic echo) for the active id.
|
|
11217
|
+
*
|
|
11218
|
+
* @param id - The channel id (ignored unless it is the active id).
|
|
11219
|
+
* @param message - The message to deliver locally now.
|
|
11220
|
+
* @example
|
|
11221
|
+
* board.deliverLocal(boardId, { type: "issue.moved", issueId });
|
|
11222
|
+
*/
|
|
11223
|
+
deliverLocal(id, message) {
|
|
11224
|
+
if (id !== desiredId) return;
|
|
11225
|
+
for (const handler of handlers) handler(message);
|
|
11226
|
+
},
|
|
11227
|
+
/**
|
|
11228
|
+
* Send a raw text frame over the live socket (no-op when not open).
|
|
11229
|
+
*
|
|
11230
|
+
* @param raw - The frame to send.
|
|
11231
|
+
* @example
|
|
11232
|
+
* board.send("ping");
|
|
11233
|
+
*/
|
|
11234
|
+
send(raw) {
|
|
11235
|
+
if (socket?.readyState === WebSocket.OPEN) socket.send(raw);
|
|
11236
|
+
},
|
|
11237
|
+
/**
|
|
11238
|
+
* The currently-subscribed channel id, or undefined when disconnected.
|
|
11239
|
+
*
|
|
11240
|
+
* @returns The active id, or undefined.
|
|
11241
|
+
* @example
|
|
11242
|
+
* board.current();
|
|
11243
|
+
*/
|
|
11244
|
+
current() {
|
|
11245
|
+
return desiredId;
|
|
11246
|
+
},
|
|
11247
|
+
/**
|
|
11248
|
+
* Close the socket, cancel reconnect, and clear all handlers.
|
|
11249
|
+
*
|
|
11250
|
+
* @example
|
|
11251
|
+
* board.disconnect();
|
|
11252
|
+
*/
|
|
11253
|
+
disconnect() {
|
|
11254
|
+
handlers.clear();
|
|
11255
|
+
teardown();
|
|
11256
|
+
}
|
|
11257
|
+
};
|
|
11258
|
+
}
|
|
11259
|
+
//#endregion
|
|
10847
11260
|
//#region src/plugins/spa/lazy-embed.ts
|
|
10848
11261
|
/**
|
|
10849
11262
|
* @file `lazyEmbed` island — activates the static embed facades emitted by the
|
|
@@ -13257,6 +13670,7 @@ Object.defineProperty(exports, "cloudflareBindings", {
|
|
|
13257
13670
|
});
|
|
13258
13671
|
exports.contentPlugin = contentPlugin;
|
|
13259
13672
|
exports.createApp = createApp;
|
|
13673
|
+
exports.createChannel = createChannel;
|
|
13260
13674
|
exports.createIsland = createIsland;
|
|
13261
13675
|
exports.createPlugin = createPlugin;
|
|
13262
13676
|
exports.createUrls = createUrls;
|