@asteby/metacore-runtime-react 9.2.0 → 10.0.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.
@@ -0,0 +1,227 @@
1
+ // hotswap-reload-policy — closes the last piece of RFC-0001 D4 ("zero-polling
2
+ // hot-swap") on the client. The {@link useManifestHotSwapSubscriber} hook
3
+ // already invalidates the metadata cache when the kernel announces a manifest
4
+ // change, but the federation container of an already-mounted addon keeps the
5
+ // old code in memory. This module picks one of three policies for forcing the
6
+ // new code to take effect:
7
+ //
8
+ // 1. `"rekey"` — the default, **recommended for most apps**. Maintains a
9
+ // reactive map `addonKey → hashShort` that the host wires into
10
+ // `<AddonRoute version={...} />`. When a swap arrives, the hash flips,
11
+ // React unmounts and remounts the addon subtree, which causes the
12
+ // federation loader to re-fetch `remoteEntry.js` (cache-busted via
13
+ // {@link withVersionParam}) and re-evaluate the exposed module. State
14
+ // inside the addon is lost — **intentional**, because the code version
15
+ // changed and stale closures over old props/state would be a footgun.
16
+ //
17
+ // 2. `"page-reload"` — `window.location.reload()`. Opt-in escape hatch
18
+ // for immersive addons with critical state (POS with an order in
19
+ // progress, kitchen-display with partial confirmations). Pair with
20
+ // `onBeforeReload` to surface an "unsaved changes" prompt before
21
+ // blowing the page away. Returning `false` from `onBeforeReload`
22
+ // cancels the reload.
23
+ //
24
+ // 3. `"manual"` — the hook only invokes the host-provided `onSwap`
25
+ // callback. The host decides what to do (e.g. show a toast: "New
26
+ // version available — reload when ready"). No automatic remount, no
27
+ // reload. The `addonVersionMap` is still updated so a host that wires
28
+ // `<AddonRoute version=...>` later in the lifecycle picks up the new
29
+ // hash on demand.
30
+ //
31
+ // ## Wiring example
32
+ //
33
+ // ```tsx
34
+ // // Host shell (4-5 line wire-up):
35
+ // const ws = useWebSocket()
36
+ // useManifestHotSwapSubscriber(ws) // invalidates metadata
37
+ // const { addonVersionMap } = useHotSwapReload({ strategy: "rekey" })
38
+ // // …in your router:
39
+ // <AddonRoute version={addonVersionMap[addonKey]} shell={renderShell}>
40
+ // <AddonLoader scope={addonKey} url={remoteEntryUrl} api={api} />
41
+ // </AddonRoute>
42
+ // ```
43
+ //
44
+ // ## Federation runtime caveat
45
+ //
46
+ // The `"rekey"` strategy re-fetches `remoteEntry.js` with a `?v=<hash8>` query
47
+ // suffix (see {@link withVersionParam}). For the new container to replace the
48
+ // old one, the federation loader **must** `delete window[Container]` before
49
+ // loading the new script — otherwise the cached container object short-
50
+ // circuits the loader and you get `Container already registered` style errors
51
+ // or, worse, the old code silently keeps running. This module exports
52
+ // {@link clearFederationContainer} for that purpose; the host's federation
53
+ // loader should call it from its `onSwap` hook.
54
+ //
55
+ // We deliberately keep the `delete window[Container]` side-effect OUT of this
56
+ // module's default behaviour. Some federation runtimes (vite-plugin-federation
57
+ // in dev, webpack 5 with `runtime: false`) wrap the container in a `Proxy`
58
+ // that mutates internal state on every access; blindly deleting it from
59
+ // here would race against any unmounting consumer that still holds a
60
+ // reference. Hosts that hit `Container already registered` should call
61
+ // `clearFederationContainer(scope)` from `onSwap` as documented below.
62
+ import { useMemo, useRef, useState } from 'react';
63
+ import { useManifestHotSwapSubscriber, } from './manifest-hotswap-subscriber';
64
+ /**
65
+ * Pure (testable) implementation of the swap handler. Decides the action
66
+ * given a message + config + deps, applies side effects via `deps`, and
67
+ * returns the action it took so callers can fire telemetry.
68
+ *
69
+ * Exported for unit tests; the React hook below composes it with React
70
+ * state. Hosts that want to drive the policy from a non-React context
71
+ * (e.g. a vanilla web component shell) can call this directly.
72
+ */
73
+ export async function applyHotSwapReload(message, config, deps) {
74
+ const strategy = config.strategy ?? 'rekey';
75
+ const addonKey = message.payload?.addonKey;
76
+ if (!addonKey)
77
+ return 'noop';
78
+ const shortHash = shortenHash(message.payload?.newHash);
79
+ if (strategy === 'manual') {
80
+ if (shortHash) {
81
+ deps.setVersionMap((m) => ({ ...m, [addonKey]: shortHash }));
82
+ }
83
+ config.onSwap?.(message, 'manual');
84
+ return 'manual';
85
+ }
86
+ if (config.onBeforeReload) {
87
+ const proceed = await Promise.resolve(config.onBeforeReload(message));
88
+ if (!proceed) {
89
+ config.onSwap?.(message, 'cancelled');
90
+ return 'cancelled';
91
+ }
92
+ }
93
+ if (strategy === 'page-reload') {
94
+ config.onSwap?.(message, 'page-reload');
95
+ const reload = deps.reload ??
96
+ (typeof window !== 'undefined'
97
+ ? () => window.location.reload()
98
+ : undefined);
99
+ if (reload) {
100
+ // Defer so any setState before us commits before we tear down.
101
+ queueMicrotask(reload);
102
+ }
103
+ return 'page-reload';
104
+ }
105
+ // strategy === "rekey"
106
+ if (shortHash) {
107
+ deps.setVersionMap((m) => {
108
+ if (m[addonKey] === shortHash)
109
+ return m;
110
+ return { ...m, [addonKey]: shortHash };
111
+ });
112
+ }
113
+ config.onSwap?.(message, 'rekey');
114
+ return 'rekey';
115
+ }
116
+ export function useHotSwapReload(client, config = {}) {
117
+ const [addonVersionMap, setAddonVersionMap] = useState({});
118
+ // Keep config behind a ref so changing callbacks between renders does
119
+ // not re-subscribe to the WebSocket / tear down listeners.
120
+ const configRef = useRef(config);
121
+ configRef.current = config;
122
+ // Stable handler for the underlying subscriber — reads the latest
123
+ // config out of the ref every time the WS emits.
124
+ const handleSwap = useMemo(() => (message) => {
125
+ void applyHotSwapReload(message, configRef.current, {
126
+ setVersionMap: setAddonVersionMap,
127
+ });
128
+ }, []);
129
+ useManifestHotSwapSubscriber(client, {
130
+ matcher: config.matcher,
131
+ onSwap: handleSwap,
132
+ });
133
+ return { addonVersionMap };
134
+ }
135
+ /**
136
+ * Append a `?v=<hash8>` query string to a `remoteEntry.js` URL so the
137
+ * browser treats it as a distinct resource and bypasses any HTTP / module
138
+ * cache. Idempotent — calling twice with the same hash returns the same
139
+ * URL. Preserves existing query params; replaces a previous `v=` entry if
140
+ * present so successive bumps don't accumulate stale parameters.
141
+ *
142
+ * Pure function (no `window` access) — safe to call in SSR.
143
+ *
144
+ * @example
145
+ * withVersionParam('/api/addons/pos/frontend/remoteEntry.js', 'abc123ef')
146
+ * // → '/api/addons/pos/frontend/remoteEntry.js?v=abc123ef'
147
+ *
148
+ * withVersionParam('/r.js?foo=1', 'abc123ef')
149
+ * // → '/r.js?foo=1&v=abc123ef'
150
+ *
151
+ * withVersionParam('/r.js?v=oldhash', 'abc123ef')
152
+ * // → '/r.js?v=abc123ef'
153
+ */
154
+ export function withVersionParam(url, hash) {
155
+ if (!hash)
156
+ return url;
157
+ const short = shortenHash(hash);
158
+ if (!short)
159
+ return url;
160
+ const hashIdx = url.indexOf('#');
161
+ const fragment = hashIdx >= 0 ? url.slice(hashIdx) : '';
162
+ const base = hashIdx >= 0 ? url.slice(0, hashIdx) : url;
163
+ const qIdx = base.indexOf('?');
164
+ if (qIdx < 0)
165
+ return `${base}?v=${short}${fragment}`;
166
+ const head = base.slice(0, qIdx);
167
+ const query = base.slice(qIdx + 1);
168
+ // Drop any previous v= entry and re-append.
169
+ const parts = query
170
+ .split('&')
171
+ .filter((p) => p.length > 0 && !p.startsWith('v='));
172
+ parts.push(`v=${short}`);
173
+ return `${head}?${parts.join('&')}${fragment}`;
174
+ }
175
+ /**
176
+ * Remove the federation container previously registered on `window[scope]`.
177
+ * Hosts call this from `onSwap` before letting the addon route re-mount
178
+ * so the next `remoteEntry.js` injection creates a fresh container instead
179
+ * of short-circuiting on the cached one.
180
+ *
181
+ * Best-effort: if `window` is undefined (SSR) or the scope was never
182
+ * registered, this is a no-op. Returns `true` if a container was actually
183
+ * removed, `false` otherwise — useful for telemetry.
184
+ *
185
+ * **Caveat:** some federation runtimes wrap the container in a Proxy
186
+ * whose internal state survives `delete`. If you hit `Container already
187
+ * registered` after calling this, the federation runtime is holding the
188
+ * reference internally and the only reliable swap is `"page-reload"`.
189
+ */
190
+ export function clearFederationContainer(scope) {
191
+ if (typeof window === 'undefined')
192
+ return false;
193
+ if (!(scope in window))
194
+ return false;
195
+ try {
196
+ delete window[scope];
197
+ return true;
198
+ }
199
+ catch {
200
+ // Some browsers refuse to delete non-configurable globals. Set
201
+ // to undefined as a fallback so the loader's `if (!window[scope])`
202
+ // check still triggers a re-inject.
203
+ ;
204
+ window[scope] = undefined;
205
+ return true;
206
+ }
207
+ }
208
+ /**
209
+ * Normalise a manifest hash for cache-busting. Accepts the full kernel
210
+ * format (`sha256:abc...`), a bare hex digest, or `undefined`. Returns
211
+ * an 8-character lowercase prefix that's short enough to keep URLs
212
+ * readable while remaining collision-resistant across realistic addon
213
+ * versioning timelines.
214
+ *
215
+ * Exported for tests; hosts that want the full hash for their own
216
+ * telemetry should read `message.payload.newHash` directly.
217
+ */
218
+ export function shortenHash(hash) {
219
+ if (!hash)
220
+ return undefined;
221
+ const colonIdx = hash.indexOf(':');
222
+ const digest = colonIdx >= 0 ? hash.slice(colonIdx + 1) : hash;
223
+ const trimmed = digest.trim();
224
+ if (!trimmed)
225
+ return undefined;
226
+ return trimmed.slice(0, 8).toLowerCase();
227
+ }
package/dist/index.d.ts CHANGED
@@ -4,12 +4,15 @@ export * from './dynamic-table';
4
4
  export * from './dynamic-form';
5
5
  export { ActionModalDispatcher, type ActionModalProps, } from './action-modal-dispatcher';
6
6
  export * from './addon-loader';
7
+ export { AddonLayoutProvider, useAddonLayout, useAddonLayoutControl, useDeclareAddonLayout, type AddonLayout, type AddonLayoutProviderProps, } from './addon-layout-context';
7
8
  export * from './slot';
8
9
  export * from './capability-gate';
9
10
  export * from './navigation-builder';
10
11
  export * from './i18n-provider';
11
12
  export * from './api-context';
12
13
  export * from './metadata-cache';
14
+ export { ADDON_MANIFEST_CHANGED_TYPE, wireHotSwapInvalidation, useManifestHotSwapSubscriber, type AddonManifestChangedMessage, type ManifestHotSwapClient, type WireHotSwapInvalidationOptions, } from './manifest-hotswap-subscriber';
15
+ export { useHotSwapReload, applyHotSwapReload, withVersionParam, clearFederationContainer, shortenHash, type HotSwapReloadStrategy, type HotSwapReloadConfig, type HotSwapReloadAction, type HotSwapReloadDeps, type UseHotSwapReloadResult, } from './hotswap-reload-policy';
13
16
  export * from './dynamic-icon';
14
17
  export type { ColumnFilterConfig, FilterOption as DynamicColumnFilterOption, GetDynamicColumns, DynamicIconComponent, } from './dynamic-columns-shim';
15
18
  export { defaultGetDynamicColumns, makeDefaultGetDynamicColumns, type DynamicColumnsHelpers, } from './dynamic-columns';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAKA,cAAc,SAAS,CAAA;AACvB,cAAc,mBAAmB,CAAA;AACjC,cAAc,iBAAiB,CAAA;AAC/B,cAAc,gBAAgB,CAAA;AAC9B,OAAO,EACH,qBAAqB,EACrB,KAAK,gBAAgB,GACxB,MAAM,2BAA2B,CAAA;AAClC,cAAc,gBAAgB,CAAA;AAC9B,cAAc,QAAQ,CAAA;AACtB,cAAc,mBAAmB,CAAA;AACjC,cAAc,sBAAsB,CAAA;AACpC,cAAc,iBAAiB,CAAA;AAC/B,cAAc,eAAe,CAAA;AAC7B,cAAc,kBAAkB,CAAA;AAChC,cAAc,gBAAgB,CAAA;AAC9B,YAAY,EACR,kBAAkB,EAClB,YAAY,IAAI,yBAAyB,EACzC,iBAAiB,EACjB,oBAAoB,GACvB,MAAM,wBAAwB,CAAA;AAC/B,OAAO,EACH,wBAAwB,EACxB,4BAA4B,EAC5B,KAAK,qBAAqB,GAC7B,MAAM,mBAAmB,CAAA;AAC1B,OAAO,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAA;AAC9D,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAA;AAC/C,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAA;AAC/C,OAAO,EACH,eAAe,EACf,KAAK,oBAAoB,EACzB,KAAK,sBAAsB,EAC3B,KAAK,sBAAsB,GAC9B,MAAM,qBAAqB,CAAA;AAC5B,OAAO,EACH,eAAe,EACf,KAAK,oBAAoB,EACzB,KAAK,sBAAsB,EAC3B,KAAK,mBAAmB,EACxB,yBAAyB,EACzB,kBAAkB,EAClB,wBAAwB,EACxB,cAAc,GACjB,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EACH,sBAAsB,EACtB,iBAAiB,EACjB,oBAAoB,EACpB,KAAK,cAAc,EACnB,KAAK,mBAAmB,GAC3B,MAAM,4BAA4B,CAAA;AACnC,OAAO,EACH,sBAAsB,EACtB,uBAAuB,GAC1B,MAAM,qBAAqB,CAAA;AAC5B,OAAO,EACH,kBAAkB,EAClB,aAAa,EACb,KAAK,cAAc,EACnB,KAAK,WAAW,EAChB,KAAK,sBAAsB,EAC3B,KAAK,wBAAwB,GAChC,MAAM,wBAAwB,CAAA;AAC/B,OAAO,EACH,kBAAkB,EAClB,kBAAkB,EAClB,qBAAqB,EACrB,KAAK,eAAe,GACvB,MAAM,yBAAyB,CAAA;AAChC,OAAO,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAKA,cAAc,SAAS,CAAA;AACvB,cAAc,mBAAmB,CAAA;AACjC,cAAc,iBAAiB,CAAA;AAC/B,cAAc,gBAAgB,CAAA;AAC9B,OAAO,EACH,qBAAqB,EACrB,KAAK,gBAAgB,GACxB,MAAM,2BAA2B,CAAA;AAClC,cAAc,gBAAgB,CAAA;AAC9B,OAAO,EACH,mBAAmB,EACnB,cAAc,EACd,qBAAqB,EACrB,qBAAqB,EACrB,KAAK,WAAW,EAChB,KAAK,wBAAwB,GAChC,MAAM,wBAAwB,CAAA;AAC/B,cAAc,QAAQ,CAAA;AACtB,cAAc,mBAAmB,CAAA;AACjC,cAAc,sBAAsB,CAAA;AACpC,cAAc,iBAAiB,CAAA;AAC/B,cAAc,eAAe,CAAA;AAC7B,cAAc,kBAAkB,CAAA;AAChC,OAAO,EACH,2BAA2B,EAC3B,uBAAuB,EACvB,4BAA4B,EAC5B,KAAK,2BAA2B,EAChC,KAAK,qBAAqB,EAC1B,KAAK,8BAA8B,GACtC,MAAM,+BAA+B,CAAA;AACtC,OAAO,EACH,gBAAgB,EAChB,kBAAkB,EAClB,gBAAgB,EAChB,wBAAwB,EACxB,WAAW,EACX,KAAK,qBAAqB,EAC1B,KAAK,mBAAmB,EACxB,KAAK,mBAAmB,EACxB,KAAK,iBAAiB,EACtB,KAAK,sBAAsB,GAC9B,MAAM,yBAAyB,CAAA;AAChC,cAAc,gBAAgB,CAAA;AAC9B,YAAY,EACR,kBAAkB,EAClB,YAAY,IAAI,yBAAyB,EACzC,iBAAiB,EACjB,oBAAoB,GACvB,MAAM,wBAAwB,CAAA;AAC/B,OAAO,EACH,wBAAwB,EACxB,4BAA4B,EAC5B,KAAK,qBAAqB,GAC7B,MAAM,mBAAmB,CAAA;AAC1B,OAAO,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAA;AAC9D,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAA;AAC/C,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAA;AAC/C,OAAO,EACH,eAAe,EACf,KAAK,oBAAoB,EACzB,KAAK,sBAAsB,EAC3B,KAAK,sBAAsB,GAC9B,MAAM,qBAAqB,CAAA;AAC5B,OAAO,EACH,eAAe,EACf,KAAK,oBAAoB,EACzB,KAAK,sBAAsB,EAC3B,KAAK,mBAAmB,EACxB,yBAAyB,EACzB,kBAAkB,EAClB,wBAAwB,EACxB,cAAc,GACjB,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EACH,sBAAsB,EACtB,iBAAiB,EACjB,oBAAoB,EACpB,KAAK,cAAc,EACnB,KAAK,mBAAmB,GAC3B,MAAM,4BAA4B,CAAA;AACnC,OAAO,EACH,sBAAsB,EACtB,uBAAuB,GAC1B,MAAM,qBAAqB,CAAA;AAC5B,OAAO,EACH,kBAAkB,EAClB,aAAa,EACb,KAAK,cAAc,EACnB,KAAK,WAAW,EAChB,KAAK,sBAAsB,EAC3B,KAAK,wBAAwB,GAChC,MAAM,wBAAwB,CAAA;AAC/B,OAAO,EACH,kBAAkB,EAClB,kBAAkB,EAClB,qBAAqB,EACrB,KAAK,eAAe,GACvB,MAAM,yBAAyB,CAAA;AAChC,OAAO,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAA"}
package/dist/index.js CHANGED
@@ -9,12 +9,15 @@ export * from './dynamic-table';
9
9
  export * from './dynamic-form';
10
10
  export { ActionModalDispatcher, } from './action-modal-dispatcher';
11
11
  export * from './addon-loader';
12
+ export { AddonLayoutProvider, useAddonLayout, useAddonLayoutControl, useDeclareAddonLayout, } from './addon-layout-context';
12
13
  export * from './slot';
13
14
  export * from './capability-gate';
14
15
  export * from './navigation-builder';
15
16
  export * from './i18n-provider';
16
17
  export * from './api-context';
17
18
  export * from './metadata-cache';
19
+ export { ADDON_MANIFEST_CHANGED_TYPE, wireHotSwapInvalidation, useManifestHotSwapSubscriber, } from './manifest-hotswap-subscriber';
20
+ export { useHotSwapReload, applyHotSwapReload, withVersionParam, clearFederationContainer, shortenHash, } from './hotswap-reload-policy';
18
21
  export * from './dynamic-icon';
19
22
  export { defaultGetDynamicColumns, makeDefaultGetDynamicColumns, } from './dynamic-columns';
20
23
  export { DynamicRecordDialog } from './dialogs/dynamic-record';
@@ -0,0 +1,83 @@
1
+ import { type AddonKeyMatcher } from './metadata-cache';
2
+ /**
3
+ * `type` string the kernel/bridge emits. Exported so consumers can subscribe
4
+ * manually if they prefer to skip the hook/wire helper. Matches
5
+ * `bridge.WSManifestChangedType` in metacore-kernel.
6
+ */
7
+ export declare const ADDON_MANIFEST_CHANGED_TYPE: "ADDON_MANIFEST_CHANGED";
8
+ /**
9
+ * Shape of the WebSocket message the kernel emits. The keys mirror the
10
+ * `bridge.manifestChangedPayload` map in metacore-kernel/bridge — keep them
11
+ * in sync if the bridge ever evolves.
12
+ */
13
+ export interface AddonManifestChangedMessage {
14
+ type: typeof ADDON_MANIFEST_CHANGED_TYPE;
15
+ payload: {
16
+ orgId?: string;
17
+ addonKey: string;
18
+ oldHash?: string;
19
+ newHash?: string;
20
+ version?: string;
21
+ timestamp?: string;
22
+ };
23
+ }
24
+ /**
25
+ * Structural client contract. The SDK's `@asteby/metacore-websocket`
26
+ * provider exposes `subscribe(type, handler)` (see `useWebSocket().subscribe`)
27
+ * that satisfies this interface — but any object with a compatible method
28
+ * works, so hosts that wrap their own transport (link's MQTT bridge, the
29
+ * kitchen-display ZeroMQ stub, …) can plug in without depending on the
30
+ * SDK websocket package.
31
+ */
32
+ export interface ManifestHotSwapClient {
33
+ subscribe: (type: string, handler: (message: AddonManifestChangedMessage) => void) => () => void;
34
+ }
35
+ export interface WireHotSwapInvalidationOptions {
36
+ /**
37
+ * Optional matcher overriding the default cache-key heuristic
38
+ * (see `defaultAddonKeyMatcher`). Hosts that namespace cached
39
+ * `model` keys under prefixes other than `${addonKey}.|:|/` should
40
+ * supply one.
41
+ */
42
+ matcher?: AddonKeyMatcher;
43
+ /**
44
+ * Optional side-effect hook invoked after the cache invalidation.
45
+ * Useful for hosts that want to log/observe hot-swaps or trigger a
46
+ * `window.location.reload()` when the running addon's bundle hash
47
+ * changes (see the module-level comment above for the trade-off).
48
+ * `removed` is the number of cache entries flushed for this addon.
49
+ */
50
+ onSwap?: (msg: AddonManifestChangedMessage, removed: number) => void;
51
+ }
52
+ /**
53
+ * Imperative wire-up — no React required. Hosts that own a long-lived
54
+ * WebSocket client (link, ops, the kitchen-display Tauri shell) call this
55
+ * once at boot, after the client has been created. The returned function
56
+ * unsubscribes; most hosts will never call it because the subscription
57
+ * lives for the lifetime of the app.
58
+ *
59
+ * const ws = createWebSocket(...)
60
+ * const unsubscribe = wireHotSwapInvalidation(ws)
61
+ * // …later, if needed:
62
+ * unsubscribe()
63
+ *
64
+ * Accepts `undefined` so the call site does not need to branch when the
65
+ * client is constructed lazily — it returns a no-op unsubscribe.
66
+ */
67
+ export declare function wireHotSwapInvalidation(client: ManifestHotSwapClient | undefined | null, options?: WireHotSwapInvalidationOptions): () => void;
68
+ /**
69
+ * React-flavoured wrapper around {@link wireHotSwapInvalidation}. Mount it
70
+ * once high in the tree (typically next to the WebSocket provider) so the
71
+ * subscription lifetime matches the host shell. Passing `undefined` for
72
+ * the client is supported — the hook becomes a no-op until a real client
73
+ * is available, mirroring how `useWebSocket().subscribe` behaves before
74
+ * the socket opens.
75
+ *
76
+ * function HostShell() {
77
+ * const ws = useWebSocket()
78
+ * useManifestHotSwapSubscriber(ws)
79
+ * return <Outlet />
80
+ * }
81
+ */
82
+ export declare function useManifestHotSwapSubscriber(client: ManifestHotSwapClient | undefined | null, options?: WireHotSwapInvalidationOptions): void;
83
+ //# sourceMappingURL=manifest-hotswap-subscriber.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"manifest-hotswap-subscriber.d.ts","sourceRoot":"","sources":["../src/manifest-hotswap-subscriber.ts"],"names":[],"mappings":"AA6CA,OAAO,EAAoB,KAAK,eAAe,EAAE,MAAM,kBAAkB,CAAA;AAEzE;;;;GAIG;AACH,eAAO,MAAM,2BAA2B,EAAG,wBAAiC,CAAA;AAE5E;;;;GAIG;AACH,MAAM,WAAW,2BAA2B;IACxC,IAAI,EAAE,OAAO,2BAA2B,CAAA;IACxC,OAAO,EAAE;QACL,KAAK,CAAC,EAAE,MAAM,CAAA;QACd,QAAQ,EAAE,MAAM,CAAA;QAChB,OAAO,CAAC,EAAE,MAAM,CAAA;QAChB,OAAO,CAAC,EAAE,MAAM,CAAA;QAChB,OAAO,CAAC,EAAE,MAAM,CAAA;QAChB,SAAS,CAAC,EAAE,MAAM,CAAA;KACrB,CAAA;CACJ;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,qBAAqB;IAClC,SAAS,EAAE,CACP,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,CAAC,OAAO,EAAE,2BAA2B,KAAK,IAAI,KACtD,MAAM,IAAI,CAAA;CAClB;AAED,MAAM,WAAW,8BAA8B;IAC3C;;;;;OAKG;IACH,OAAO,CAAC,EAAE,eAAe,CAAA;IACzB;;;;;;OAMG;IACH,MAAM,CAAC,EAAE,CAAC,GAAG,EAAE,2BAA2B,EAAE,OAAO,EAAE,MAAM,KAAK,IAAI,CAAA;CACvE;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,uBAAuB,CACnC,MAAM,EAAE,qBAAqB,GAAG,SAAS,GAAG,IAAI,EAChD,OAAO,GAAE,8BAAmC,GAC7C,MAAM,IAAI,CAeZ;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,4BAA4B,CACxC,MAAM,EAAE,qBAAqB,GAAG,SAAS,GAAG,IAAI,EAChD,OAAO,GAAE,8BAAmC,GAC7C,IAAI,CAON"}
@@ -0,0 +1,104 @@
1
+ // manifest-hotswap-subscriber — client-side closure of RFC-0001 D4.
2
+ //
3
+ // The kernel installer (see metacore-kernel/installer/broadcaster.go) emits a
4
+ // WebSocket message every time it observes a manifest-hash change at persist
5
+ // time. The bridge (metacore-kernel/bridge/installer_broadcaster.go) fans it
6
+ // out per-org over `ws.Hub.SendToUsers` with this exact envelope:
7
+ //
8
+ // {
9
+ // "type": "ADDON_MANIFEST_CHANGED",
10
+ // "payload": {
11
+ // "orgId": "<uuid>",
12
+ // "addonKey": "kitchen_display",
13
+ // "oldHash": "sha256:...",
14
+ // "newHash": "sha256:...",
15
+ // "version": "1.2.0",
16
+ // "timestamp": "2026-05-11T12:34:56Z"
17
+ // }
18
+ // }
19
+ //
20
+ // (The original RFC scratchpad used snake_case keys; the bridge canonicalised
21
+ // them to camelCase so the SDK's metadata-cache reducer can consume the
22
+ // payload verbatim. This module matches the bridge — the source of truth.)
23
+ //
24
+ // This module wires the message into `useMetadataCache().invalidateAddon` so
25
+ // every cached table/modal metadata entry belonging to the bumped addon is
26
+ // dropped from the zustand store. The next mount of any `DynamicTable` or
27
+ // `DynamicCRUDPage` will refetch fresh metadata, picking up whatever the
28
+ // new manifest changed (new fields, renamed actions, dropped relations…).
29
+ //
30
+ // IMPORTANT — a freshly-bumped addon whose `remoteEntry.js` URL changes will
31
+ // also need its federation container reloaded. Cache invalidation handles
32
+ // metadata; it does NOT swap the JS already executing in memory. If your
33
+ // host wants the new code to take effect immediately, either:
34
+ //
35
+ // 1. force a full `window.location.reload()` when an `addon:manifest:changed`
36
+ // event arrives for the addon currently mounted, or
37
+ // 2. for immersive addons (`layout: "immersive"`) where users tolerate a
38
+ // flicker, key the addon route component on `newHash` so React unmounts
39
+ // and remounts the AddonLoader with the new remoteEntry URL.
40
+ //
41
+ // The subscriber here intentionally stops at metadata-cache: deciding when
42
+ // to reload is a host policy decision and we keep this module side-effect
43
+ // free beyond the cache.
44
+ import { useEffect } from 'react';
45
+ import { useMetadataCache } from './metadata-cache';
46
+ /**
47
+ * `type` string the kernel/bridge emits. Exported so consumers can subscribe
48
+ * manually if they prefer to skip the hook/wire helper. Matches
49
+ * `bridge.WSManifestChangedType` in metacore-kernel.
50
+ */
51
+ export const ADDON_MANIFEST_CHANGED_TYPE = 'ADDON_MANIFEST_CHANGED';
52
+ /**
53
+ * Imperative wire-up — no React required. Hosts that own a long-lived
54
+ * WebSocket client (link, ops, the kitchen-display Tauri shell) call this
55
+ * once at boot, after the client has been created. The returned function
56
+ * unsubscribes; most hosts will never call it because the subscription
57
+ * lives for the lifetime of the app.
58
+ *
59
+ * const ws = createWebSocket(...)
60
+ * const unsubscribe = wireHotSwapInvalidation(ws)
61
+ * // …later, if needed:
62
+ * unsubscribe()
63
+ *
64
+ * Accepts `undefined` so the call site does not need to branch when the
65
+ * client is constructed lazily — it returns a no-op unsubscribe.
66
+ */
67
+ export function wireHotSwapInvalidation(client, options = {}) {
68
+ if (!client)
69
+ return () => { };
70
+ const { matcher, onSwap } = options;
71
+ const unsubscribe = client.subscribe(ADDON_MANIFEST_CHANGED_TYPE, (message) => {
72
+ const addonKey = message?.payload?.addonKey;
73
+ if (!addonKey)
74
+ return;
75
+ const removed = useMetadataCache
76
+ .getState()
77
+ .invalidateAddon(addonKey, matcher);
78
+ onSwap?.(message, removed);
79
+ });
80
+ return unsubscribe;
81
+ }
82
+ /**
83
+ * React-flavoured wrapper around {@link wireHotSwapInvalidation}. Mount it
84
+ * once high in the tree (typically next to the WebSocket provider) so the
85
+ * subscription lifetime matches the host shell. Passing `undefined` for
86
+ * the client is supported — the hook becomes a no-op until a real client
87
+ * is available, mirroring how `useWebSocket().subscribe` behaves before
88
+ * the socket opens.
89
+ *
90
+ * function HostShell() {
91
+ * const ws = useWebSocket()
92
+ * useManifestHotSwapSubscriber(ws)
93
+ * return <Outlet />
94
+ * }
95
+ */
96
+ export function useManifestHotSwapSubscriber(client, options = {}) {
97
+ const { matcher, onSwap } = options;
98
+ useEffect(() => {
99
+ if (!client)
100
+ return;
101
+ const unsubscribe = wireHotSwapInvalidation(client, { matcher, onSwap });
102
+ return unsubscribe;
103
+ }, [client, matcher, onSwap]);
104
+ }
@@ -4,6 +4,21 @@ export interface MetadataApiClient {
4
4
  data: any;
5
5
  }>;
6
6
  }
7
+ /**
8
+ * Predicate matching a cache key against an addon. The default
9
+ * implementation (see {@link defaultAddonKeyMatcher}) treats `addonKey`
10
+ * itself plus any key beginning with `${addonKey}.`, `${addonKey}:` or
11
+ * `${addonKey}/` as belonging to the addon. Hosts that namespace their
12
+ * `model` strings differently can pass a custom matcher to
13
+ * {@link MetadataCacheState.invalidateAddon}.
14
+ */
15
+ export type AddonKeyMatcher = (cacheKey: string, addonKey: string) => boolean;
16
+ /**
17
+ * Default matcher used by {@link MetadataCacheState.invalidateAddon}.
18
+ * Mirrors the convention used by the kernel installer when it scopes
19
+ * model metadata under an addon's key.
20
+ */
21
+ export declare function defaultAddonKeyMatcher(cacheKey: string, addonKey: string): boolean;
7
22
  interface MetadataCacheState {
8
23
  cache: Record<string, TableMetadata>;
9
24
  modalCache: Record<string, TableMetadata>;
@@ -16,6 +31,26 @@ interface MetadataCacheState {
16
31
  hasMetadata: (key: string) => boolean;
17
32
  hasModalMetadata: (key: string) => boolean;
18
33
  prefetchAll: (api: MetadataApiClient) => Promise<void>;
34
+ /**
35
+ * Remove cached entries belonging to a specific addon. Used by the
36
+ * hot-swap subscriber when the kernel announces a manifest change.
37
+ *
38
+ * Returns the number of entries removed across both caches, which is
39
+ * useful for tests and observability — `0` means the cache had nothing
40
+ * to flush (the addon either hadn't been hit yet or uses a key
41
+ * convention the default matcher doesn't recognise; pass a custom
42
+ * `matcher` if your host namespaces differently).
43
+ *
44
+ * Also resets `prefetched` to `false` so the next mount re-runs
45
+ * `prefetchAll()` and the `metadataVersion` is allowed to advance to
46
+ * the kernel's freshly-bumped hash.
47
+ */
48
+ invalidateAddon: (addonKey: string, matcher?: AddonKeyMatcher) => number;
49
+ /**
50
+ * Remove every cached entry. Heavier hammer for hosts that prefer a
51
+ * blanket flush on hot-swap rather than per-addon scoping.
52
+ */
53
+ clearAll: () => void;
19
54
  }
20
55
  export declare const useMetadataCache: import("zustand").UseBoundStore<Omit<import("zustand").StoreApi<MetadataCacheState>, "setState" | "persist"> & {
21
56
  setState(partial: MetadataCacheState | Partial<MetadataCacheState> | ((state: MetadataCacheState) => MetadataCacheState | Partial<MetadataCacheState>), replace?: false | undefined): unknown;
@@ -1 +1 @@
1
- {"version":3,"file":"metadata-cache.d.ts","sourceRoot":"","sources":["../src/metadata-cache.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,SAAS,CAAA;AAE5C,MAAM,WAAW,iBAAiB;IAC9B,GAAG,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,GAAG,KAAK,OAAO,CAAC;QAAE,IAAI,EAAE,GAAG,CAAA;KAAE,CAAC,CAAA;CAC7D;AAED,UAAU,kBAAkB;IACxB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,CAAA;IACpC,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,CAAA;IACzC,eAAe,EAAE,MAAM,CAAA;IACvB,UAAU,EAAE,OAAO,CAAA;IACnB,WAAW,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,aAAa,GAAG,SAAS,CAAA;IACvD,gBAAgB,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,aAAa,GAAG,SAAS,CAAA;IAC5D,WAAW,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,aAAa,KAAK,IAAI,CAAA;IAC3D,gBAAgB,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,aAAa,KAAK,IAAI,CAAA;IAChE,WAAW,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAA;IACrC,gBAAgB,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAA;IAC1C,WAAW,EAAE,CAAC,GAAG,EAAE,iBAAiB,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;CACzD;AAED,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;EAwE5B,CAAA"}
1
+ {"version":3,"file":"metadata-cache.d.ts","sourceRoot":"","sources":["../src/metadata-cache.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,SAAS,CAAA;AAE5C,MAAM,WAAW,iBAAiB;IAC9B,GAAG,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,GAAG,KAAK,OAAO,CAAC;QAAE,IAAI,EAAE,GAAG,CAAA;KAAE,CAAC,CAAA;CAC7D;AAED;;;;;;;GAOG;AACH,MAAM,MAAM,eAAe,GAAG,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAA;AAE7E;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAQlF;AAED,UAAU,kBAAkB;IACxB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,CAAA;IACpC,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,CAAA;IACzC,eAAe,EAAE,MAAM,CAAA;IACvB,UAAU,EAAE,OAAO,CAAA;IACnB,WAAW,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,aAAa,GAAG,SAAS,CAAA;IACvD,gBAAgB,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,aAAa,GAAG,SAAS,CAAA;IAC5D,WAAW,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,aAAa,KAAK,IAAI,CAAA;IAC3D,gBAAgB,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,aAAa,KAAK,IAAI,CAAA;IAChE,WAAW,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAA;IACrC,gBAAgB,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAA;IAC1C,WAAW,EAAE,CAAC,GAAG,EAAE,iBAAiB,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;IACtD;;;;;;;;;;;;;OAaG;IACH,eAAe,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,eAAe,KAAK,MAAM,CAAA;IACxE;;;OAGG;IACH,QAAQ,EAAE,MAAM,IAAI,CAAA;CACvB;AAED,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;EAiH5B,CAAA"}
@@ -8,6 +8,20 @@
8
8
  // never invokes prefetchAll, the `api` dep is not required.
9
9
  import { create } from 'zustand';
10
10
  import { persist } from 'zustand/middleware';
11
+ /**
12
+ * Default matcher used by {@link MetadataCacheState.invalidateAddon}.
13
+ * Mirrors the convention used by the kernel installer when it scopes
14
+ * model metadata under an addon's key.
15
+ */
16
+ export function defaultAddonKeyMatcher(cacheKey, addonKey) {
17
+ if (!addonKey)
18
+ return false;
19
+ if (cacheKey === addonKey)
20
+ return true;
21
+ return (cacheKey.startsWith(`${addonKey}.`) ||
22
+ cacheKey.startsWith(`${addonKey}:`) ||
23
+ cacheKey.startsWith(`${addonKey}/`));
24
+ }
11
25
  export const useMetadataCache = create()(persist((set, get) => ({
12
26
  cache: {},
13
27
  modalCache: {},
@@ -27,6 +41,47 @@ export const useMetadataCache = create()(persist((set, get) => ({
27
41
  },
28
42
  hasMetadata: (key) => key in get().cache,
29
43
  hasModalMetadata: (key) => key in get().modalCache,
44
+ invalidateAddon: (addonKey, matcher = defaultAddonKeyMatcher) => {
45
+ if (!addonKey)
46
+ return 0;
47
+ let removed = 0;
48
+ const state = get();
49
+ const nextCache = {};
50
+ for (const [key, value] of Object.entries(state.cache)) {
51
+ if (matcher(key, addonKey)) {
52
+ removed += 1;
53
+ continue;
54
+ }
55
+ nextCache[key] = value;
56
+ }
57
+ const nextModalCache = {};
58
+ for (const [key, value] of Object.entries(state.modalCache)) {
59
+ if (matcher(key, addonKey)) {
60
+ removed += 1;
61
+ continue;
62
+ }
63
+ nextModalCache[key] = value;
64
+ }
65
+ if (removed === 0)
66
+ return 0;
67
+ set({
68
+ cache: nextCache,
69
+ modalCache: nextModalCache,
70
+ // Allow prefetchAll() to re-run so we pick up the new
71
+ // metadataVersion the kernel emits alongside the
72
+ // bumped manifest.
73
+ prefetched: false,
74
+ });
75
+ return removed;
76
+ },
77
+ clearAll: () => {
78
+ set({
79
+ cache: {},
80
+ modalCache: {},
81
+ metadataVersion: '',
82
+ prefetched: false,
83
+ });
84
+ },
30
85
  prefetchAll: async (api) => {
31
86
  if (get().prefetched)
32
87
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteby/metacore-runtime-react",
3
- "version": "9.2.0",
3
+ "version": "10.0.0",
4
4
  "description": "React runtime for metacore hosts — renders addon contributions dynamically",
5
5
  "repository": {
6
6
  "type": "git",
@@ -32,7 +32,7 @@
32
32
  "lucide-react": ">=0.460",
33
33
  "date-fns": ">=3",
34
34
  "react-day-picker": ">=8",
35
- "@asteby/metacore-sdk": "^2.4.0",
35
+ "@asteby/metacore-sdk": "^2.5.0",
36
36
  "@asteby/metacore-ui": "^2.0.0"
37
37
  },
38
38
  "peerDependenciesMeta": {
@@ -60,7 +60,7 @@
60
60
  "typescript": "^6.0.0",
61
61
  "vitest": "^4.0.0",
62
62
  "zustand": "^5.0.0",
63
- "@asteby/metacore-sdk": "2.4.0",
63
+ "@asteby/metacore-sdk": "2.5.0",
64
64
  "@asteby/metacore-ui": "2.0.0"
65
65
  },
66
66
  "scripts": {