@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.
- package/CHANGELOG.md +162 -0
- package/dist/addon-layout-context.d.ts +49 -0
- package/dist/addon-layout-context.d.ts.map +1 -0
- package/dist/addon-layout-context.js +94 -0
- package/dist/addon-loader.d.ts +14 -2
- package/dist/addon-loader.d.ts.map +1 -1
- package/dist/addon-loader.js +7 -1
- package/dist/hotswap-reload-policy.d.ts +155 -0
- package/dist/hotswap-reload-policy.d.ts.map +1 -0
- package/dist/hotswap-reload-policy.js +227 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/manifest-hotswap-subscriber.d.ts +83 -0
- package/dist/manifest-hotswap-subscriber.d.ts.map +1 -0
- package/dist/manifest-hotswap-subscriber.js +104 -0
- package/dist/metadata-cache.d.ts +35 -0
- package/dist/metadata-cache.d.ts.map +1 -1
- package/dist/metadata-cache.js +55 -0
- package/dist/slot.d.ts.map +1 -1
- package/dist/slot.js +2 -0
- package/package.json +3 -3
- package/src/__tests__/hotswap-reload-policy.test.ts +249 -0
- package/src/__tests__/manifest-hotswap-subscriber.test.ts +179 -0
- package/src/__tests__/slot.test.ts +70 -0
- package/src/__tests__/wasm-client-sri.test.ts +82 -0
- package/src/addon-layout-context.tsx +137 -0
- package/src/addon-loader.tsx +21 -1
- package/src/hotswap-reload-policy.ts +360 -0
- package/src/index.ts +28 -0
- package/src/manifest-hotswap-subscriber.ts +164 -0
- package/src/metadata-cache.ts +86 -0
- package/src/slot.tsx +2 -0
|
@@ -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
|
+
}
|
package/src/metadata-cache.ts
CHANGED
|
@@ -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()
|