@astrale-os/shell 0.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/dist/adapters/iframe.shell-adapter.d.ts +10 -0
- package/dist/adapters/iframe.shell-adapter.d.ts.map +1 -0
- package/dist/adapters/iframe.shell-adapter.js +55 -0
- package/dist/adapters/kernel-client.adapter.d.ts +53 -0
- package/dist/adapters/kernel-client.adapter.d.ts.map +1 -0
- package/dist/adapters/kernel-client.adapter.js +69 -0
- package/dist/adapters/message-port.transport.d.ts +7 -0
- package/dist/adapters/message-port.transport.d.ts.map +1 -0
- package/dist/adapters/message-port.transport.js +42 -0
- package/dist/application/channels/children-channel.d.ts +21 -0
- package/dist/application/channels/children-channel.d.ts.map +1 -0
- package/dist/application/channels/children-channel.js +82 -0
- package/dist/application/channels/observer.d.ts +9 -0
- package/dist/application/channels/observer.d.ts.map +1 -0
- package/dist/application/channels/observer.js +23 -0
- package/dist/application/channels/parent-channel.d.ts +17 -0
- package/dist/application/channels/parent-channel.d.ts.map +1 -0
- package/dist/application/channels/parent-channel.js +21 -0
- package/dist/application/channels/types.d.ts +12 -0
- package/dist/application/channels/types.d.ts.map +1 -0
- package/dist/application/channels/types.js +29 -0
- package/dist/application/intent/pipeline-runner.d.ts +12 -0
- package/dist/application/intent/pipeline-runner.d.ts.map +1 -0
- package/dist/application/intent/pipeline-runner.js +27 -0
- package/dist/application/intent/request.d.ts +34 -0
- package/dist/application/intent/request.d.ts.map +1 -0
- package/dist/application/intent/request.js +70 -0
- package/dist/application/intent/router.d.ts +56 -0
- package/dist/application/intent/router.d.ts.map +1 -0
- package/dist/application/intent/router.js +94 -0
- package/dist/application/kernel-facade.d.ts +33 -0
- package/dist/application/kernel-facade.d.ts.map +1 -0
- package/dist/application/kernel-facade.js +77 -0
- package/dist/application/shell.d.ts +100 -0
- package/dist/application/shell.d.ts.map +1 -0
- package/dist/application/shell.js +256 -0
- package/dist/application/windowing/child-init.d.ts +23 -0
- package/dist/application/windowing/child-init.d.ts.map +1 -0
- package/dist/application/windowing/child-init.js +101 -0
- package/dist/application/windowing/handshake.d.ts +30 -0
- package/dist/application/windowing/handshake.d.ts.map +1 -0
- package/dist/application/windowing/handshake.js +63 -0
- package/dist/application/windowing/lifecycle-controller.d.ts +10 -0
- package/dist/application/windowing/lifecycle-controller.d.ts.map +1 -0
- package/dist/application/windowing/lifecycle-controller.js +30 -0
- package/dist/application/windowing/mount-controller.d.ts +25 -0
- package/dist/application/windowing/mount-controller.d.ts.map +1 -0
- package/dist/application/windowing/mount-controller.js +277 -0
- package/dist/application/windowing/protocol.d.ts +21 -0
- package/dist/application/windowing/protocol.d.ts.map +1 -0
- package/dist/application/windowing/protocol.js +19 -0
- package/dist/application/windowing/token-refresh.d.ts +19 -0
- package/dist/application/windowing/token-refresh.d.ts.map +1 -0
- package/dist/application/windowing/token-refresh.js +60 -0
- package/dist/domain/identity/delegation.d.ts +15 -0
- package/dist/domain/identity/delegation.d.ts.map +1 -0
- package/dist/domain/identity/delegation.js +12 -0
- package/dist/domain/identity/path.d.ts +11 -0
- package/dist/domain/identity/path.d.ts.map +1 -0
- package/dist/domain/identity/path.js +13 -0
- package/dist/domain/identity/resolve-self.d.ts +21 -0
- package/dist/domain/identity/resolve-self.d.ts.map +1 -0
- package/dist/domain/identity/resolve-self.js +36 -0
- package/dist/domain/intent/middleware/capabilities.d.ts +28 -0
- package/dist/domain/intent/middleware/capabilities.d.ts.map +1 -0
- package/dist/domain/intent/middleware/capabilities.js +18 -0
- package/dist/domain/intent/middleware/logging.d.ts +14 -0
- package/dist/domain/intent/middleware/logging.d.ts.map +1 -0
- package/dist/domain/intent/middleware/logging.js +21 -0
- package/dist/domain/intent/middleware/rate-limit.d.ts +20 -0
- package/dist/domain/intent/middleware/rate-limit.d.ts.map +1 -0
- package/dist/domain/intent/middleware/rate-limit.js +33 -0
- package/dist/domain/intent/pipeline.d.ts +3 -0
- package/dist/domain/intent/pipeline.d.ts.map +1 -0
- package/dist/domain/intent/pipeline.js +1 -0
- package/dist/domain/intent/router.d.ts +6 -0
- package/dist/domain/intent/router.d.ts.map +1 -0
- package/dist/domain/intent/router.js +1 -0
- package/dist/domain/message/errors.d.ts +2 -0
- package/dist/domain/message/errors.d.ts.map +1 -0
- package/dist/domain/message/errors.js +1 -0
- package/dist/domain/message/registries.d.ts +145 -0
- package/dist/domain/message/registries.d.ts.map +1 -0
- package/dist/domain/message/registries.js +1 -0
- package/dist/domain/message/types.d.ts +30 -0
- package/dist/domain/message/types.d.ts.map +1 -0
- package/dist/domain/message/types.js +1 -0
- package/dist/domain/window/config.d.ts +26 -0
- package/dist/domain/window/config.d.ts.map +1 -0
- package/dist/domain/window/config.js +1 -0
- package/dist/domain/window/lifecycle.d.ts +27 -0
- package/dist/domain/window/lifecycle.d.ts.map +1 -0
- package/dist/domain/window/lifecycle.js +36 -0
- package/dist/domain/window/window.d.ts +12 -0
- package/dist/domain/window/window.d.ts.map +1 -0
- package/dist/domain/window/window.js +1 -0
- package/dist/index.d.ts +42 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/ports/kernel-client.d.ts +33 -0
- package/dist/ports/kernel-client.d.ts.map +1 -0
- package/dist/ports/kernel-client.js +1 -0
- package/dist/ports/shell-adapter.d.ts +13 -0
- package/dist/ports/shell-adapter.d.ts.map +1 -0
- package/dist/ports/shell-adapter.js +1 -0
- package/dist/ports/transport.d.ts +7 -0
- package/dist/ports/transport.d.ts.map +1 -0
- package/dist/ports/transport.js +1 -0
- package/package.json +41 -0
- package/src/adapters/iframe.shell-adapter.ts +68 -0
- package/src/adapters/kernel-client.adapter.ts +138 -0
- package/src/adapters/message-port.transport.ts +50 -0
- package/src/application/channels/children-channel.ts +112 -0
- package/src/application/channels/observer.ts +33 -0
- package/src/application/channels/parent-channel.ts +43 -0
- package/src/application/channels/types.ts +41 -0
- package/src/application/intent/pipeline-runner.ts +36 -0
- package/src/application/intent/request.ts +87 -0
- package/src/application/intent/router.ts +161 -0
- package/src/application/kernel-facade.ts +143 -0
- package/src/application/shell.ts +433 -0
- package/src/application/windowing/child-init.ts +137 -0
- package/src/application/windowing/handshake.ts +98 -0
- package/src/application/windowing/lifecycle-controller.ts +40 -0
- package/src/application/windowing/mount-controller.ts +335 -0
- package/src/application/windowing/protocol.ts +37 -0
- package/src/application/windowing/token-refresh.ts +78 -0
- package/src/domain/identity/delegation.ts +25 -0
- package/src/domain/identity/path.ts +14 -0
- package/src/domain/identity/resolve-self.ts +38 -0
- package/src/domain/intent/middleware/capabilities.ts +53 -0
- package/src/domain/intent/middleware/logging.ts +45 -0
- package/src/domain/intent/middleware/rate-limit.ts +54 -0
- package/src/domain/intent/pipeline.ts +3 -0
- package/src/domain/intent/router.ts +6 -0
- package/src/domain/message/errors.ts +9 -0
- package/src/domain/message/registries.ts +76 -0
- package/src/domain/message/types.ts +32 -0
- package/src/domain/window/config.ts +28 -0
- package/src/domain/window/lifecycle.ts +39 -0
- package/src/domain/window/window.ts +13 -0
- package/src/index.ts +103 -0
- package/src/ports/kernel-client.ts +32 -0
- package/src/ports/shell-adapter.ts +11 -0
- package/src/ports/transport.ts +7 -0
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
import type { FunctionSchema } from '@astrale-os/kernel-client'
|
|
2
|
+
|
|
3
|
+
import type { IntentMiddleware } from '../domain/intent/pipeline'
|
|
4
|
+
import type { IntentRegistry } from '../domain/message/registries'
|
|
5
|
+
import type { WindowCapabilities, SandboxProfile } from '../domain/window/config'
|
|
6
|
+
import type { Window } from '../domain/window/window'
|
|
7
|
+
import type { KernelClient } from '../ports/kernel-client'
|
|
8
|
+
import type { ShellAdapter } from '../ports/shell-adapter'
|
|
9
|
+
import type { ChildrenChannel, ChildrenChannelController } from './channels/children-channel'
|
|
10
|
+
import type { MessageObserver, ObserverHandler } from './channels/observer'
|
|
11
|
+
import type { ParentChannel, ParentChannelController } from './channels/parent-channel'
|
|
12
|
+
import type { IntentPipeline } from './intent/pipeline-runner'
|
|
13
|
+
import type { LocalIntentHandler } from './intent/router'
|
|
14
|
+
import type { KernelFacadeController } from './kernel-facade'
|
|
15
|
+
import type { LifecycleController } from './windowing/lifecycle-controller'
|
|
16
|
+
import type { MountController } from './windowing/mount-controller'
|
|
17
|
+
|
|
18
|
+
import { createKernelClientAdapter } from '../adapters/kernel-client.adapter'
|
|
19
|
+
import { createChildrenChannel } from './channels/children-channel'
|
|
20
|
+
import { createMessageObserver } from './channels/observer'
|
|
21
|
+
import { createIntentPipeline } from './intent/pipeline-runner'
|
|
22
|
+
import { createIntentRouter } from './intent/router'
|
|
23
|
+
import { createKernelFacade } from './kernel-facade'
|
|
24
|
+
import { performChildInit } from './windowing/child-init'
|
|
25
|
+
import { createLifecycleController } from './windowing/lifecycle-controller'
|
|
26
|
+
import { createMountController } from './windowing/mount-controller'
|
|
27
|
+
|
|
28
|
+
// ─── Re-exports ─────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
export type { MessageDirection, ObserverHandler } from './channels/observer'
|
|
31
|
+
export type { ParentChannel } from './channels/parent-channel'
|
|
32
|
+
export type { ChildrenChannel } from './channels/children-channel'
|
|
33
|
+
export type { MessageHandler } from './channels/types'
|
|
34
|
+
|
|
35
|
+
// ─── Mount API ──────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
export type MountParams = {
|
|
38
|
+
readonly host: HTMLElement
|
|
39
|
+
readonly url: string
|
|
40
|
+
readonly functionId: string
|
|
41
|
+
readonly capabilities: WindowCapabilities
|
|
42
|
+
readonly targetNodeId?: string
|
|
43
|
+
/** See `WindowConfig.sandbox` — `null` opts the iframe out of the attribute. */
|
|
44
|
+
readonly sandbox?: SandboxProfile | null
|
|
45
|
+
readonly handshakeTimeoutMs?: number
|
|
46
|
+
readonly delegationTtlSeconds?: number
|
|
47
|
+
/**
|
|
48
|
+
* `'shell'` (default) runs the parent-side handshake, opening a MessagePort
|
|
49
|
+
* transport with delegation tokens and an intent channel. `'none'` mounts a
|
|
50
|
+
* plain iframe with no transport — used for hosted views that don't speak
|
|
51
|
+
* the shell protocol (`BlaxelComputer.deployFunction(kind:'view')`), so they
|
|
52
|
+
* render immediately instead of paying the 5 s handshake timeout. In `'none'`
|
|
53
|
+
* mode `capabilities` / `targetNodeId` / `delegationTtlSeconds` are inert
|
|
54
|
+
* (no channel to deliver them).
|
|
55
|
+
*/
|
|
56
|
+
readonly handshake?: 'shell' | 'none'
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type CloseResult =
|
|
60
|
+
| { kind: 'closed' }
|
|
61
|
+
| { kind: 'refused'; reason?: string }
|
|
62
|
+
| { kind: 'noop' }
|
|
63
|
+
|
|
64
|
+
export type MountedWindow = {
|
|
65
|
+
readonly windowId: string
|
|
66
|
+
readonly window: Window
|
|
67
|
+
readonly handle: { element: HTMLIFrameElement | HTMLElement }
|
|
68
|
+
close(opts?: { force?: boolean; timeoutMs?: number }): Promise<CloseResult>
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ─── Shell ──────────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
export interface Shell {
|
|
74
|
+
readonly parent: ParentChannel | null
|
|
75
|
+
readonly children: ChildrenChannel
|
|
76
|
+
readonly kernel: KernelClient
|
|
77
|
+
readonly windows: ReadonlyMap<string, Window>
|
|
78
|
+
readonly targetNodeId?: string
|
|
79
|
+
|
|
80
|
+
use(middleware: IntentMiddleware): Shell
|
|
81
|
+
onMessage(handler: ObserverHandler): () => void
|
|
82
|
+
onIntent<K extends keyof IntentRegistry>(name: K, handler: LocalIntentHandler<K>): () => void
|
|
83
|
+
|
|
84
|
+
mount(params: MountParams): Promise<MountedWindow>
|
|
85
|
+
close(windowId: string, opts?: { force?: boolean; timeoutMs?: number }): Promise<CloseResult>
|
|
86
|
+
|
|
87
|
+
init(): Promise<Shell>
|
|
88
|
+
dispose(): Promise<void>
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ─── Config ─────────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
export type StandaloneConfig = {
|
|
94
|
+
readonly mode: 'standalone'
|
|
95
|
+
readonly kernelUrl: string
|
|
96
|
+
readonly credential?: string
|
|
97
|
+
readonly getCredential?: () => string
|
|
98
|
+
readonly mintIdentity?: string
|
|
99
|
+
readonly schema?: FunctionSchema[]
|
|
100
|
+
readonly adapter?: ShellAdapter
|
|
101
|
+
readonly windowId?: string
|
|
102
|
+
/**
|
|
103
|
+
* Optional wire-layer `fetch` shim for the kernel transport. A cookie-fronted
|
|
104
|
+
* SPA passes a wrapper adding `credentials:'include'` + refresh-on-401 and an
|
|
105
|
+
* empty `credential`, so a router unseals the cookie into a Bearer (the SPA
|
|
106
|
+
* holds no token). Omit it (the default) for token-based callers — including
|
|
107
|
+
* every sandboxed child view, which authenticates with a delegation token.
|
|
108
|
+
*/
|
|
109
|
+
readonly fetch?: typeof fetch
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export type SandboxedConfig = {
|
|
113
|
+
readonly mode: 'sandboxed'
|
|
114
|
+
readonly schema?: FunctionSchema[]
|
|
115
|
+
readonly parentWindow?: WindowProxy
|
|
116
|
+
readonly initTimeoutMs?: number
|
|
117
|
+
readonly adapter?: ShellAdapter
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export type ShellConfig = StandaloneConfig | SandboxedConfig
|
|
121
|
+
|
|
122
|
+
const DEFAULT_ROOT_WINDOW_ID = 'root'
|
|
123
|
+
/** Trusted sender id stamped on intents arriving from the parent channel. */
|
|
124
|
+
const PARENT_SENDER_ID = '<parent>'
|
|
125
|
+
|
|
126
|
+
// ─── Factory ────────────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
export function createShell(config: ShellConfig): Shell {
|
|
129
|
+
return config.mode === 'standalone' ? createStandaloneShell(config) : createSandboxedShell(config)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ─── Standalone ─────────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
function createStandaloneShell(config: StandaloneConfig): Shell {
|
|
135
|
+
const windowId = config.windowId ?? DEFAULT_ROOT_WINDOW_ID
|
|
136
|
+
const observer = createMessageObserver()
|
|
137
|
+
const getCredential =
|
|
138
|
+
config.getCredential ??
|
|
139
|
+
(() => {
|
|
140
|
+
if (config.credential === undefined) {
|
|
141
|
+
throw new Error('Standalone shell requires credential or getCredential')
|
|
142
|
+
}
|
|
143
|
+
return config.credential
|
|
144
|
+
})
|
|
145
|
+
const adapter = createKernelClientAdapter({
|
|
146
|
+
url: config.kernelUrl,
|
|
147
|
+
schema: config.schema,
|
|
148
|
+
getCredential,
|
|
149
|
+
mintIdentity: config.mintIdentity,
|
|
150
|
+
fetch: config.fetch,
|
|
151
|
+
})
|
|
152
|
+
const kernel = createKernelFacade({
|
|
153
|
+
adapter,
|
|
154
|
+
getCredential,
|
|
155
|
+
url: config.kernelUrl,
|
|
156
|
+
mintIdentity: config.mintIdentity,
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
const lifecycle = createLifecycleController()
|
|
160
|
+
const windows = new Map<string, Window>()
|
|
161
|
+
|
|
162
|
+
const childrenChannel = createChildrenChannel({
|
|
163
|
+
observer,
|
|
164
|
+
onDeliveryFailed: (toWindowId) => lifecycle.markCrashed(toWindowId),
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
const pipeline = createIntentPipeline()
|
|
168
|
+
const router = createIntentRouter({
|
|
169
|
+
selfWindowId: windowId,
|
|
170
|
+
pipeline,
|
|
171
|
+
parent: null,
|
|
172
|
+
children: childrenChannel,
|
|
173
|
+
windows,
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
childrenChannel.on('intent', (windowId, message) => {
|
|
177
|
+
void router.handleInbound('child', message, windowId)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
let mountController: MountController | null = null
|
|
181
|
+
function getMountController(): MountController {
|
|
182
|
+
if (mountController) return mountController
|
|
183
|
+
if (!config.adapter) {
|
|
184
|
+
throw new Error('Shell.mount() requires a ShellAdapter in the config')
|
|
185
|
+
}
|
|
186
|
+
mountController = createMountController({
|
|
187
|
+
adapter: config.adapter,
|
|
188
|
+
kernel,
|
|
189
|
+
kernelUrl: config.kernelUrl,
|
|
190
|
+
mintIdentity: config.mintIdentity,
|
|
191
|
+
childrenChannel,
|
|
192
|
+
windows,
|
|
193
|
+
lifecycle,
|
|
194
|
+
})
|
|
195
|
+
return mountController
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return buildShellFacade({
|
|
199
|
+
parent: null,
|
|
200
|
+
childrenChannel,
|
|
201
|
+
kernel,
|
|
202
|
+
pipeline,
|
|
203
|
+
observer,
|
|
204
|
+
lifecycle,
|
|
205
|
+
windows,
|
|
206
|
+
getMountController,
|
|
207
|
+
onIntent: (name, handler) => router.onLocal(name, handler),
|
|
208
|
+
onDispose: async () => {
|
|
209
|
+
if (mountController) await mountController.dispose()
|
|
210
|
+
childrenChannel.dispose()
|
|
211
|
+
adapter.disconnect()
|
|
212
|
+
},
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ─── Sandboxed child ────────────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
type SandboxedState = {
|
|
219
|
+
windowId: string
|
|
220
|
+
kernelUrl: string
|
|
221
|
+
targetNodeId?: string
|
|
222
|
+
mintIdentity?: string
|
|
223
|
+
kernel: KernelFacadeController
|
|
224
|
+
parent: ParentChannelController
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function createSandboxedShell(config: SandboxedConfig): Shell {
|
|
228
|
+
const observer = createMessageObserver()
|
|
229
|
+
const lifecycle = createLifecycleController()
|
|
230
|
+
const windows = new Map<string, Window>()
|
|
231
|
+
const childrenChannel = createChildrenChannel({
|
|
232
|
+
observer,
|
|
233
|
+
onDeliveryFailed: (toWindowId) => lifecycle.markCrashed(toWindowId),
|
|
234
|
+
})
|
|
235
|
+
const pipeline = createIntentPipeline()
|
|
236
|
+
|
|
237
|
+
let state: SandboxedState | null = null
|
|
238
|
+
let router: ReturnType<typeof createIntentRouter> | null = null
|
|
239
|
+
let mountController: MountController | null = null
|
|
240
|
+
|
|
241
|
+
// Buffer intent subscriptions made before `init()` resolves — the router
|
|
242
|
+
// is only built once the handshake completes, but consumers want to call
|
|
243
|
+
// `onIntent` at construction time.
|
|
244
|
+
type PendingIntentSub = {
|
|
245
|
+
name: string
|
|
246
|
+
handler: LocalIntentHandler
|
|
247
|
+
unsubscribed: boolean
|
|
248
|
+
off?: () => void
|
|
249
|
+
}
|
|
250
|
+
const pendingIntentSubs: PendingIntentSub[] = []
|
|
251
|
+
|
|
252
|
+
function subscribeIntent<K extends keyof IntentRegistry>(
|
|
253
|
+
name: K,
|
|
254
|
+
handler: LocalIntentHandler<K>,
|
|
255
|
+
): () => void {
|
|
256
|
+
if (router) return router.onLocal(name, handler)
|
|
257
|
+
const entry: PendingIntentSub = {
|
|
258
|
+
name: name as string,
|
|
259
|
+
handler: handler as LocalIntentHandler,
|
|
260
|
+
unsubscribed: false,
|
|
261
|
+
}
|
|
262
|
+
pendingIntentSubs.push(entry)
|
|
263
|
+
return () => {
|
|
264
|
+
entry.unsubscribed = true
|
|
265
|
+
// After init, `off` is the router's real disposer (set during replay);
|
|
266
|
+
// before init, flipping `unsubscribed` is enough (replay skips it).
|
|
267
|
+
entry.off?.()
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const requireState = (): SandboxedState => {
|
|
272
|
+
if (!state) throw new Error('Sandboxed shell not initialized — call shell.init() first')
|
|
273
|
+
return state
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const kernelProxy: KernelClient = {
|
|
277
|
+
call: (path, params) => requireState().kernel.call(path, params),
|
|
278
|
+
mintDelegation: () => requireState().kernel.mintDelegation(),
|
|
279
|
+
authToken: () => requireState().kernel.authToken(),
|
|
280
|
+
get url() {
|
|
281
|
+
return requireState().kernel.url
|
|
282
|
+
},
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const parentProxy: ParentChannel = {
|
|
286
|
+
send: (message) => requireState().parent.send(message),
|
|
287
|
+
on: (type, handler) => requireState().parent.on(type, handler),
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function getMountController(): MountController {
|
|
291
|
+
if (mountController) return mountController
|
|
292
|
+
if (!config.adapter) {
|
|
293
|
+
throw new Error('Shell.mount() requires a ShellAdapter in the config')
|
|
294
|
+
}
|
|
295
|
+
const current = requireState()
|
|
296
|
+
mountController = createMountController({
|
|
297
|
+
adapter: config.adapter,
|
|
298
|
+
kernel: kernelProxy,
|
|
299
|
+
kernelUrl: current.kernelUrl,
|
|
300
|
+
mintIdentity: current.mintIdentity,
|
|
301
|
+
childrenChannel,
|
|
302
|
+
windows,
|
|
303
|
+
lifecycle,
|
|
304
|
+
})
|
|
305
|
+
return mountController
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return buildShellFacade({
|
|
309
|
+
parent: parentProxy,
|
|
310
|
+
childrenChannel,
|
|
311
|
+
kernel: kernelProxy,
|
|
312
|
+
pipeline,
|
|
313
|
+
observer,
|
|
314
|
+
lifecycle,
|
|
315
|
+
windows,
|
|
316
|
+
getMountController,
|
|
317
|
+
targetNodeId: () => state?.targetNodeId,
|
|
318
|
+
onIntent: subscribeIntent,
|
|
319
|
+
init: async () => {
|
|
320
|
+
const result = await performChildInit({
|
|
321
|
+
parent: config.parentWindow ?? window.parent,
|
|
322
|
+
timeoutMs: config.initTimeoutMs,
|
|
323
|
+
schema: config.schema,
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
state = {
|
|
327
|
+
windowId: result.windowId,
|
|
328
|
+
kernelUrl: result.kernelUrl,
|
|
329
|
+
targetNodeId: result.targetNodeId,
|
|
330
|
+
mintIdentity: result.mintIdentity,
|
|
331
|
+
kernel: result.kernel,
|
|
332
|
+
parent: result.parentChannel(observer),
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
router = createIntentRouter({
|
|
336
|
+
selfWindowId: state.windowId,
|
|
337
|
+
pipeline,
|
|
338
|
+
parent: state.parent,
|
|
339
|
+
children: childrenChannel,
|
|
340
|
+
windows,
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
childrenChannel.on('intent', (windowId, message) => {
|
|
344
|
+
void router!.handleInbound('child', message, windowId)
|
|
345
|
+
})
|
|
346
|
+
state.parent.on('intent', (message) => {
|
|
347
|
+
void router!.handleInbound('parent', message, PARENT_SENDER_ID)
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
// Replay subscriptions registered before init. Capture each real disposer
|
|
351
|
+
// so a post-init unsubscribe (via the closure returned at subscribe time)
|
|
352
|
+
// actually removes the handler from the router instead of no-op'ing.
|
|
353
|
+
for (const entry of pendingIntentSubs) {
|
|
354
|
+
if (entry.unsubscribed) continue
|
|
355
|
+
entry.off = router.onLocal(entry.name as keyof IntentRegistry, entry.handler)
|
|
356
|
+
}
|
|
357
|
+
pendingIntentSubs.length = 0
|
|
358
|
+
},
|
|
359
|
+
onDispose: async () => {
|
|
360
|
+
if (mountController) await mountController.dispose()
|
|
361
|
+
childrenChannel.dispose()
|
|
362
|
+
state?.parent.dispose()
|
|
363
|
+
state?.kernel.dispose()
|
|
364
|
+
},
|
|
365
|
+
})
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ─── Shared facade builder ──────────────────────────────────────────────────
|
|
369
|
+
|
|
370
|
+
type FacadeDeps = {
|
|
371
|
+
parent: ParentChannel | null
|
|
372
|
+
childrenChannel: ChildrenChannelController
|
|
373
|
+
kernel: KernelClient
|
|
374
|
+
pipeline: IntentPipeline
|
|
375
|
+
observer: MessageObserver
|
|
376
|
+
lifecycle: LifecycleController
|
|
377
|
+
windows: Map<string, Window>
|
|
378
|
+
getMountController: () => MountController
|
|
379
|
+
onIntent: <K extends keyof IntentRegistry>(name: K, handler: LocalIntentHandler<K>) => () => void
|
|
380
|
+
targetNodeId?: () => string | undefined
|
|
381
|
+
init?: () => Promise<void>
|
|
382
|
+
onDispose: () => Promise<void>
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function buildShellFacade(deps: FacadeDeps): Shell {
|
|
386
|
+
let disposed = false
|
|
387
|
+
|
|
388
|
+
const shell: Shell = {
|
|
389
|
+
get parent() {
|
|
390
|
+
return deps.parent
|
|
391
|
+
},
|
|
392
|
+
children: deps.childrenChannel,
|
|
393
|
+
kernel: deps.kernel,
|
|
394
|
+
windows: deps.windows,
|
|
395
|
+
get targetNodeId() {
|
|
396
|
+
return deps.targetNodeId?.()
|
|
397
|
+
},
|
|
398
|
+
|
|
399
|
+
use(middleware) {
|
|
400
|
+
deps.pipeline.use(middleware)
|
|
401
|
+
return shell
|
|
402
|
+
},
|
|
403
|
+
|
|
404
|
+
onMessage(handler) {
|
|
405
|
+
return deps.observer.subscribe(handler)
|
|
406
|
+
},
|
|
407
|
+
|
|
408
|
+
onIntent(name, handler) {
|
|
409
|
+
return deps.onIntent(name, handler)
|
|
410
|
+
},
|
|
411
|
+
|
|
412
|
+
mount(params) {
|
|
413
|
+
return deps.getMountController().mount(params)
|
|
414
|
+
},
|
|
415
|
+
|
|
416
|
+
close(windowId, opts) {
|
|
417
|
+
return deps.getMountController().close(windowId, opts)
|
|
418
|
+
},
|
|
419
|
+
|
|
420
|
+
async init() {
|
|
421
|
+
if (deps.init) await deps.init()
|
|
422
|
+
return shell
|
|
423
|
+
},
|
|
424
|
+
|
|
425
|
+
async dispose() {
|
|
426
|
+
if (disposed) return
|
|
427
|
+
disposed = true
|
|
428
|
+
await deps.onDispose()
|
|
429
|
+
},
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return shell
|
|
433
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { FunctionSchema } from '@astrale-os/kernel-client'
|
|
2
|
+
|
|
3
|
+
import type { DelegationToken } from '../../domain/identity/delegation'
|
|
4
|
+
import type { CtrlMessage } from '../../domain/message/types'
|
|
5
|
+
import type { MessageObserver } from '../channels/observer'
|
|
6
|
+
import type { ParentChannelController } from '../channels/parent-channel'
|
|
7
|
+
|
|
8
|
+
import { createKernelClientAdapter } from '../../adapters/kernel-client.adapter'
|
|
9
|
+
import { createMessagePortTransport } from '../../adapters/message-port.transport'
|
|
10
|
+
import { createParentChannel } from '../channels/parent-channel'
|
|
11
|
+
import {
|
|
12
|
+
createDelegationKernelFacade,
|
|
13
|
+
type DelegationKernelFacadeController,
|
|
14
|
+
} from '../kernel-facade'
|
|
15
|
+
import { INIT_REQUEST_TYPE, isInitResponse } from './protocol'
|
|
16
|
+
|
|
17
|
+
export type ChildInitParams = {
|
|
18
|
+
readonly parent: WindowProxy
|
|
19
|
+
readonly timeoutMs?: number
|
|
20
|
+
readonly schema?: FunctionSchema[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type ChildInitResult = {
|
|
24
|
+
readonly windowId: string
|
|
25
|
+
readonly parentChannel: (observer: MessageObserver) => ParentChannelController
|
|
26
|
+
readonly kernel: DelegationKernelFacadeController
|
|
27
|
+
readonly initialToken: DelegationToken
|
|
28
|
+
readonly functionId: string
|
|
29
|
+
readonly kernelUrl: string
|
|
30
|
+
readonly targetNodeId?: string
|
|
31
|
+
readonly mintIdentity?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const DEFAULT_TIMEOUT = 5_000
|
|
35
|
+
|
|
36
|
+
/** Child-side init: post INIT_REQUEST, receive MessagePort + ctrl:handshake, ack. */
|
|
37
|
+
export function performChildInit(params: ChildInitParams): Promise<ChildInitResult> {
|
|
38
|
+
const { parent } = params
|
|
39
|
+
const timeoutMs = params.timeoutMs ?? DEFAULT_TIMEOUT
|
|
40
|
+
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
let port: MessagePort | null = null
|
|
43
|
+
|
|
44
|
+
const timer = setTimeout(() => {
|
|
45
|
+
cleanup()
|
|
46
|
+
reject(new Error(`Shell child init timed out after ${timeoutMs}ms`))
|
|
47
|
+
}, timeoutMs)
|
|
48
|
+
|
|
49
|
+
function cleanup() {
|
|
50
|
+
clearTimeout(timer)
|
|
51
|
+
window.removeEventListener('message', onWindowMessage)
|
|
52
|
+
if (port) port.onmessage = null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function onHandshake(handshakePort: MessagePort) {
|
|
56
|
+
return (ev: MessageEvent) => {
|
|
57
|
+
const msg = ev.data
|
|
58
|
+
if (!msg || msg.type !== 'ctrl' || (msg as CtrlMessage).action !== 'handshake') return
|
|
59
|
+
|
|
60
|
+
const data = (msg as CtrlMessage<'handshake'>).data
|
|
61
|
+
handshakePort.postMessage({
|
|
62
|
+
type: 'ctrl',
|
|
63
|
+
version: 1,
|
|
64
|
+
action: 'handshakeAck',
|
|
65
|
+
data: { windowId: data.windowId },
|
|
66
|
+
} satisfies CtrlMessage<'handshakeAck'>)
|
|
67
|
+
cleanup()
|
|
68
|
+
|
|
69
|
+
const initialToken: DelegationToken = {
|
|
70
|
+
credential: data.delegationToken,
|
|
71
|
+
scope: { functionId: data.functionId },
|
|
72
|
+
issuedAt: Date.now(),
|
|
73
|
+
expiresAt: data.tokenExpiresAt,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let currentToken: DelegationToken = initialToken
|
|
77
|
+
const adapter = createKernelClientAdapter({
|
|
78
|
+
url: data.kernelUrl,
|
|
79
|
+
schema: params.schema,
|
|
80
|
+
getCredential: () => currentToken.credential,
|
|
81
|
+
mintIdentity: data.mintIdentity,
|
|
82
|
+
})
|
|
83
|
+
const kernel = createDelegationKernelFacade({
|
|
84
|
+
adapter,
|
|
85
|
+
initialToken,
|
|
86
|
+
url: data.kernelUrl,
|
|
87
|
+
mintIdentity: data.mintIdentity,
|
|
88
|
+
onTokenChange: (next) => {
|
|
89
|
+
currentToken = next
|
|
90
|
+
},
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
resolve({
|
|
94
|
+
windowId: data.windowId,
|
|
95
|
+
functionId: data.functionId,
|
|
96
|
+
kernelUrl: data.kernelUrl,
|
|
97
|
+
targetNodeId: data.targetNodeId,
|
|
98
|
+
mintIdentity: data.mintIdentity,
|
|
99
|
+
initialToken,
|
|
100
|
+
kernel,
|
|
101
|
+
parentChannel: (observer) => {
|
|
102
|
+
const transport = createMessagePortTransport({ port: handshakePort })
|
|
103
|
+
const channel = createParentChannel({ transport, observer })
|
|
104
|
+
|
|
105
|
+
channel.on('ctrl', (message) => {
|
|
106
|
+
if (message.action !== 'tokenRefresh') return
|
|
107
|
+
const refresh = message as CtrlMessage<'tokenRefresh'>
|
|
108
|
+
kernel.setToken({
|
|
109
|
+
credential: refresh.data.delegationToken,
|
|
110
|
+
scope: { functionId: data.functionId },
|
|
111
|
+
issuedAt: Date.now(),
|
|
112
|
+
expiresAt: refresh.data.tokenExpiresAt,
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
return channel
|
|
117
|
+
},
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function onWindowMessage(ev: MessageEvent) {
|
|
123
|
+
if (!isInitResponse(ev.data)) return
|
|
124
|
+
if (ev.source !== parent) return
|
|
125
|
+
const [receivedPort] = ev.ports
|
|
126
|
+
if (!receivedPort) return
|
|
127
|
+
port = receivedPort
|
|
128
|
+
port.onmessage = onHandshake(receivedPort)
|
|
129
|
+
port.start()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
window.addEventListener('message', onWindowMessage)
|
|
133
|
+
|
|
134
|
+
// targetOrigin="*" — the parent verifies our event.origin on its side.
|
|
135
|
+
parent.postMessage({ type: INIT_REQUEST_TYPE, version: 1 }, '*')
|
|
136
|
+
})
|
|
137
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { CtrlMessage, ShellMessage } from '../../domain/message/types'
|
|
2
|
+
|
|
3
|
+
import { INIT_RESPONSE_TYPE, isInitRequest } from './protocol'
|
|
4
|
+
|
|
5
|
+
export type HandshakeParams = {
|
|
6
|
+
readonly iframe: HTMLIFrameElement
|
|
7
|
+
readonly expectedOrigin: string
|
|
8
|
+
readonly windowId: string
|
|
9
|
+
readonly delegationToken: string
|
|
10
|
+
readonly tokenExpiresAt: number
|
|
11
|
+
readonly kernelUrl: string
|
|
12
|
+
readonly functionId: string
|
|
13
|
+
readonly targetNodeId?: string
|
|
14
|
+
readonly mintIdentity?: string
|
|
15
|
+
readonly timeoutMs: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type HandshakeError =
|
|
19
|
+
| { code: 'HANDSHAKE_TIMEOUT'; message: string }
|
|
20
|
+
| { code: 'ORIGIN_MISMATCH'; message: string; actual: string; expected: string }
|
|
21
|
+
| { code: 'HANDSHAKE_FAILED'; message: string }
|
|
22
|
+
|
|
23
|
+
export type HandshakeResult = {
|
|
24
|
+
readonly port: MessagePort
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Parent-side handshake: wait for INIT_REQUEST, transfer a MessagePort, complete ctrl:handshake. */
|
|
28
|
+
export function runParentHandshake(params: HandshakeParams): Promise<HandshakeResult> {
|
|
29
|
+
const { iframe, expectedOrigin, timeoutMs } = params
|
|
30
|
+
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
let port: MessagePort | null = null
|
|
33
|
+
const timer = setTimeout(() => {
|
|
34
|
+
cleanup()
|
|
35
|
+
reject({
|
|
36
|
+
code: 'HANDSHAKE_TIMEOUT',
|
|
37
|
+
message: `handshake timed out after ${timeoutMs}ms`,
|
|
38
|
+
} satisfies HandshakeError)
|
|
39
|
+
}, timeoutMs)
|
|
40
|
+
|
|
41
|
+
function cleanup() {
|
|
42
|
+
clearTimeout(timer)
|
|
43
|
+
window.removeEventListener('message', onInitRequest)
|
|
44
|
+
if (port) port.onmessage = null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function onInitRequest(event: MessageEvent) {
|
|
48
|
+
if (event.source !== iframe.contentWindow) return
|
|
49
|
+
if (!isInitRequest(event.data)) return
|
|
50
|
+
|
|
51
|
+
if (event.origin !== expectedOrigin) {
|
|
52
|
+
cleanup()
|
|
53
|
+
reject({
|
|
54
|
+
code: 'ORIGIN_MISMATCH',
|
|
55
|
+
message: `expected origin ${expectedOrigin}, got ${event.origin}`,
|
|
56
|
+
actual: event.origin,
|
|
57
|
+
expected: expectedOrigin,
|
|
58
|
+
} satisfies HandshakeError)
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const channel = new MessageChannel()
|
|
63
|
+
port = channel.port1
|
|
64
|
+
|
|
65
|
+
port.onmessage = (ev: MessageEvent) => {
|
|
66
|
+
const msg = ev.data as ShellMessage
|
|
67
|
+
if (msg && msg.type === 'ctrl' && (msg as CtrlMessage).action === 'handshakeAck') {
|
|
68
|
+
cleanup()
|
|
69
|
+
resolve({ port: channel.port1 })
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
port.start()
|
|
74
|
+
|
|
75
|
+
iframe.contentWindow?.postMessage(
|
|
76
|
+
{ type: INIT_RESPONSE_TYPE, version: 1 as const, windowId: params.windowId },
|
|
77
|
+
expectedOrigin,
|
|
78
|
+
[channel.port2],
|
|
79
|
+
)
|
|
80
|
+
port.postMessage({
|
|
81
|
+
type: 'ctrl',
|
|
82
|
+
version: 1,
|
|
83
|
+
action: 'handshake',
|
|
84
|
+
data: {
|
|
85
|
+
delegationToken: params.delegationToken,
|
|
86
|
+
tokenExpiresAt: params.tokenExpiresAt,
|
|
87
|
+
windowId: params.windowId,
|
|
88
|
+
kernelUrl: params.kernelUrl,
|
|
89
|
+
functionId: params.functionId,
|
|
90
|
+
targetNodeId: params.targetNodeId,
|
|
91
|
+
mintIdentity: params.mintIdentity,
|
|
92
|
+
},
|
|
93
|
+
} satisfies CtrlMessage<'handshake'>)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
window.addEventListener('message', onInitRequest)
|
|
97
|
+
})
|
|
98
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { WindowState } from '../../domain/window/window'
|
|
2
|
+
|
|
3
|
+
import { transition, type LifecycleEvent } from '../../domain/window/lifecycle'
|
|
4
|
+
|
|
5
|
+
export interface LifecycleController {
|
|
6
|
+
register(windowId: string, initial?: WindowState): void
|
|
7
|
+
apply(windowId: string, event: LifecycleEvent): WindowState | null
|
|
8
|
+
markCrashed(windowId: string): void
|
|
9
|
+
remove(windowId: string): void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function createLifecycleController(): LifecycleController {
|
|
13
|
+
const states = new Map<string, WindowState>()
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
register(windowId, initial = 'active') {
|
|
17
|
+
if (states.has(windowId)) return
|
|
18
|
+
states.set(windowId, initial)
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
apply(windowId, event) {
|
|
22
|
+
const prev = states.get(windowId)
|
|
23
|
+
if (prev === undefined) return null
|
|
24
|
+
const result = transition(prev, event)
|
|
25
|
+
if (!result.ok) return null
|
|
26
|
+
states.set(windowId, result.next)
|
|
27
|
+
return result.next
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
markCrashed(windowId) {
|
|
31
|
+
const prev = states.get(windowId)
|
|
32
|
+
if (prev === undefined || prev === 'closed') return
|
|
33
|
+
states.set(windowId, 'closed')
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
remove(windowId) {
|
|
37
|
+
states.delete(windowId)
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
}
|