@astrale-os/adapter-cloudflare 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/package.json +53 -0
- package/src/client.ts +54 -0
- package/src/cloudflare.ts +228 -0
- package/src/codegen/identity.ts +40 -0
- package/src/codegen/merge.ts +92 -0
- package/src/codegen/worker.ts +105 -0
- package/src/codegen/wrangler.ts +95 -0
- package/src/index.ts +17 -0
- package/src/params.ts +49 -0
- package/src/parse-output.ts +54 -0
- package/src/wrangler-cli.ts +240 -0
- package/template/.env.example +6 -0
- package/template/README.md +77 -0
- package/template/astrale.config.ts +35 -0
- package/template/client/README.md +85 -0
- package/template/client/__tests__/app.test.tsx +191 -0
- package/template/client/__tests__/harness.ts +221 -0
- package/template/client/__tests__/kernel.test.ts +68 -0
- package/template/client/index.html +12 -0
- package/template/client/package.json +26 -0
- package/template/client/src/app.tsx +94 -0
- package/template/client/src/lib/kernel.ts +135 -0
- package/template/client/src/lib/shell.ts +197 -0
- package/template/client/src/lib/use-node.ts +66 -0
- package/template/client/src/lib/use-shell.ts +85 -0
- package/template/client/src/main.tsx +9 -0
- package/template/client/src/styles.css +107 -0
- package/template/client/tsconfig.json +25 -0
- package/template/client/vite.config.ts +40 -0
- package/template/client/vitest.config.ts +18 -0
- package/template/env.ts +18 -0
- package/template/functions/index.ts +9 -0
- package/template/methods/index.ts +66 -0
- package/template/methods/note.ts +131 -0
- package/template/package.json +30 -0
- package/template/pnpm-workspace.yaml +17 -0
- package/template/schema/compiled.ts +14 -0
- package/template/schema/index.ts +21 -0
- package/template/schema/note.ts +64 -0
- package/template/tsconfig.json +17 -0
- package/template/views/index.ts +10 -0
- package/template/views/note.ts +21 -0
- package/template/views/welcome.ts +35 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal, self-contained kernel JSON client — just enough to load a node via
|
|
3
|
+
* `@<id>::get`, without pulling in `@astrale-os/kernel-client` (and its
|
|
4
|
+
* transitive deps). It reproduces the kernel ENVELOPE wire shape inline.
|
|
5
|
+
*
|
|
6
|
+
* Wire contract (authoritative source: `kernel/api/envelope/`):
|
|
7
|
+
* - Request: POST <kernelUrl> with headers
|
|
8
|
+
* content-type: application/vnd.astrale.kernel+json
|
|
9
|
+
* accept: application/vnd.astrale.kernel+json
|
|
10
|
+
* authorization: <delegationToken> (BARE token — no "Bearer " prefix)
|
|
11
|
+
* body JSON `{ method, params, id }` (see `encode.ts:encodeKernelRequest`).
|
|
12
|
+
* - Response: JSON, exactly one of (decode precedence error → redirect → result,
|
|
13
|
+
* mirroring `decode.ts:decodeKernelResponse`):
|
|
14
|
+
* { error: { code, message }, id } → throw Error("<code>: <message>")
|
|
15
|
+
* { redirect, id } → throw (we don't follow redirects here)
|
|
16
|
+
* { result, id } → return result
|
|
17
|
+
*
|
|
18
|
+
* Deliberately JSON-only: no msgpack codec, no streaming/binary, no redirect
|
|
19
|
+
* following, no schema/batching. Those live in `@astrale-os/kernel-client`,
|
|
20
|
+
* which this self-contained build omits on purpose.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// From `kernel/api/envelope/types.ts` (KERNEL_CONTENT_TYPE_JSON). Inlined so the
|
|
24
|
+
// template needs no @astrale-os import.
|
|
25
|
+
const KERNEL_CONTENT_TYPE_JSON = 'application/vnd.astrale.kernel+json'
|
|
26
|
+
|
|
27
|
+
// Module-local monotonic request id. Strings keep ids stable across reloads and
|
|
28
|
+
// distinguishable in logs; the kernel echoes `id` back but we don't correlate
|
|
29
|
+
// (one request per fetch).
|
|
30
|
+
let idSeq = 0
|
|
31
|
+
function nextId(): string {
|
|
32
|
+
idSeq += 1
|
|
33
|
+
return `c${idSeq}`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Prop key constants — the graph stores props with fully-qualified keys
|
|
38
|
+
* (`<domain>:<member>.property.<name>`). The kernel `Named.name` key is fixed
|
|
39
|
+
* and known; domain props (`title`, `body`) are qualified by the (build-time
|
|
40
|
+
* unknown) domain origin — read those by suffix with `readPropBySuffix`.
|
|
41
|
+
*/
|
|
42
|
+
export const PROP = {
|
|
43
|
+
named: {
|
|
44
|
+
name: 'kernel.astrale.ai:interface.Named.property.name',
|
|
45
|
+
},
|
|
46
|
+
} as const
|
|
47
|
+
|
|
48
|
+
export type KernelNode = {
|
|
49
|
+
id: string
|
|
50
|
+
path: string
|
|
51
|
+
class: string | { raw?: string }
|
|
52
|
+
props: Record<string, unknown>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function readProp(props: Record<string, unknown>, key: string): string | undefined {
|
|
56
|
+
const v = props[key]
|
|
57
|
+
return typeof v === 'string' ? v : undefined
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Read a string prop by key suffix — for domain-qualified props whose full key
|
|
62
|
+
* embeds the (build-time-unknown) domain origin. e.g.
|
|
63
|
+
* `readPropBySuffix(props, '.property.body')`.
|
|
64
|
+
*/
|
|
65
|
+
export function readPropBySuffix(
|
|
66
|
+
props: Record<string, unknown>,
|
|
67
|
+
suffix: string,
|
|
68
|
+
): string | undefined {
|
|
69
|
+
for (const [k, v] of Object.entries(props)) {
|
|
70
|
+
if (k.endsWith(suffix) && typeof v === 'string') return v
|
|
71
|
+
}
|
|
72
|
+
return undefined
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Short class name from a `class.raw` path `/:<domain>:class.<Name>` → `<Name>`. */
|
|
76
|
+
export function classShortName(node: KernelNode): string {
|
|
77
|
+
const raw = (typeof node.class === 'string' ? node.class : node.class?.raw) ?? ''
|
|
78
|
+
const last = raw.split(':').pop() ?? ''
|
|
79
|
+
const dot = last.indexOf('.')
|
|
80
|
+
return dot >= 0 ? last.slice(dot + 1) : last
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* POST a single kernel call and return its `result`. Throws on a kernel error
|
|
85
|
+
* envelope or an unexpected redirect. `token` is the bare delegation credential
|
|
86
|
+
* from the shell handshake; the iframe authenticates SOLELY with it (the parent
|
|
87
|
+
* minted it for this kernel, so the audience already matches — no cookie/mint).
|
|
88
|
+
*/
|
|
89
|
+
export async function kernelCall(
|
|
90
|
+
kernelUrl: string,
|
|
91
|
+
token: string,
|
|
92
|
+
method: string,
|
|
93
|
+
params: Record<string, unknown> = {},
|
|
94
|
+
): Promise<unknown> {
|
|
95
|
+
// The kernel routes on a trailing slash; the parent absolutizes the URL, but
|
|
96
|
+
// not always with the slash, so normalize here.
|
|
97
|
+
const url = kernelUrl.endsWith('/') ? kernelUrl : `${kernelUrl}/`
|
|
98
|
+
const id = nextId()
|
|
99
|
+
|
|
100
|
+
const res = await fetch(url, {
|
|
101
|
+
method: 'POST',
|
|
102
|
+
headers: {
|
|
103
|
+
'content-type': KERNEL_CONTENT_TYPE_JSON,
|
|
104
|
+
accept: KERNEL_CONTENT_TYPE_JSON,
|
|
105
|
+
authorization: token,
|
|
106
|
+
},
|
|
107
|
+
body: JSON.stringify({ method, params, id }),
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
let body: unknown
|
|
111
|
+
try {
|
|
112
|
+
body = await res.json()
|
|
113
|
+
} catch {
|
|
114
|
+
throw new Error(`kernel returned a non-JSON response (HTTP ${res.status})`)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (body === null || typeof body !== 'object') {
|
|
118
|
+
throw new Error(`kernel returned an unexpected response (HTTP ${res.status})`)
|
|
119
|
+
}
|
|
120
|
+
const obj = body as Record<string, unknown>
|
|
121
|
+
|
|
122
|
+
// Decode precedence error → redirect → result (mirrors decodeKernelResponse).
|
|
123
|
+
if ('error' in obj && obj.error && typeof obj.error === 'object') {
|
|
124
|
+
const err = obj.error as { code?: unknown; message?: unknown }
|
|
125
|
+
const code = typeof err.code === 'number' ? err.code : 5000
|
|
126
|
+
const message = typeof err.message === 'string' ? err.message : 'Unknown error'
|
|
127
|
+
throw new Error(`${code}: ${message}`)
|
|
128
|
+
}
|
|
129
|
+
if ('redirect' in obj && obj.redirect) {
|
|
130
|
+
// Redirects (remote-domain Functions) aren't followed by this minimal
|
|
131
|
+
// client — they require credential re-minting against the target worker.
|
|
132
|
+
throw new Error('unexpected redirect from kernel (not supported by the template client)')
|
|
133
|
+
}
|
|
134
|
+
return obj.result
|
|
135
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal, self-contained reimplementation of the Astrale shell *child* init
|
|
3
|
+
* handshake — just enough to learn which node this view is mounted for and to
|
|
4
|
+
* keep its delegation token live, without pulling in `@astrale-os/shell` (and
|
|
5
|
+
* its `kernel-client` transitive deps).
|
|
6
|
+
*
|
|
7
|
+
* Protocol (mirrors `shell/packages/shell/src/application/windowing/`):
|
|
8
|
+
* 1. child posts `{ type: 'astrale-shell/init-request', version: 1 }` to the
|
|
9
|
+
* parent window via `window.postMessage(..., '*')`.
|
|
10
|
+
* 2. parent replies with `{ type: 'astrale-shell/init-response', windowId }`
|
|
11
|
+
* and *transfers a MessagePort* (`event.ports[0]`).
|
|
12
|
+
* 3. over that port the parent sends a `ctrl:handshake` carrying the session
|
|
13
|
+
* data — `windowId`, `kernelUrl`, `delegationToken`, `tokenExpiresAt`,
|
|
14
|
+
* `functionId`, and the `targetNodeId` (the node this view should render).
|
|
15
|
+
* 4. child replies with `ctrl:handshakeAck` and keeps the port open.
|
|
16
|
+
*
|
|
17
|
+
* Steady state on the port:
|
|
18
|
+
* - `intent` `setTarget { nodeId }` — hot-swap the target node (re-render).
|
|
19
|
+
* - `ctrl:tokenRefresh { delegationToken, tokenExpiresAt }` — the parent
|
|
20
|
+
* pushes a FRESH token before the old one expires. We update the mutable
|
|
21
|
+
* `currentToken` so the next kernel call uses it (see `getToken()`).
|
|
22
|
+
*
|
|
23
|
+
* The iframe authenticates with the handshake `delegationToken` ONLY: the
|
|
24
|
+
* parent minted it for this kernel, so the audience already matches — no
|
|
25
|
+
* cookie, no whoami, no client-side minting. Actually CALLING the kernel lives
|
|
26
|
+
* in `lib/kernel.ts` (a minimal inline JSON client).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const INIT_REQUEST_TYPE = 'astrale-shell/init-request'
|
|
30
|
+
const INIT_RESPONSE_TYPE = 'astrale-shell/init-response'
|
|
31
|
+
|
|
32
|
+
export type HandshakeData = {
|
|
33
|
+
windowId: string
|
|
34
|
+
kernelUrl?: string
|
|
35
|
+
functionId?: string
|
|
36
|
+
/** Opaque delegation credential the parent minted for this view. */
|
|
37
|
+
delegationToken?: string
|
|
38
|
+
/** Unix-ms expiry of `delegationToken`; advances on each `tokenRefresh`. */
|
|
39
|
+
tokenExpiresAt?: number
|
|
40
|
+
/** The node this view is mounted for — what we want to render. */
|
|
41
|
+
targetNodeId?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type ShellSession = {
|
|
45
|
+
/** Handshake data captured at handshake time (token here is the INITIAL one). */
|
|
46
|
+
readonly data: HandshakeData
|
|
47
|
+
/** Convenience mirror of `data.kernelUrl` (satisfies `NodeSession`). */
|
|
48
|
+
readonly kernelUrl: string | undefined
|
|
49
|
+
/** The LIVE delegation token — reflects `tokenRefresh` pushes. */
|
|
50
|
+
getToken(): string | undefined
|
|
51
|
+
/** The LIVE token expiry (Unix ms) — reflects `tokenRefresh` pushes. */
|
|
52
|
+
getTokenExpiresAt(): number | undefined
|
|
53
|
+
/** Subscribe to `targetNodeId` hot-swaps pushed via `setTarget` intents. */
|
|
54
|
+
onTargetChange(cb: (nodeId: string | undefined) => void): () => void
|
|
55
|
+
/** Tear down listeners + the message port. */
|
|
56
|
+
dispose(): void
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type CtrlMessage = {
|
|
60
|
+
type: 'ctrl'
|
|
61
|
+
version: number
|
|
62
|
+
action: string
|
|
63
|
+
data?: Record<string, unknown>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type IntentMessage = {
|
|
67
|
+
type: 'intent'
|
|
68
|
+
envelope?: { name?: string; payload?: Record<string, unknown> }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const isObj = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null
|
|
72
|
+
|
|
73
|
+
const asString = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Run the child handshake. Resolves once the parent completes `ctrl:handshake`.
|
|
77
|
+
* Rejects on timeout (no parent / not sandboxed) — callers should render a
|
|
78
|
+
* standalone fallback in that case.
|
|
79
|
+
*/
|
|
80
|
+
export function connectShell(timeoutMs = 5000): Promise<ShellSession> {
|
|
81
|
+
return new Promise((resolve, reject) => {
|
|
82
|
+
const parent = window.parent
|
|
83
|
+
if (!parent || parent === window) {
|
|
84
|
+
reject(new Error('not running inside a parent frame'))
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let port: MessagePort | null = null
|
|
89
|
+
// Mutable token state — `tokenRefresh` updates these in place so later
|
|
90
|
+
// kernel calls read the fresh credential via `getToken()`.
|
|
91
|
+
let currentToken: string | undefined
|
|
92
|
+
let currentExpiresAt: number | undefined
|
|
93
|
+
const targetListeners = new Set<(nodeId: string | undefined) => void>()
|
|
94
|
+
|
|
95
|
+
const timer = setTimeout(() => {
|
|
96
|
+
cleanupWindow()
|
|
97
|
+
reject(new Error(`shell handshake timed out after ${timeoutMs}ms`))
|
|
98
|
+
}, timeoutMs)
|
|
99
|
+
|
|
100
|
+
function cleanupWindow() {
|
|
101
|
+
clearTimeout(timer)
|
|
102
|
+
window.removeEventListener('message', onWindowMessage)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Steady-state port listener: target hot-swaps + token refreshes.
|
|
106
|
+
function onPortMessage(ev: MessageEvent) {
|
|
107
|
+
const msg = ev.data as unknown
|
|
108
|
+
if (!isObj(msg)) return
|
|
109
|
+
|
|
110
|
+
// Token refresh: the parent pushed a fresh credential. Swap it in place.
|
|
111
|
+
if (msg.type === 'ctrl' && (msg as CtrlMessage).action === 'tokenRefresh') {
|
|
112
|
+
const d = (msg as CtrlMessage).data ?? {}
|
|
113
|
+
const next = asString(d.delegationToken)
|
|
114
|
+
if (next) currentToken = next
|
|
115
|
+
if (typeof d.tokenExpiresAt === 'number') currentExpiresAt = d.tokenExpiresAt
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Hot-swap: `setTarget` intent carries a new nodeId.
|
|
120
|
+
if (msg.type === 'intent') {
|
|
121
|
+
const intent = msg as IntentMessage
|
|
122
|
+
if (intent.envelope?.name === 'setTarget') {
|
|
123
|
+
const raw = intent.envelope.payload?.nodeId
|
|
124
|
+
const nodeId = asString(raw)
|
|
125
|
+
for (const cb of targetListeners) cb(nodeId)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function onWindowMessage(ev: MessageEvent) {
|
|
131
|
+
const msg = ev.data as unknown
|
|
132
|
+
if (ev.source !== parent || !isObj(msg)) return
|
|
133
|
+
if (msg.type !== INIT_RESPONSE_TYPE) return
|
|
134
|
+
|
|
135
|
+
const received = ev.ports[0]
|
|
136
|
+
if (!received) return
|
|
137
|
+
port = received
|
|
138
|
+
|
|
139
|
+
port.onmessage = (portEv: MessageEvent) => {
|
|
140
|
+
const ctrl = portEv.data as unknown
|
|
141
|
+
if (!isObj(ctrl) || ctrl.type !== 'ctrl') {
|
|
142
|
+
onPortMessage(portEv)
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
const m = ctrl as CtrlMessage
|
|
146
|
+
if (m.action !== 'handshake' || !port) return
|
|
147
|
+
|
|
148
|
+
const d = (m.data ?? {}) as Record<string, unknown>
|
|
149
|
+
currentToken = asString(d.delegationToken)
|
|
150
|
+
currentExpiresAt = typeof d.tokenExpiresAt === 'number' ? d.tokenExpiresAt : undefined
|
|
151
|
+
const kernelUrl = asString(d.kernelUrl)
|
|
152
|
+
|
|
153
|
+
// Ack so the parent considers the child live.
|
|
154
|
+
port.postMessage({
|
|
155
|
+
type: 'ctrl',
|
|
156
|
+
version: 1,
|
|
157
|
+
action: 'handshakeAck',
|
|
158
|
+
data: { windowId: d.windowId },
|
|
159
|
+
})
|
|
160
|
+
// Switch the port to the steady-state listener (intents + tokenRefresh).
|
|
161
|
+
port.onmessage = onPortMessage
|
|
162
|
+
cleanupWindow()
|
|
163
|
+
|
|
164
|
+
resolve({
|
|
165
|
+
data: {
|
|
166
|
+
windowId: String(d.windowId ?? ''),
|
|
167
|
+
kernelUrl,
|
|
168
|
+
functionId: asString(d.functionId),
|
|
169
|
+
delegationToken: currentToken,
|
|
170
|
+
tokenExpiresAt: currentExpiresAt,
|
|
171
|
+
targetNodeId: asString(d.targetNodeId),
|
|
172
|
+
},
|
|
173
|
+
kernelUrl,
|
|
174
|
+
getToken: () => currentToken,
|
|
175
|
+
getTokenExpiresAt: () => currentExpiresAt,
|
|
176
|
+
onTargetChange(cb) {
|
|
177
|
+
targetListeners.add(cb)
|
|
178
|
+
return () => targetListeners.delete(cb)
|
|
179
|
+
},
|
|
180
|
+
dispose() {
|
|
181
|
+
cleanupWindow()
|
|
182
|
+
targetListeners.clear()
|
|
183
|
+
if (port) {
|
|
184
|
+
port.onmessage = null
|
|
185
|
+
port.close()
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
port.start()
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
window.addEventListener('message', onWindowMessage)
|
|
194
|
+
// targetOrigin '*' — the parent verifies our origin on its side.
|
|
195
|
+
parent.postMessage({ type: INIT_REQUEST_TYPE, version: 1 }, '*')
|
|
196
|
+
})
|
|
197
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
import { kernelCall, type KernelNode } from './kernel'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The slice of the shell session this hook needs: where to call the kernel
|
|
7
|
+
* (`kernelUrl`) and how to read the LIVE delegation token (`getToken()` —
|
|
8
|
+
* which advances on `ctrl:tokenRefresh`). `ShellSession` satisfies this, and is
|
|
9
|
+
* a stable reference for the component's lifetime (set once on handshake).
|
|
10
|
+
*/
|
|
11
|
+
export type NodeSession = {
|
|
12
|
+
readonly kernelUrl: string | undefined
|
|
13
|
+
getToken(): string | undefined
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type NodeState =
|
|
17
|
+
| { status: 'idle' }
|
|
18
|
+
| { status: 'loading' }
|
|
19
|
+
| { status: 'ok'; node: KernelNode }
|
|
20
|
+
| { status: 'error'; message: string }
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Fetch a node by id via `@<id>::get`, using the session's kernel URL + live
|
|
24
|
+
* delegation token. Re-fetches when the target node id changes (`setTarget`
|
|
25
|
+
* hot-swap) or when a token first becomes available. Stays `idle` until both a
|
|
26
|
+
* target node id and a token exist.
|
|
27
|
+
*
|
|
28
|
+
* The effect keys on the token's PRESENCE (`hasToken`), not its value: a
|
|
29
|
+
* `ctrl:tokenRefresh` swaps the token in place WITHOUT re-firing, and the next
|
|
30
|
+
* fetch (triggered by a `setTarget` or remount) reads the fresh token via
|
|
31
|
+
* `session.getToken()` at call time. `session` is stable, so listing it as a
|
|
32
|
+
* dep is safe — it never causes a re-fire on its own.
|
|
33
|
+
*/
|
|
34
|
+
export function useNode(session: NodeSession, nodeId: string | undefined): NodeState {
|
|
35
|
+
const hasToken = session.getToken() !== undefined
|
|
36
|
+
const [state, setState] = useState<NodeState>({ status: 'idle' })
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
const token = session.getToken()
|
|
40
|
+
if (!nodeId || !session.kernelUrl || !token) {
|
|
41
|
+
setState({ status: 'idle' })
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let cancelled = false
|
|
46
|
+
setState({ status: 'loading' })
|
|
47
|
+
kernelCall(session.kernelUrl, token, `@${nodeId}::get`, {})
|
|
48
|
+
.then((result) => {
|
|
49
|
+
if (!cancelled) setState({ status: 'ok', node: result as KernelNode })
|
|
50
|
+
})
|
|
51
|
+
.catch((err: unknown) => {
|
|
52
|
+
if (!cancelled) {
|
|
53
|
+
setState({
|
|
54
|
+
status: 'error',
|
|
55
|
+
message: err instanceof Error ? err.message : String(err),
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
return () => {
|
|
61
|
+
cancelled = true
|
|
62
|
+
}
|
|
63
|
+
}, [session, hasToken, nodeId])
|
|
64
|
+
|
|
65
|
+
return state
|
|
66
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
import { connectShell, type HandshakeData, type ShellSession } from './shell'
|
|
4
|
+
|
|
5
|
+
export type ShellStatus = 'loading' | 'ready' | 'standalone'
|
|
6
|
+
|
|
7
|
+
export type ShellState = {
|
|
8
|
+
status: ShellStatus
|
|
9
|
+
/** Handshake data once `ready` (kernelUrl, token, etc). */
|
|
10
|
+
data: HandshakeData | null
|
|
11
|
+
/**
|
|
12
|
+
* The live session once `ready` — exposes `getToken()` (which reflects
|
|
13
|
+
* `tokenRefresh`) and `kernelUrl`. The node-fetch hook reads through this so
|
|
14
|
+
* it always uses the CURRENT token, not the one captured at handshake time.
|
|
15
|
+
*/
|
|
16
|
+
session: ShellSession | null
|
|
17
|
+
/** Current target node id — updates on `setTarget` hot-swaps. */
|
|
18
|
+
nodeId: string | undefined
|
|
19
|
+
/** Reason we fell back to `standalone` (no parent / timeout). */
|
|
20
|
+
reason: string | null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Runs the inline shell handshake once on mount and tracks the target node id.
|
|
25
|
+
*
|
|
26
|
+
* Three outcomes:
|
|
27
|
+
* - `loading` — handshake in flight
|
|
28
|
+
* - `ready` — parent completed the handshake; `data`/`session`/`nodeId`
|
|
29
|
+
* populated
|
|
30
|
+
* - `standalone` — no parent or it timed out (e.g. opened directly in a tab);
|
|
31
|
+
* the view renders a self-describing fallback
|
|
32
|
+
*/
|
|
33
|
+
export function useShell(): ShellState {
|
|
34
|
+
const [state, setState] = useState<ShellState>({
|
|
35
|
+
status: 'loading',
|
|
36
|
+
data: null,
|
|
37
|
+
session: null,
|
|
38
|
+
nodeId: undefined,
|
|
39
|
+
reason: null,
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
let cancelled = false
|
|
44
|
+
let dispose: (() => void) | undefined
|
|
45
|
+
|
|
46
|
+
connectShell()
|
|
47
|
+
.then((session) => {
|
|
48
|
+
if (cancelled) {
|
|
49
|
+
session.dispose()
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
setState({
|
|
53
|
+
status: 'ready',
|
|
54
|
+
data: session.data,
|
|
55
|
+
session,
|
|
56
|
+
nodeId: session.data.targetNodeId,
|
|
57
|
+
reason: null,
|
|
58
|
+
})
|
|
59
|
+
const off = session.onTargetChange((nodeId) => {
|
|
60
|
+
setState((prev) => ({ ...prev, nodeId }))
|
|
61
|
+
})
|
|
62
|
+
dispose = () => {
|
|
63
|
+
off()
|
|
64
|
+
session.dispose()
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
.catch((err: unknown) => {
|
|
68
|
+
if (cancelled) return
|
|
69
|
+
setState({
|
|
70
|
+
status: 'standalone',
|
|
71
|
+
data: null,
|
|
72
|
+
session: null,
|
|
73
|
+
nodeId: undefined,
|
|
74
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
return () => {
|
|
79
|
+
cancelled = true
|
|
80
|
+
dispose?.()
|
|
81
|
+
}
|
|
82
|
+
}, [])
|
|
83
|
+
|
|
84
|
+
return state
|
|
85
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { createRoot } from 'react-dom/client'
|
|
2
|
+
|
|
3
|
+
import './styles.css'
|
|
4
|
+
import { App } from './app'
|
|
5
|
+
|
|
6
|
+
const root = document.getElementById('root')
|
|
7
|
+
if (!root) throw new Error('ui-note client: #root missing from index.html')
|
|
8
|
+
|
|
9
|
+
createRoot(root).render(<App />)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--bg: #fafafa;
|
|
3
|
+
--fg: #171717;
|
|
4
|
+
--muted: #737373;
|
|
5
|
+
--border: #e5e5e5;
|
|
6
|
+
--card: #ffffff;
|
|
7
|
+
--accent: #2563eb;
|
|
8
|
+
--warn-bg: #fffbeb;
|
|
9
|
+
--warn-border: #fde68a;
|
|
10
|
+
--warn-fg: #92400e;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
* {
|
|
14
|
+
box-sizing: border-box;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
body {
|
|
18
|
+
margin: 0;
|
|
19
|
+
background: var(--bg);
|
|
20
|
+
color: var(--fg);
|
|
21
|
+
font: 14px/1.6 system-ui, -apple-system, sans-serif;
|
|
22
|
+
-webkit-font-smoothing: antialiased;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
#root {
|
|
26
|
+
min-height: 100vh;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.wrap {
|
|
30
|
+
max-width: 42rem;
|
|
31
|
+
margin: 0 auto;
|
|
32
|
+
padding: 1.5rem;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.card {
|
|
36
|
+
background: var(--card);
|
|
37
|
+
border: 1px solid var(--border);
|
|
38
|
+
border-radius: 0.75rem;
|
|
39
|
+
padding: 1.25rem 1.5rem;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.title {
|
|
43
|
+
font-size: 1.4rem;
|
|
44
|
+
font-weight: 600;
|
|
45
|
+
margin: 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.subline {
|
|
49
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
50
|
+
font-size: 0.72rem;
|
|
51
|
+
color: var(--muted);
|
|
52
|
+
margin: 0.25rem 0 1rem;
|
|
53
|
+
word-break: break-all;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.body {
|
|
57
|
+
white-space: pre-wrap;
|
|
58
|
+
margin: 0 0 1rem;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.banner {
|
|
62
|
+
border: 1px solid var(--warn-border);
|
|
63
|
+
background: var(--warn-bg);
|
|
64
|
+
color: var(--warn-fg);
|
|
65
|
+
border-radius: 0.5rem;
|
|
66
|
+
padding: 0.6rem 0.85rem;
|
|
67
|
+
font-size: 0.8rem;
|
|
68
|
+
margin-bottom: 1rem;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.kv {
|
|
72
|
+
border: 1px solid var(--border);
|
|
73
|
+
border-radius: 0.5rem;
|
|
74
|
+
overflow: hidden;
|
|
75
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
76
|
+
font-size: 0.72rem;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.kv-row {
|
|
80
|
+
display: grid;
|
|
81
|
+
grid-template-columns: 9rem 1fr;
|
|
82
|
+
gap: 0.75rem;
|
|
83
|
+
padding: 0.4rem 0.7rem;
|
|
84
|
+
border-top: 1px solid var(--border);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.kv-row:first-child {
|
|
88
|
+
border-top: 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.kv-key {
|
|
92
|
+
color: var(--muted);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.kv-val {
|
|
96
|
+
word-break: break-all;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.muted {
|
|
100
|
+
color: var(--muted);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.section-label {
|
|
104
|
+
font-size: 0.78rem;
|
|
105
|
+
font-weight: 600;
|
|
106
|
+
margin: 0 0 0.5rem;
|
|
107
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
7
|
+
"jsx": "react-jsx",
|
|
8
|
+
"types": ["vite/client", "vitest/globals"],
|
|
9
|
+
"strict": true,
|
|
10
|
+
"noEmit": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"isolatedModules": true,
|
|
13
|
+
"resolveJsonModule": true,
|
|
14
|
+
"verbatimModuleSyntax": true
|
|
15
|
+
},
|
|
16
|
+
"include": [
|
|
17
|
+
"src/**/*.ts",
|
|
18
|
+
"src/**/*.tsx",
|
|
19
|
+
"__tests__/**/*.ts",
|
|
20
|
+
"__tests__/**/*.tsx",
|
|
21
|
+
"vite.config.ts",
|
|
22
|
+
"vitest.config.ts"
|
|
23
|
+
],
|
|
24
|
+
"exclude": ["node_modules", "../dist-client"]
|
|
25
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import viteReact from '@vitejs/plugin-react'
|
|
2
|
+
import { defineConfig } from 'vite'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Client SPA for the domain's `ui-note` View. Built into `../dist-client/`,
|
|
6
|
+
* served by the generated worker via its `ASSETS` binding (`.astrale/`).
|
|
7
|
+
*
|
|
8
|
+
* `base: '/ui/'` + `outDir: '../dist-client'`: Vite emits asset refs as
|
|
9
|
+
* `/ui/assets/<hash>.js`; the worker strips the `/ui` prefix before delegating
|
|
10
|
+
* to `ASSETS`, so files resolve from the directory root. `index.html` is the
|
|
11
|
+
* SPA fallback for `/ui/<anything>`.
|
|
12
|
+
*
|
|
13
|
+
* Deliberately self-contained — only react/react-dom/vite/@vitejs/plugin-react,
|
|
14
|
+
* all on public npm — so the template builds without the @astrale-os registry
|
|
15
|
+
* or workspace `link:`s. Two small subsets are reimplemented inline: the shell
|
|
16
|
+
* child-handshake (`src/lib/shell.ts`, including `ctrl:tokenRefresh` so a
|
|
17
|
+
* pushed credential is picked up) and a minimal JSON kernel client
|
|
18
|
+
* (`src/lib/kernel.ts`, `@<id>::get`). Tests live in `__tests__/` and run on
|
|
19
|
+
* `vitest`/`happy-dom` (see `vitest.config.ts`) — vite never bundles them.
|
|
20
|
+
*
|
|
21
|
+
* Deferred on purpose (grow via `@astrale-os/kernel-client` if needed): no
|
|
22
|
+
* msgpack, no streaming/binary, no redirect following, no client-side minting,
|
|
23
|
+
* no writes. See `README.md`.
|
|
24
|
+
*/
|
|
25
|
+
export default defineConfig({
|
|
26
|
+
base: '/ui/',
|
|
27
|
+
plugins: [viteReact()],
|
|
28
|
+
build: {
|
|
29
|
+
outDir: '../dist-client',
|
|
30
|
+
emptyOutDir: true,
|
|
31
|
+
sourcemap: false,
|
|
32
|
+
},
|
|
33
|
+
// `pnpm dev:hmr` — set `VIEW_DEV_URL=http://127.0.0.1:5173` in the worker's
|
|
34
|
+
// `.dev.vars` to proxy `/ui/*` here and get React HMR.
|
|
35
|
+
server: {
|
|
36
|
+
host: '127.0.0.1',
|
|
37
|
+
port: 5173,
|
|
38
|
+
cors: true,
|
|
39
|
+
},
|
|
40
|
+
})
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import viteReact from '@vitejs/plugin-react'
|
|
2
|
+
import { defineConfig } from 'vitest/config'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Vitest config for the self-contained client. A DOM env (`happy-dom`) backs
|
|
6
|
+
* the React render + `window.postMessage`/`MessageChannel` used by the fake
|
|
7
|
+
* shell-parent harness. Kept separate from `vite.config.ts` (which sets the
|
|
8
|
+
* `/ui/` base + `../dist-client` build) so tests don't inherit the SPA build
|
|
9
|
+
* options.
|
|
10
|
+
*/
|
|
11
|
+
export default defineConfig({
|
|
12
|
+
plugins: [viteReact()],
|
|
13
|
+
test: {
|
|
14
|
+
environment: 'happy-dom',
|
|
15
|
+
globals: true,
|
|
16
|
+
include: ['__tests__/**/*.test.{ts,tsx}', 'src/**/*.test.{ts,tsx}'],
|
|
17
|
+
},
|
|
18
|
+
})
|