@devframes/hub 0.9.0-beta.8 → 0.9.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.
@@ -134,9 +134,30 @@ interface DockPanelStorage {
134
134
  top: number;
135
135
  left: number;
136
136
  position: 'left' | 'right' | 'bottom' | 'top';
137
- open: boolean;
138
137
  inactiveTimeout: number;
139
138
  }
139
+ /**
140
+ * Per-tab UI state of the dock panel, distinct from the browser-shared geometry
141
+ * in {@link DockPanelStorage}. A viewer persists this to `sessionStorage` so a
142
+ * reload (or the RPC auth handshake that follows one) restores the panel to
143
+ * exactly where the developer left it — which dock was open, and the
144
+ * address-bar route of that dock's iframe. Kept `sessionStorage`-scoped (not
145
+ * `localStorage`) because it is per-tab navigation state: two tabs open against
146
+ * the same dev server each keep their own selection rather than fighting over a
147
+ * shared one. Every field is serializable.
148
+ */
149
+ interface DockSessionStorage {
150
+ /** Whether the dock panel is open. */
151
+ open: boolean;
152
+ /** The currently selected dock entry id, or `null` when nothing is open. */
153
+ selectedDockId: string | null;
154
+ /**
155
+ * The live address-bar URL of the selected iframe dock, restored as that
156
+ * iframe's boot `src` on the next load so the deep-linked route survives a
157
+ * reload. `null` when the selected dock has no iframe route to remember.
158
+ */
159
+ selectedDockRoute: string | null;
160
+ }
140
161
  type DockClientType = 'embedded' | 'standalone';
141
162
  interface DocksContext extends DevframeRpcContext {
142
163
  /**
@@ -196,12 +217,29 @@ interface WhenClauseContext {
196
217
  type DevframeClientContext = DocksContext;
197
218
  interface DocksPanelContext {
198
219
  store: DockPanelStorage;
220
+ /**
221
+ * Per-tab session UI state — whether the panel is open, which dock is
222
+ * selected, and that dock's iframe route. Restored across reloads (and the
223
+ * auth handshake that follows one) by a viewer that persists it to
224
+ * `sessionStorage`.
225
+ */
226
+ session: DockSessionStorage;
199
227
  isDragging: boolean;
200
228
  isResizing: boolean;
201
229
  readonly isVertical: boolean;
230
+ /**
231
+ * Claim the persisted {@link DockSessionStorage.selectedDockRoute boot route} for a dock
232
+ * id, once. Returns the saved address-bar URL only for the dock that was
233
+ * selected before the last reload — and only on the first call for it — so a
234
+ * restored iframe boots deep-linked while a later switch to a different dock
235
+ * (whose live route isn't reflected in {@link DockSessionStorage.selectedDockRoute} yet)
236
+ * doesn't reuse the stale value. Returns `null` otherwise.
237
+ */
238
+ consumeBootRoute?: (id: string) => string | null;
202
239
  }
203
240
  interface DocksEntriesContext {
204
241
  selectedId: string | null;
242
+ selectedRoute: string | null;
205
243
  readonly selected: DevframeDockEntry | null;
206
244
  entries: DevframeDockEntry[];
207
245
  entryToStateMap: Map<string, DockEntryState>;
@@ -346,6 +384,79 @@ declare function resolveDockUrl(url: string, connection: DevframeConnection): st
346
384
  /** Resolve URL-backed dock icons while preserving Iconify names and data URLs. */
347
385
  declare function resolveDockIcon(icon: DevframeDockEntryIcon, connection: DevframeConnection): DevframeDockEntryIcon;
348
386
  //#endregion
387
+ //#region src/client/frame-location.d.ts
388
+ /**
389
+ * Live location tracking for an iframe dock.
390
+ *
391
+ * An iframe dock's address bar — and the route persisted from it into
392
+ * `DockSessionStorage.selectedDockRoute` — has to follow wherever the embedded
393
+ * app actually goes. The `load` event only covers whole-document navigations, so
394
+ * an SPA router moving between routes with `history.pushState()` leaves both
395
+ * showing the URL the frame booted with.
396
+ *
397
+ * {@link watchFrameLocation} closes that gap for a same-origin frame, reporting
398
+ * `location.href` on every navigation it can observe:
399
+ *
400
+ * - `popstate` / `hashchange` — back/forward and hash routing;
401
+ * - `history.pushState`/`replaceState`, wrapped in place (and restored on
402
+ * dispose) because those two fire no event of their own;
403
+ * - the [Navigation API](https://developer.mozilla.org/en-US/docs/Web/API/Navigation_API)'s
404
+ * `currententrychange` where it exists, which reports post-commit and so also
405
+ * catches what a wrapper structurally cannot — a router holding a reference to
406
+ * `pushState` captured before this watch attached;
407
+ * - `load`, which also re-subscribes: a document navigation swaps the frame's
408
+ * `history`/`navigation` objects, so the previous subscription dies with them.
409
+ *
410
+ * These sources overlap deliberately rather than being chosen between, since a
411
+ * report is deduped against the last href — the cost of hearing the same
412
+ * navigation twice is nothing, and the cost of missing one is a stale route.
413
+ *
414
+ * A cross-origin frame reports nothing — its location is unreadable by design,
415
+ * so the caller keeps the last URL it knew.
416
+ */
417
+ /** The minimal `history` surface the watcher wraps. Injectable for tests. */
418
+ interface FrameLocationHistory {
419
+ pushState: (...args: any[]) => void;
420
+ replaceState: (...args: any[]) => void;
421
+ }
422
+ /** The minimal frame-window surface the watcher reads and subscribes to. */
423
+ interface FrameLocationWindow {
424
+ readonly location: {
425
+ readonly href: string;
426
+ };
427
+ readonly history?: FrameLocationHistory;
428
+ /** Present only where the Navigation API ships. */
429
+ readonly navigation?: {
430
+ addEventListener: (type: 'currententrychange', listener: () => void) => void;
431
+ removeEventListener: (type: 'currententrychange', listener: () => void) => void;
432
+ };
433
+ addEventListener: (type: 'popstate' | 'hashchange', listener: () => void) => void;
434
+ removeEventListener: (type: 'popstate' | 'hashchange', listener: () => void) => void;
435
+ }
436
+ /** The minimal iframe surface the watcher needs. Injectable for tests. */
437
+ interface FrameLocationTarget {
438
+ readonly contentWindow: FrameLocationWindow | null;
439
+ addEventListener: (type: 'load', listener: () => void) => void;
440
+ removeEventListener: (type: 'load', listener: () => void) => void;
441
+ }
442
+ interface WatchFrameLocationOptions {
443
+ /** The live iframe whose location is tracked. */
444
+ iframe: FrameLocationTarget;
445
+ /** Called with the frame's `location.href` on each observed change. */
446
+ onChange: (href: string) => void;
447
+ /**
448
+ * The href the caller already shows, so an initial report is only made when
449
+ * the frame is somewhere else (a frame re-attached mid-session, say).
450
+ */
451
+ initial?: string;
452
+ }
453
+ /**
454
+ * Track a frame's location until the returned disposer is called. Disposing
455
+ * detaches every listener and restores any wrapped `history` method, leaving the
456
+ * embedded page as it was found.
457
+ */
458
+ declare function watchFrameLocation(options: WatchFrameLocationOptions): () => void;
459
+ //#endregion
349
460
  //#region src/client/frame-nav.d.ts
350
461
  /**
351
462
  * Shared-iframe soft navigation — the viewer-side half of a host↔iframe
@@ -587,4 +698,4 @@ declare function parseRemoteConnection(input?: string): RemoteConnectionInfo | n
587
698
  */
588
699
  declare function connectRemoteDevframe(options?: ConnectRemoteDevframeOptions): Promise<DevframeRpcClient>;
589
700
  //#endregion
590
- export { CLIENT_CONTEXT_KEY, CommandsContext, ConnectRemoteDevframeOptions, CreateDockRenderersContextOptions, DevframeClientContext, DevframeClientHost, DevframeClientHostOptions, type DevframeClientRpcHost, DockClientScriptContext, DockClientType, DockEntryState, DockEntryStateEvents, DockPanelStorage, DockRegistration, DockRenderer, DockRendererInstance, DockRendererManifest, DockRendererMountOptions, DockRendererMountResult, DockRenderersContext, DocksConnectionContext, DocksContext, DocksEntriesContext, DocksPanelContext, FRAME_NAV_CHANNEL, FRAME_NAV_VERSION, FrameNavClient, FrameNavClientOptions, FrameNavEnvelope, FrameNavFrameMessage, FrameNavHostMessage, FrameNavHostPayload, FrameNavListenTarget, FrameTab, MessagesClientOptions, type RpcClientEvents, WhenClauseContext, attachFrameNavClient, buildRemoteDevframeUrl, connectRemoteDevframe, createDevframeClientHost, createDockRenderersContext, createMessagesClient, getDevframeClientContext, parseRemoteConnection, resolveDockIcon, resolveDockUrl, setDevframeClientContext, stripRemoteConnectionFromUrl };
701
+ export { CLIENT_CONTEXT_KEY, CommandsContext, ConnectRemoteDevframeOptions, CreateDockRenderersContextOptions, DevframeClientContext, DevframeClientHost, DevframeClientHostOptions, type DevframeClientRpcHost, DockClientScriptContext, DockClientType, DockEntryState, DockEntryStateEvents, DockPanelStorage, DockRegistration, DockRenderer, DockRendererInstance, DockRendererManifest, DockRendererMountOptions, DockRendererMountResult, DockRenderersContext, DockSessionStorage, DocksConnectionContext, DocksContext, DocksEntriesContext, DocksPanelContext, FRAME_NAV_CHANNEL, FRAME_NAV_VERSION, FrameLocationHistory, FrameLocationTarget, FrameLocationWindow, FrameNavClient, FrameNavClientOptions, FrameNavEnvelope, FrameNavFrameMessage, FrameNavHostMessage, FrameNavHostPayload, FrameNavListenTarget, FrameTab, MessagesClientOptions, type RpcClientEvents, WatchFrameLocationOptions, WhenClauseContext, attachFrameNavClient, buildRemoteDevframeUrl, connectRemoteDevframe, createDevframeClientHost, createDockRenderersContext, createMessagesClient, getDevframeClientContext, parseRemoteConnection, resolveDockIcon, resolveDockUrl, setDevframeClientContext, stripRemoteConnectionFromUrl, watchFrameLocation };
@@ -69,6 +69,88 @@ function resolveDockIcon(icon, connection) {
69
69
  };
70
70
  }
71
71
  //#endregion
72
+ //#region src/client/frame-location.ts
73
+ /**
74
+ * Track a frame's location until the returned disposer is called. Disposing
75
+ * detaches every listener and restores any wrapped `history` method, leaving the
76
+ * embedded page as it was found.
77
+ */
78
+ function watchFrameLocation(options) {
79
+ const { iframe, onChange } = options;
80
+ let lastReported = options.initial;
81
+ let detach;
82
+ let disposed = false;
83
+ /**
84
+ * The frame's window, or `null` when its location can't be read. Touching
85
+ * `location.href` is the cross-origin probe — it throws for a foreign
86
+ * document, where there is nothing to observe.
87
+ */
88
+ function readableWindow() {
89
+ try {
90
+ const win = iframe.contentWindow;
91
+ return win && typeof win.location.href === "string" ? win : null;
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+ function report() {
97
+ const href = readableWindow()?.location.href;
98
+ if (!href || href === "about:blank" || href === lastReported) return;
99
+ lastReported = href;
100
+ onChange(href);
101
+ }
102
+ function subscribe() {
103
+ const win = readableWindow();
104
+ if (!win) return;
105
+ const listeners = [];
106
+ win.addEventListener("popstate", report);
107
+ win.addEventListener("hashchange", report);
108
+ listeners.push(() => {
109
+ win.removeEventListener("popstate", report);
110
+ win.removeEventListener("hashchange", report);
111
+ });
112
+ const { navigation, history } = win;
113
+ if (navigation) {
114
+ navigation.addEventListener("currententrychange", report);
115
+ listeners.push(() => navigation.removeEventListener("currententrychange", report));
116
+ }
117
+ if (history) {
118
+ const originals = {
119
+ pushState: history.pushState,
120
+ replaceState: history.replaceState
121
+ };
122
+ for (const method of ["pushState", "replaceState"]) history[method] = function(...args) {
123
+ originals[method].apply(this, args);
124
+ report();
125
+ };
126
+ listeners.push(() => {
127
+ history.pushState = originals.pushState;
128
+ history.replaceState = originals.replaceState;
129
+ });
130
+ }
131
+ detach = () => {
132
+ for (const off of listeners) off();
133
+ };
134
+ }
135
+ function onLoad() {
136
+ if (disposed) return;
137
+ detach?.();
138
+ detach = void 0;
139
+ subscribe();
140
+ report();
141
+ }
142
+ iframe.addEventListener("load", onLoad);
143
+ subscribe();
144
+ report();
145
+ return () => {
146
+ if (disposed) return;
147
+ disposed = true;
148
+ iframe.removeEventListener("load", onLoad);
149
+ detach?.();
150
+ detach = void 0;
151
+ };
152
+ }
153
+ //#endregion
72
154
  //#region src/client/frame-nav.ts
73
155
  /**
74
156
  * Shared-iframe soft navigation — the viewer-side half of a host↔iframe
@@ -462,7 +544,7 @@ async function createDevframeClientHost(options = {}) {
462
544
  when: { get context() {
463
545
  return {
464
546
  clientType,
465
- dockOpen: panel.store.open,
547
+ dockOpen: panel.session.open,
466
548
  paletteOpen: commands.paletteOpen,
467
549
  dockSelectedId: selectedId ?? ""
468
550
  };
@@ -584,7 +666,11 @@ async function createDevframeClientHost(options = {}) {
584
666
  }
585
667
  docks.entries = entries;
586
668
  docks.groupedEntries = groupByCategory(entries, categoryOrder);
587
- if (selectedId && !entryToStateMap.has(selectedId)) selectedId = null;
669
+ if (selectedId && !entryToStateMap.has(selectedId)) {
670
+ selectedId = null;
671
+ panel.session.selectedDockId = null;
672
+ panel.session.selectedDockRoute = null;
673
+ }
588
674
  }
589
675
  function createDocksContext() {
590
676
  return {
@@ -594,6 +680,12 @@ async function createDevframeClientHost(options = {}) {
594
680
  set selectedId(id) {
595
681
  switchEntry(id);
596
682
  },
683
+ get selectedRoute() {
684
+ return panel.session.selectedDockRoute;
685
+ },
686
+ set selectedRoute(route) {
687
+ panel.session.selectedDockRoute = route;
688
+ },
597
689
  get selected() {
598
690
  return selectedId && entryToStateMap.get(selectedId)?.entryMeta || null;
599
691
  },
@@ -638,6 +730,8 @@ async function createDevframeClientHost(options = {}) {
638
730
  if (next !== null && !entryToStateMap.has(next)) return false;
639
731
  const previous = selectedId;
640
732
  selectedId = next;
733
+ panel.session.selectedDockId = next;
734
+ if (next === null || entryToStateMap.get(next)?.entryMeta.type !== "iframe") panel.session.selectedDockRoute = null;
641
735
  if (previous) entryToStateMap.get(previous)?.events.emit("entry:deactivated");
642
736
  if (next) entryToStateMap.get(next)?.events.emit("entry:activated");
643
737
  return true;
@@ -734,11 +828,15 @@ function createPanelContext(clientType) {
734
828
  top: 0,
735
829
  left: 0,
736
830
  position: "right",
737
- open: clientType === "standalone",
738
831
  inactiveTimeout: 0
739
832
  };
740
833
  return {
741
834
  store,
835
+ session: {
836
+ open: clientType === "standalone",
837
+ selectedDockId: null,
838
+ selectedDockRoute: null
839
+ },
742
840
  isDragging: false,
743
841
  isResizing: false,
744
842
  get isVertical() {
@@ -869,4 +967,4 @@ async function connectRemoteDevframe(options = {}) {
869
967
  });
870
968
  }
871
969
  //#endregion
872
- export { CLIENT_CONTEXT_KEY, FRAME_NAV_CHANNEL, FRAME_NAV_VERSION, attachFrameNavClient, buildRemoteDevframeUrl, connectRemoteDevframe, createDevframeClientHost, createDockRenderersContext, createMessagesClient, getDevframeClientContext, parseRemoteConnection, resolveDockIcon, resolveDockUrl, setDevframeClientContext, stripRemoteConnectionFromUrl };
970
+ export { CLIENT_CONTEXT_KEY, FRAME_NAV_CHANNEL, FRAME_NAV_VERSION, attachFrameNavClient, buildRemoteDevframeUrl, connectRemoteDevframe, createDevframeClientHost, createDockRenderersContext, createMessagesClient, getDevframeClientContext, parseRemoteConnection, resolveDockIcon, resolveDockUrl, setDevframeClientContext, stripRemoteConnectionFromUrl, watchFrameLocation };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@devframes/hub",
3
3
  "type": "module",
4
- "version": "0.9.0-beta.8",
4
+ "version": "0.9.0",
5
5
  "description": "Hub layer that orchestrates devframe docks, terminals, messages, and commands on any host.",
6
6
  "author": "Anthony Fu <anthonyfu117@hotmail.com>",
7
7
  "license": "MIT",
@@ -34,7 +34,7 @@
34
34
  "dist"
35
35
  ],
36
36
  "peerDependencies": {
37
- "devframe": "0.9.0-beta.8"
37
+ "devframe": "0.9.0"
38
38
  },
39
39
  "dependencies": {
40
40
  "@standard-schema/spec": "^1.1.0",
@@ -52,7 +52,7 @@
52
52
  "mlly": "^1.8.2",
53
53
  "tsdown": "^0.22.14",
54
54
  "valibot": "^1.4.2",
55
- "devframe": "0.9.0-beta.8"
55
+ "devframe": "0.9.0"
56
56
  },
57
57
  "scripts": {
58
58
  "build": "tsdown",