@asteby/metacore-runtime-react 9.2.0 → 11.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,164 @@
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
+
45
+ import { useEffect } from 'react'
46
+ import { useMetadataCache, type AddonKeyMatcher } from './metadata-cache'
47
+
48
+ /**
49
+ * `type` string the kernel/bridge emits. Exported so consumers can subscribe
50
+ * manually if they prefer to skip the hook/wire helper. Matches
51
+ * `bridge.WSManifestChangedType` in metacore-kernel.
52
+ */
53
+ export const ADDON_MANIFEST_CHANGED_TYPE = 'ADDON_MANIFEST_CHANGED' as const
54
+
55
+ /**
56
+ * Shape of the WebSocket message the kernel emits. The keys mirror the
57
+ * `bridge.manifestChangedPayload` map in metacore-kernel/bridge — keep them
58
+ * in sync if the bridge ever evolves.
59
+ */
60
+ export interface AddonManifestChangedMessage {
61
+ type: typeof ADDON_MANIFEST_CHANGED_TYPE
62
+ payload: {
63
+ orgId?: string
64
+ addonKey: string
65
+ oldHash?: string
66
+ newHash?: string
67
+ version?: string
68
+ timestamp?: string
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Structural client contract. The SDK's `@asteby/metacore-websocket`
74
+ * provider exposes `subscribe(type, handler)` (see `useWebSocket().subscribe`)
75
+ * that satisfies this interface — but any object with a compatible method
76
+ * works, so hosts that wrap their own transport (link's MQTT bridge, the
77
+ * kitchen-display ZeroMQ stub, …) can plug in without depending on the
78
+ * SDK websocket package.
79
+ */
80
+ export interface ManifestHotSwapClient {
81
+ subscribe: (
82
+ type: string,
83
+ handler: (message: AddonManifestChangedMessage) => void,
84
+ ) => () => void
85
+ }
86
+
87
+ export interface WireHotSwapInvalidationOptions {
88
+ /**
89
+ * Optional matcher overriding the default cache-key heuristic
90
+ * (see `defaultAddonKeyMatcher`). Hosts that namespace cached
91
+ * `model` keys under prefixes other than `${addonKey}.|:|/` should
92
+ * supply one.
93
+ */
94
+ matcher?: AddonKeyMatcher
95
+ /**
96
+ * Optional side-effect hook invoked after the cache invalidation.
97
+ * Useful for hosts that want to log/observe hot-swaps or trigger a
98
+ * `window.location.reload()` when the running addon's bundle hash
99
+ * changes (see the module-level comment above for the trade-off).
100
+ * `removed` is the number of cache entries flushed for this addon.
101
+ */
102
+ onSwap?: (msg: AddonManifestChangedMessage, removed: number) => void
103
+ }
104
+
105
+ /**
106
+ * Imperative wire-up — no React required. Hosts that own a long-lived
107
+ * WebSocket client (link, ops, the kitchen-display Tauri shell) call this
108
+ * once at boot, after the client has been created. The returned function
109
+ * unsubscribes; most hosts will never call it because the subscription
110
+ * lives for the lifetime of the app.
111
+ *
112
+ * const ws = createWebSocket(...)
113
+ * const unsubscribe = wireHotSwapInvalidation(ws)
114
+ * // …later, if needed:
115
+ * unsubscribe()
116
+ *
117
+ * Accepts `undefined` so the call site does not need to branch when the
118
+ * client is constructed lazily — it returns a no-op unsubscribe.
119
+ */
120
+ export function wireHotSwapInvalidation(
121
+ client: ManifestHotSwapClient | undefined | null,
122
+ options: WireHotSwapInvalidationOptions = {},
123
+ ): () => void {
124
+ if (!client) return () => {}
125
+ const { matcher, onSwap } = options
126
+ const unsubscribe = client.subscribe(
127
+ ADDON_MANIFEST_CHANGED_TYPE,
128
+ (message) => {
129
+ const addonKey = message?.payload?.addonKey
130
+ if (!addonKey) return
131
+ const removed = useMetadataCache
132
+ .getState()
133
+ .invalidateAddon(addonKey, matcher)
134
+ onSwap?.(message, removed)
135
+ },
136
+ )
137
+ return unsubscribe
138
+ }
139
+
140
+ /**
141
+ * React-flavoured wrapper around {@link wireHotSwapInvalidation}. Mount it
142
+ * once high in the tree (typically next to the WebSocket provider) so the
143
+ * subscription lifetime matches the host shell. Passing `undefined` for
144
+ * the client is supported — the hook becomes a no-op until a real client
145
+ * is available, mirroring how `useWebSocket().subscribe` behaves before
146
+ * the socket opens.
147
+ *
148
+ * function HostShell() {
149
+ * const ws = useWebSocket()
150
+ * useManifestHotSwapSubscriber(ws)
151
+ * return <Outlet />
152
+ * }
153
+ */
154
+ export function useManifestHotSwapSubscriber(
155
+ client: ManifestHotSwapClient | undefined | null,
156
+ options: WireHotSwapInvalidationOptions = {},
157
+ ): void {
158
+ const { matcher, onSwap } = options
159
+ useEffect(() => {
160
+ if (!client) return
161
+ const unsubscribe = wireHotSwapInvalidation(client, { matcher, onSwap })
162
+ return unsubscribe
163
+ }, [client, matcher, onSwap])
164
+ }
@@ -14,6 +14,31 @@ export interface MetadataApiClient {
14
14
  get: (url: string, config?: any) => Promise<{ data: any }>
15
15
  }
16
16
 
17
+ /**
18
+ * Predicate matching a cache key against an addon. The default
19
+ * implementation (see {@link defaultAddonKeyMatcher}) treats `addonKey`
20
+ * itself plus any key beginning with `${addonKey}.`, `${addonKey}:` or
21
+ * `${addonKey}/` as belonging to the addon. Hosts that namespace their
22
+ * `model` strings differently can pass a custom matcher to
23
+ * {@link MetadataCacheState.invalidateAddon}.
24
+ */
25
+ export type AddonKeyMatcher = (cacheKey: string, addonKey: string) => boolean
26
+
27
+ /**
28
+ * Default matcher used by {@link MetadataCacheState.invalidateAddon}.
29
+ * Mirrors the convention used by the kernel installer when it scopes
30
+ * model metadata under an addon's key.
31
+ */
32
+ export function defaultAddonKeyMatcher(cacheKey: string, addonKey: string): boolean {
33
+ if (!addonKey) return false
34
+ if (cacheKey === addonKey) return true
35
+ return (
36
+ cacheKey.startsWith(`${addonKey}.`) ||
37
+ cacheKey.startsWith(`${addonKey}:`) ||
38
+ cacheKey.startsWith(`${addonKey}/`)
39
+ )
40
+ }
41
+
17
42
  interface MetadataCacheState {
18
43
  cache: Record<string, TableMetadata>
19
44
  modalCache: Record<string, TableMetadata>
@@ -26,6 +51,26 @@ interface MetadataCacheState {
26
51
  hasMetadata: (key: string) => boolean
27
52
  hasModalMetadata: (key: string) => boolean
28
53
  prefetchAll: (api: MetadataApiClient) => Promise<void>
54
+ /**
55
+ * Remove cached entries belonging to a specific addon. Used by the
56
+ * hot-swap subscriber when the kernel announces a manifest change.
57
+ *
58
+ * Returns the number of entries removed across both caches, which is
59
+ * useful for tests and observability — `0` means the cache had nothing
60
+ * to flush (the addon either hadn't been hit yet or uses a key
61
+ * convention the default matcher doesn't recognise; pass a custom
62
+ * `matcher` if your host namespaces differently).
63
+ *
64
+ * Also resets `prefetched` to `false` so the next mount re-runs
65
+ * `prefetchAll()` and the `metadataVersion` is allowed to advance to
66
+ * the kernel's freshly-bumped hash.
67
+ */
68
+ invalidateAddon: (addonKey: string, matcher?: AddonKeyMatcher) => number
69
+ /**
70
+ * Remove every cached entry. Heavier hammer for hosts that prefer a
71
+ * blanket flush on hot-swap rather than per-addon scoping.
72
+ */
73
+ clearAll: () => void
29
74
  }
30
75
 
31
76
  export const useMetadataCache = create<MetadataCacheState>()(
@@ -54,6 +99,47 @@ export const useMetadataCache = create<MetadataCacheState>()(
54
99
  hasMetadata: (key: string) => key in get().cache,
55
100
  hasModalMetadata: (key: string) => key in get().modalCache,
56
101
 
102
+ invalidateAddon: (addonKey: string, matcher = defaultAddonKeyMatcher) => {
103
+ if (!addonKey) return 0
104
+ let removed = 0
105
+ const state = get()
106
+ const nextCache: Record<string, TableMetadata> = {}
107
+ for (const [key, value] of Object.entries(state.cache)) {
108
+ if (matcher(key, addonKey)) {
109
+ removed += 1
110
+ continue
111
+ }
112
+ nextCache[key] = value
113
+ }
114
+ const nextModalCache: Record<string, TableMetadata> = {}
115
+ for (const [key, value] of Object.entries(state.modalCache)) {
116
+ if (matcher(key, addonKey)) {
117
+ removed += 1
118
+ continue
119
+ }
120
+ nextModalCache[key] = value
121
+ }
122
+ if (removed === 0) return 0
123
+ set({
124
+ cache: nextCache,
125
+ modalCache: nextModalCache,
126
+ // Allow prefetchAll() to re-run so we pick up the new
127
+ // metadataVersion the kernel emits alongside the
128
+ // bumped manifest.
129
+ prefetched: false,
130
+ })
131
+ return removed
132
+ },
133
+
134
+ clearAll: () => {
135
+ set({
136
+ cache: {},
137
+ modalCache: {},
138
+ metadataVersion: '',
139
+ prefetched: false,
140
+ })
141
+ },
142
+
57
143
  prefetchAll: async (api: MetadataApiClient) => {
58
144
  if (get().prefetched) return
59
145
  try {
package/src/slot.tsx CHANGED
@@ -22,6 +22,8 @@ class SlotRegistryImpl {
22
22
  const entry: SlotEntry = { id: slotId, component, priority: opts?.priority ?? 0, source: opts?.source }
23
23
  const list = this.slots.get(slotId) ?? []
24
24
  list.push(entry)
25
+ // Higher priority renders first — canonical across SDK and runtime-react.
26
+ // See docs/slot-priority.md.
25
27
  list.sort((a, b) => b.priority - a.priority)
26
28
  this.slots.set(slotId, list)
27
29
  this.emit()