@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.
Files changed (145) hide show
  1. package/dist/adapters/iframe.shell-adapter.d.ts +10 -0
  2. package/dist/adapters/iframe.shell-adapter.d.ts.map +1 -0
  3. package/dist/adapters/iframe.shell-adapter.js +55 -0
  4. package/dist/adapters/kernel-client.adapter.d.ts +53 -0
  5. package/dist/adapters/kernel-client.adapter.d.ts.map +1 -0
  6. package/dist/adapters/kernel-client.adapter.js +69 -0
  7. package/dist/adapters/message-port.transport.d.ts +7 -0
  8. package/dist/adapters/message-port.transport.d.ts.map +1 -0
  9. package/dist/adapters/message-port.transport.js +42 -0
  10. package/dist/application/channels/children-channel.d.ts +21 -0
  11. package/dist/application/channels/children-channel.d.ts.map +1 -0
  12. package/dist/application/channels/children-channel.js +82 -0
  13. package/dist/application/channels/observer.d.ts +9 -0
  14. package/dist/application/channels/observer.d.ts.map +1 -0
  15. package/dist/application/channels/observer.js +23 -0
  16. package/dist/application/channels/parent-channel.d.ts +17 -0
  17. package/dist/application/channels/parent-channel.d.ts.map +1 -0
  18. package/dist/application/channels/parent-channel.js +21 -0
  19. package/dist/application/channels/types.d.ts +12 -0
  20. package/dist/application/channels/types.d.ts.map +1 -0
  21. package/dist/application/channels/types.js +29 -0
  22. package/dist/application/intent/pipeline-runner.d.ts +12 -0
  23. package/dist/application/intent/pipeline-runner.d.ts.map +1 -0
  24. package/dist/application/intent/pipeline-runner.js +27 -0
  25. package/dist/application/intent/request.d.ts +34 -0
  26. package/dist/application/intent/request.d.ts.map +1 -0
  27. package/dist/application/intent/request.js +70 -0
  28. package/dist/application/intent/router.d.ts +56 -0
  29. package/dist/application/intent/router.d.ts.map +1 -0
  30. package/dist/application/intent/router.js +94 -0
  31. package/dist/application/kernel-facade.d.ts +33 -0
  32. package/dist/application/kernel-facade.d.ts.map +1 -0
  33. package/dist/application/kernel-facade.js +77 -0
  34. package/dist/application/shell.d.ts +100 -0
  35. package/dist/application/shell.d.ts.map +1 -0
  36. package/dist/application/shell.js +256 -0
  37. package/dist/application/windowing/child-init.d.ts +23 -0
  38. package/dist/application/windowing/child-init.d.ts.map +1 -0
  39. package/dist/application/windowing/child-init.js +101 -0
  40. package/dist/application/windowing/handshake.d.ts +30 -0
  41. package/dist/application/windowing/handshake.d.ts.map +1 -0
  42. package/dist/application/windowing/handshake.js +63 -0
  43. package/dist/application/windowing/lifecycle-controller.d.ts +10 -0
  44. package/dist/application/windowing/lifecycle-controller.d.ts.map +1 -0
  45. package/dist/application/windowing/lifecycle-controller.js +30 -0
  46. package/dist/application/windowing/mount-controller.d.ts +25 -0
  47. package/dist/application/windowing/mount-controller.d.ts.map +1 -0
  48. package/dist/application/windowing/mount-controller.js +277 -0
  49. package/dist/application/windowing/protocol.d.ts +21 -0
  50. package/dist/application/windowing/protocol.d.ts.map +1 -0
  51. package/dist/application/windowing/protocol.js +19 -0
  52. package/dist/application/windowing/token-refresh.d.ts +19 -0
  53. package/dist/application/windowing/token-refresh.d.ts.map +1 -0
  54. package/dist/application/windowing/token-refresh.js +60 -0
  55. package/dist/domain/identity/delegation.d.ts +15 -0
  56. package/dist/domain/identity/delegation.d.ts.map +1 -0
  57. package/dist/domain/identity/delegation.js +12 -0
  58. package/dist/domain/identity/path.d.ts +11 -0
  59. package/dist/domain/identity/path.d.ts.map +1 -0
  60. package/dist/domain/identity/path.js +13 -0
  61. package/dist/domain/identity/resolve-self.d.ts +21 -0
  62. package/dist/domain/identity/resolve-self.d.ts.map +1 -0
  63. package/dist/domain/identity/resolve-self.js +36 -0
  64. package/dist/domain/intent/middleware/capabilities.d.ts +28 -0
  65. package/dist/domain/intent/middleware/capabilities.d.ts.map +1 -0
  66. package/dist/domain/intent/middleware/capabilities.js +18 -0
  67. package/dist/domain/intent/middleware/logging.d.ts +14 -0
  68. package/dist/domain/intent/middleware/logging.d.ts.map +1 -0
  69. package/dist/domain/intent/middleware/logging.js +21 -0
  70. package/dist/domain/intent/middleware/rate-limit.d.ts +20 -0
  71. package/dist/domain/intent/middleware/rate-limit.d.ts.map +1 -0
  72. package/dist/domain/intent/middleware/rate-limit.js +33 -0
  73. package/dist/domain/intent/pipeline.d.ts +3 -0
  74. package/dist/domain/intent/pipeline.d.ts.map +1 -0
  75. package/dist/domain/intent/pipeline.js +1 -0
  76. package/dist/domain/intent/router.d.ts +6 -0
  77. package/dist/domain/intent/router.d.ts.map +1 -0
  78. package/dist/domain/intent/router.js +1 -0
  79. package/dist/domain/message/errors.d.ts +2 -0
  80. package/dist/domain/message/errors.d.ts.map +1 -0
  81. package/dist/domain/message/errors.js +1 -0
  82. package/dist/domain/message/registries.d.ts +145 -0
  83. package/dist/domain/message/registries.d.ts.map +1 -0
  84. package/dist/domain/message/registries.js +1 -0
  85. package/dist/domain/message/types.d.ts +30 -0
  86. package/dist/domain/message/types.d.ts.map +1 -0
  87. package/dist/domain/message/types.js +1 -0
  88. package/dist/domain/window/config.d.ts +26 -0
  89. package/dist/domain/window/config.d.ts.map +1 -0
  90. package/dist/domain/window/config.js +1 -0
  91. package/dist/domain/window/lifecycle.d.ts +27 -0
  92. package/dist/domain/window/lifecycle.d.ts.map +1 -0
  93. package/dist/domain/window/lifecycle.js +36 -0
  94. package/dist/domain/window/window.d.ts +12 -0
  95. package/dist/domain/window/window.d.ts.map +1 -0
  96. package/dist/domain/window/window.js +1 -0
  97. package/dist/index.d.ts +42 -0
  98. package/dist/index.d.ts.map +1 -0
  99. package/dist/index.js +20 -0
  100. package/dist/ports/kernel-client.d.ts +33 -0
  101. package/dist/ports/kernel-client.d.ts.map +1 -0
  102. package/dist/ports/kernel-client.js +1 -0
  103. package/dist/ports/shell-adapter.d.ts +13 -0
  104. package/dist/ports/shell-adapter.d.ts.map +1 -0
  105. package/dist/ports/shell-adapter.js +1 -0
  106. package/dist/ports/transport.d.ts +7 -0
  107. package/dist/ports/transport.d.ts.map +1 -0
  108. package/dist/ports/transport.js +1 -0
  109. package/package.json +41 -0
  110. package/src/adapters/iframe.shell-adapter.ts +68 -0
  111. package/src/adapters/kernel-client.adapter.ts +138 -0
  112. package/src/adapters/message-port.transport.ts +50 -0
  113. package/src/application/channels/children-channel.ts +112 -0
  114. package/src/application/channels/observer.ts +33 -0
  115. package/src/application/channels/parent-channel.ts +43 -0
  116. package/src/application/channels/types.ts +41 -0
  117. package/src/application/intent/pipeline-runner.ts +36 -0
  118. package/src/application/intent/request.ts +87 -0
  119. package/src/application/intent/router.ts +161 -0
  120. package/src/application/kernel-facade.ts +143 -0
  121. package/src/application/shell.ts +433 -0
  122. package/src/application/windowing/child-init.ts +137 -0
  123. package/src/application/windowing/handshake.ts +98 -0
  124. package/src/application/windowing/lifecycle-controller.ts +40 -0
  125. package/src/application/windowing/mount-controller.ts +335 -0
  126. package/src/application/windowing/protocol.ts +37 -0
  127. package/src/application/windowing/token-refresh.ts +78 -0
  128. package/src/domain/identity/delegation.ts +25 -0
  129. package/src/domain/identity/path.ts +14 -0
  130. package/src/domain/identity/resolve-self.ts +38 -0
  131. package/src/domain/intent/middleware/capabilities.ts +53 -0
  132. package/src/domain/intent/middleware/logging.ts +45 -0
  133. package/src/domain/intent/middleware/rate-limit.ts +54 -0
  134. package/src/domain/intent/pipeline.ts +3 -0
  135. package/src/domain/intent/router.ts +6 -0
  136. package/src/domain/message/errors.ts +9 -0
  137. package/src/domain/message/registries.ts +76 -0
  138. package/src/domain/message/types.ts +32 -0
  139. package/src/domain/window/config.ts +28 -0
  140. package/src/domain/window/lifecycle.ts +39 -0
  141. package/src/domain/window/window.ts +13 -0
  142. package/src/index.ts +103 -0
  143. package/src/ports/kernel-client.ts +32 -0
  144. package/src/ports/shell-adapter.ts +11 -0
  145. 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
+ }