@astrale-os/adapter-cloudflare 0.4.4 → 0.4.5
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 +4 -3
- package/template/CLAUDE.md +8 -21
- package/template/README.md +30 -107
- package/template/client/__tests__/app.test.tsx +6 -57
- package/template/client/components.json +25 -0
- package/template/client/index.html +1 -1
- package/template/client/package.json +10 -2
- package/template/client/src/app.tsx +42 -33
- package/template/client/src/components/ui/cn.ts +6 -0
- package/template/client/src/styles.css +71 -502
- package/template/client/vite.config.ts +2 -18
- package/template/client/vitest.config.ts +0 -6
- package/template/core/README.md +5 -21
- package/template/deps.ts +2 -20
- package/template/domain.ts +0 -26
- package/template/env.ts +1 -17
- package/template/icons.ts +4 -25
- package/template/integrations/README.md +5 -20
- package/template/package.json +7 -5
- package/template/pnpm-workspace.yaml +2 -0
- package/template/runtime/index.ts +0 -27
- package/template/schema/index.ts +0 -20
- package/template/simulation/README.md +12 -0
- package/template/tsconfig.json +2 -3
- package/template/vitest.config.ts +7 -0
- package/template/.domain-studio/comments.json +0 -4
- package/template/client/README.md +0 -104
- package/template/client/__tests__/harness.ts +0 -230
- package/template/client/__tests__/shell.test.ts +0 -46
- package/template/client/src/shell/client.ts +0 -67
- package/template/client/src/shell/index.ts +0 -21
- package/template/client/src/shell/invoke.ts +0 -35
- package/template/client/src/shell/transformers.ts +0 -75
- package/template/client/src/shell/use-async.ts +0 -59
- package/template/client/src/shell/use-capability.ts +0 -59
- package/template/client/src/shell/use-node.ts +0 -81
- package/template/client/src/shell/use-shell.ts +0 -91
- package/template/client/src/shell/view-router.tsx +0 -97
- package/template/client/src/ui/format.ts +0 -24
- package/template/client/src/ui/index.ts +0 -10
- package/template/client/src/ui/surface.tsx +0 -56
- package/template/client/src/ui/value.tsx +0 -32
- package/template/functions/index.ts +0 -22
- package/template/views/index.ts +0 -19
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Graph/prop helpers for the nodes the kernel returns — reading fully-qualified
|
|
3
|
-
* props and short class names off a `KernelNode`. Pure shaping: the kernel
|
|
4
|
-
* TRANSPORT now lives in `@astrale-os/shell` (`shell.kernel` → `session.call`),
|
|
5
|
-
* so this file no longer speaks the wire.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Prop key constants — the graph stores props with fully-qualified keys
|
|
10
|
-
* (`<domain>:<member>.property.<name>`). The kernel `Named.name` key is fixed
|
|
11
|
-
* and known; domain props (`url`, `status`) are qualified by the (build-time
|
|
12
|
-
* unknown) domain origin — read those by suffix with `readPropBySuffix`.
|
|
13
|
-
*/
|
|
14
|
-
export const PROP = {
|
|
15
|
-
named: {
|
|
16
|
-
name: 'kernel.astrale.ai:interface.Named.property.name',
|
|
17
|
-
},
|
|
18
|
-
} as const
|
|
19
|
-
|
|
20
|
-
export type KernelNode = {
|
|
21
|
-
id: string
|
|
22
|
-
path: string
|
|
23
|
-
class: string | { raw?: string }
|
|
24
|
-
props: Record<string, unknown>
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function readProp(props: Record<string, unknown>, key: string): string | undefined {
|
|
28
|
-
const v = props[key]
|
|
29
|
-
return typeof v === 'string' ? v : undefined
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Read a string prop by key suffix — for domain-qualified props whose full key
|
|
34
|
-
* embeds the (build-time-unknown) domain origin. e.g.
|
|
35
|
-
* `readPropBySuffix(props, '.property.url')`.
|
|
36
|
-
*/
|
|
37
|
-
export function readPropBySuffix(
|
|
38
|
-
props: Record<string, unknown>,
|
|
39
|
-
suffix: string,
|
|
40
|
-
): string | undefined {
|
|
41
|
-
for (const [k, v] of Object.entries(props)) {
|
|
42
|
-
if (k.endsWith(suffix) && typeof v === 'string') return v
|
|
43
|
-
}
|
|
44
|
-
return undefined
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** Short class name from a `class.raw` path `/:<domain>:class.<Name>` → `<Name>`. */
|
|
48
|
-
export function classShortName(node: KernelNode): string {
|
|
49
|
-
const raw = (typeof node.class === 'string' ? node.class : node.class?.raw) ?? ''
|
|
50
|
-
const last = raw.split(':').pop() ?? ''
|
|
51
|
-
const dot = last.indexOf('.')
|
|
52
|
-
return dot >= 0 ? last.slice(dot + 1) : last
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Operator-facing message for a failed kernel call. Errors from the shell's
|
|
57
|
-
* kernel client carry a numeric `code` (e.g. `NotFoundError` → `code 3002`) with
|
|
58
|
-
* the raw server text in `message`; surface them as `<code>: <message>` so the
|
|
59
|
-
* code stays visible. Plain errors fall back to their message.
|
|
60
|
-
*/
|
|
61
|
-
export function errorMessage(err: unknown): string {
|
|
62
|
-
if (err instanceof Error) {
|
|
63
|
-
const code = (err as { code?: unknown }).code
|
|
64
|
-
return typeof code === 'number' ? `${code}: ${err.message}` : err.message
|
|
65
|
-
}
|
|
66
|
-
return String(err)
|
|
67
|
-
}
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The kernel boundary — everything for talking to the kernel and the GUI
|
|
3
|
-
* shell, plus the common read/write primitives features build their
|
|
4
|
-
* `.query`/`.mutation` hooks on. Import from the barrel: `@/shell`.
|
|
5
|
-
*/
|
|
6
|
-
export { classShortName, errorMessage, PROP, readProp, readPropBySuffix } from './client'
|
|
7
|
-
export type { KernelNode } from './client'
|
|
8
|
-
export { callMethod, invokeNode, nodeAddr } from './invoke'
|
|
9
|
-
export type { KernelClient } from '@astrale-os/shell'
|
|
10
|
-
export { useNode } from './use-node'
|
|
11
|
-
export type { NodeState } from './use-node'
|
|
12
|
-
export { useShell } from './use-shell'
|
|
13
|
-
export type { ShellState, ShellStatus } from './use-shell'
|
|
14
|
-
export { useAsync } from './use-async'
|
|
15
|
-
export type { AsyncResource, AsyncState } from './use-async'
|
|
16
|
-
export { useCapability } from './use-capability'
|
|
17
|
-
export type { Capability, Phase } from './use-capability'
|
|
18
|
-
export { asNodeArray, linkTargets, qualifiedProp, qualifiedString } from './transformers'
|
|
19
|
-
export type { GraphEdge } from './transformers'
|
|
20
|
-
export { resolveView, ViewFrame } from './view-router'
|
|
21
|
-
export type { ViewComponent, ViewRoutes } from './view-router'
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Kernel invocation primitives — the single place a view reaches the kernel,
|
|
3
|
-
* over the shell's proper `KernelClient` (`session.call`, wired by the sandboxed
|
|
4
|
-
* handshake: fresh delegation token per call, redirects followed). `callMethod`
|
|
5
|
-
* is the generic method-ref call; `invokeNode` is instance dispatch by node id
|
|
6
|
-
* (`@<id>::<method>` — the shell hands views node IDs, not paths); `nodeAddr`
|
|
7
|
-
* picks the stable address for a node.
|
|
8
|
-
*/
|
|
9
|
-
import type { KernelClient } from '@astrale-os/shell'
|
|
10
|
-
|
|
11
|
-
import type { KernelNode } from './client'
|
|
12
|
-
|
|
13
|
-
/** One kernel call through the live session (proper client: fresh token, redirects). */
|
|
14
|
-
export function callMethod(
|
|
15
|
-
session: KernelClient,
|
|
16
|
-
method: string,
|
|
17
|
-
params: Record<string, unknown> = {},
|
|
18
|
-
): Promise<unknown> {
|
|
19
|
-
return session.call(method, params)
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** Instance-method dispatch by node id. */
|
|
23
|
-
export function invokeNode(
|
|
24
|
-
session: KernelClient,
|
|
25
|
-
nodeId: string,
|
|
26
|
-
method: string,
|
|
27
|
-
params: Record<string, unknown> = {},
|
|
28
|
-
): Promise<unknown> {
|
|
29
|
-
return callMethod(session, `@${nodeId}::${method}`, params)
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** Stable instance address: tree path when known, else `@id`. */
|
|
33
|
-
export function nodeAddr(node: KernelNode): string {
|
|
34
|
-
return node.path && node.path.startsWith('/') ? node.path : `@${node.id}`
|
|
35
|
-
}
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Graph-shape helpers at the kernel boundary — turn `shell.kernel.graph` results
|
|
3
|
-
* (`graph.node` / `graph.children` / `graph.links`, backed by `function.get`)
|
|
4
|
-
* into the shapes features consume. Domain-neutral: per-class node→record mappers
|
|
5
|
-
* live in each feature's `*.mappers.ts` and build on `qualifiedString` here.
|
|
6
|
-
*
|
|
7
|
-
* The graph sugar already returns typed records (`{ nodes }`, `{ edges }`) — no
|
|
8
|
-
* envelope-sniffing needed — so these are thin projections over those shapes.
|
|
9
|
-
*
|
|
10
|
-
* Props are stored under QUALIFIED keys
|
|
11
|
-
* (`monitors.astrale.ai:class.Monitor.property.url`). The client must not import
|
|
12
|
-
* server schema, so values resolve by KEY SUFFIX (`.property.<name>`),
|
|
13
|
-
* preferring domain-qualified keys over kernel ones
|
|
14
|
-
* (`kernel.astrale.ai:interface.Named.property.name` would otherwise shadow
|
|
15
|
-
* `Monitor.property.name` — both end in `.property.name`).
|
|
16
|
-
*/
|
|
17
|
-
import type { KernelNode } from './client'
|
|
18
|
-
|
|
19
|
-
/** One incident edge as `shell.kernel.graph.links` returns it (a `GraphEdgeWire`). */
|
|
20
|
-
export type GraphEdge = {
|
|
21
|
-
class: string
|
|
22
|
-
source: string
|
|
23
|
-
target: string
|
|
24
|
-
slug?: string | null
|
|
25
|
-
props?: Record<string, unknown>
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** Resolve a prop by `.property.<name>` suffix; domain keys win over kernel ones. */
|
|
29
|
-
export function qualifiedProp(props: Record<string, unknown>, name: string): unknown {
|
|
30
|
-
const suffix = `.property.${name}`
|
|
31
|
-
let kernelFallback: unknown
|
|
32
|
-
for (const [key, value] of Object.entries(props)) {
|
|
33
|
-
if (!key.endsWith(suffix)) continue
|
|
34
|
-
if (key.startsWith('kernel.astrale.ai:')) {
|
|
35
|
-
if (kernelFallback === undefined) kernelFallback = value
|
|
36
|
-
} else {
|
|
37
|
-
return value
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
return kernelFallback
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export function qualifiedString(props: Record<string, unknown>, name: string): string | undefined {
|
|
44
|
-
const v = qualifiedProp(props, name)
|
|
45
|
-
return typeof v === 'string' && v !== '' ? v : undefined
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Targets of `graph.links` edges whose edge class ends with `classTail`
|
|
50
|
-
* (e.g. `'monitor_on_host'`). Accepts the `graph.links` result (`{ edges }`) or a
|
|
51
|
-
* bare `GraphEdge[]`.
|
|
52
|
-
*/
|
|
53
|
-
export function linkTargets(
|
|
54
|
-
edges: GraphEdge[] | { edges: GraphEdge[] } | null | undefined,
|
|
55
|
-
classTail: string,
|
|
56
|
-
): string[] {
|
|
57
|
-
const list: GraphEdge[] = Array.isArray(edges) ? edges : (edges?.edges ?? [])
|
|
58
|
-
const out: string[] = []
|
|
59
|
-
for (const edge of list) {
|
|
60
|
-
if (typeof edge.class !== 'string' || !edge.class.endsWith(classTail)) continue
|
|
61
|
-
if (typeof edge.target === 'string') out.push(edge.target)
|
|
62
|
-
}
|
|
63
|
-
return out
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* The nodes of a `graph.children` / `graph.tree` result. Accepts the sugar result
|
|
68
|
-
* (`{ nodes }`) or a bare `KernelNode[]`.
|
|
69
|
-
*/
|
|
70
|
-
export function asNodeArray(
|
|
71
|
-
result: KernelNode[] | { nodes: KernelNode[] } | null | undefined,
|
|
72
|
-
): KernelNode[] {
|
|
73
|
-
if (Array.isArray(result)) return result
|
|
74
|
-
return result?.nodes ?? []
|
|
75
|
-
}
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tiny async-resource hook — load on mount / when `deps` change, expose a
|
|
3
|
-
* `reload()` for refresh buttons and post-mutation refetches. Mirrors the
|
|
4
|
-
* cancellation discipline of `use-node.ts` (stale resolutions are dropped).
|
|
5
|
-
*/
|
|
6
|
-
import { useCallback, useEffect, useState } from 'react'
|
|
7
|
-
|
|
8
|
-
import { errorMessage } from './client'
|
|
9
|
-
|
|
10
|
-
export type AsyncState<T> =
|
|
11
|
-
| { status: 'loading' }
|
|
12
|
-
| { status: 'ok'; data: T }
|
|
13
|
-
| { status: 'error'; message: string }
|
|
14
|
-
|
|
15
|
-
export type AsyncResource<T> = {
|
|
16
|
-
state: AsyncState<T>
|
|
17
|
-
/** Refetch. Keeps showing the previous data while the reload is in flight. */
|
|
18
|
-
reload: () => void
|
|
19
|
-
/** True while a reload is in flight on top of an `ok` state. */
|
|
20
|
-
reloading: boolean
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function useAsync<T>(fn: () => Promise<T>, deps: unknown[]): AsyncResource<T> {
|
|
24
|
-
const [state, setState] = useState<AsyncState<T>>({ status: 'loading' })
|
|
25
|
-
const [reloading, setReloading] = useState(false)
|
|
26
|
-
const [epoch, setEpoch] = useState(0)
|
|
27
|
-
|
|
28
|
-
useEffect(() => {
|
|
29
|
-
let cancelled = false
|
|
30
|
-
setState((prev) => {
|
|
31
|
-
if (prev.status === 'ok') {
|
|
32
|
-
setReloading(true)
|
|
33
|
-
return prev // keep stale data visible during a reload
|
|
34
|
-
}
|
|
35
|
-
return { status: 'loading' }
|
|
36
|
-
})
|
|
37
|
-
fn()
|
|
38
|
-
.then((data) => {
|
|
39
|
-
if (cancelled) return
|
|
40
|
-
setReloading(false)
|
|
41
|
-
setState({ status: 'ok', data })
|
|
42
|
-
})
|
|
43
|
-
.catch((err: unknown) => {
|
|
44
|
-
if (cancelled) return
|
|
45
|
-
setReloading(false)
|
|
46
|
-
setState({ status: 'error', message: errorMessage(err) })
|
|
47
|
-
})
|
|
48
|
-
return () => {
|
|
49
|
-
cancelled = true
|
|
50
|
-
}
|
|
51
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps -- deps are the caller's cache key
|
|
52
|
-
}, [...deps, epoch])
|
|
53
|
-
|
|
54
|
-
const reload = useCallback(() => {
|
|
55
|
-
setReloading(true)
|
|
56
|
-
setEpoch((e) => e + 1)
|
|
57
|
-
}, [])
|
|
58
|
-
return { state, reload, reloading }
|
|
59
|
-
}
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `useCapability` — the convention's WRITE primitive: a node method made
|
|
3
|
-
* interactive, wrapped in one uniform lifecycle (idle → running → done/failed).
|
|
4
|
-
* Every actuator (a form submit, a danger button) shares this machine instead
|
|
5
|
-
* of re-implementing busy/error flags by hand.
|
|
6
|
-
*
|
|
7
|
-
* const check = useCapability(() => invokeNode(session, nodeId, 'check', {}))
|
|
8
|
-
* …
|
|
9
|
-
* if (await check.run()) reload()
|
|
10
|
-
*
|
|
11
|
-
* (This is the single seam where permission-gating would later land — a
|
|
12
|
-
* `permitted` flag computed from the node's affordances — without touching any
|
|
13
|
-
* presentational component.)
|
|
14
|
-
*/
|
|
15
|
-
import { useCallback, useState } from 'react'
|
|
16
|
-
|
|
17
|
-
import { errorMessage } from './client'
|
|
18
|
-
|
|
19
|
-
export type Phase = 'idle' | 'running' | 'done' | 'failed'
|
|
20
|
-
|
|
21
|
-
export interface Capability<P = void> {
|
|
22
|
-
/** Phase of the most recent invocation. */
|
|
23
|
-
phase: Phase
|
|
24
|
-
/** Failure message when `phase === 'failed'`, else null. */
|
|
25
|
-
error: string | null
|
|
26
|
-
/** Invoke; resolves true on success, false on failure (error then set). */
|
|
27
|
-
run: [P] extends [void] ? () => Promise<boolean> : (params: P) => Promise<boolean>
|
|
28
|
-
/** Return to idle, clearing any error. */
|
|
29
|
-
reset: () => void
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export function useCapability<P = void>(perform: (params: P) => Promise<unknown>): Capability<P> {
|
|
33
|
-
const [phase, setPhase] = useState<Phase>('idle')
|
|
34
|
-
const [error, setError] = useState<string | null>(null)
|
|
35
|
-
|
|
36
|
-
const run = useCallback(
|
|
37
|
-
async (params: P): Promise<boolean> => {
|
|
38
|
-
setPhase('running')
|
|
39
|
-
setError(null)
|
|
40
|
-
try {
|
|
41
|
-
await perform(params)
|
|
42
|
-
setPhase('done')
|
|
43
|
-
return true
|
|
44
|
-
} catch (err) {
|
|
45
|
-
setPhase('failed')
|
|
46
|
-
setError(errorMessage(err))
|
|
47
|
-
return false
|
|
48
|
-
}
|
|
49
|
-
},
|
|
50
|
-
[perform],
|
|
51
|
-
)
|
|
52
|
-
|
|
53
|
-
const reset = useCallback(() => {
|
|
54
|
-
setPhase('idle')
|
|
55
|
-
setError(null)
|
|
56
|
-
}, [])
|
|
57
|
-
|
|
58
|
-
return { phase, error, run, reset } as Capability<P>
|
|
59
|
-
}
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
import type { KernelClient } from '@astrale-os/shell'
|
|
2
|
-
|
|
3
|
-
import { useCallback, useEffect, useState } from 'react'
|
|
4
|
-
|
|
5
|
-
import { errorMessage, type KernelNode } from './client'
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* The node shape `shell.kernel.get()` resolves — a graph `Node` ({@link
|
|
9
|
-
* KernelClient.get} returns `Node | null`). Derived from the client's own
|
|
10
|
-
* return type so this file never imports server schema; mapped onto the plain
|
|
11
|
-
* {@link KernelNode} the view helpers consume by `toKernelNode`.
|
|
12
|
-
*/
|
|
13
|
-
type GraphNode = NonNullable<Awaited<ReturnType<KernelClient['get']>>>
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Project a graph `Node` onto the plain, schema-free {@link KernelNode}. `path`
|
|
17
|
-
* and `class` arrive as typed `Path` instances over the wire — `String()` yields
|
|
18
|
-
* their raw form (and is the identity on a plain string), so this stays correct
|
|
19
|
-
* whether or not the client hydrates them.
|
|
20
|
-
*/
|
|
21
|
-
function toKernelNode(node: GraphNode): KernelNode {
|
|
22
|
-
return {
|
|
23
|
-
id: String(node.id),
|
|
24
|
-
path: String(node.path),
|
|
25
|
-
class: String(node.class),
|
|
26
|
-
props: { ...node.props },
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export type NodeState =
|
|
31
|
-
| { status: 'idle' }
|
|
32
|
-
| { status: 'loading' }
|
|
33
|
-
| { status: 'ok'; node: KernelNode }
|
|
34
|
-
| { status: 'error'; message: string }
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Fetch a node by id through `shell.kernel.get('@<id>')`.
|
|
38
|
-
* `null` means missing or masked; `reload()` refetches the current id.
|
|
39
|
-
*/
|
|
40
|
-
export function useNode(
|
|
41
|
-
session: KernelClient,
|
|
42
|
-
nodeId: string | undefined,
|
|
43
|
-
): NodeState & { reload(): void } {
|
|
44
|
-
const [state, setState] = useState<NodeState>({ status: 'idle' })
|
|
45
|
-
const [nonce, setNonce] = useState(0)
|
|
46
|
-
|
|
47
|
-
const reload = useCallback(() => {
|
|
48
|
-
setNonce((n) => n + 1)
|
|
49
|
-
}, [])
|
|
50
|
-
|
|
51
|
-
useEffect(() => {
|
|
52
|
-
if (!nodeId) {
|
|
53
|
-
setState({ status: 'idle' })
|
|
54
|
-
return
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
let cancelled = false
|
|
58
|
-
setState({ status: 'loading' })
|
|
59
|
-
session
|
|
60
|
-
.get(`@${nodeId}`)
|
|
61
|
-
.then((node) => {
|
|
62
|
-
if (cancelled) return
|
|
63
|
-
if (node === null) {
|
|
64
|
-
setState({ status: 'error', message: 'Node not found or not visible' })
|
|
65
|
-
return
|
|
66
|
-
}
|
|
67
|
-
setState({ status: 'ok', node: toKernelNode(node) })
|
|
68
|
-
})
|
|
69
|
-
.catch((err: unknown) => {
|
|
70
|
-
if (!cancelled) {
|
|
71
|
-
setState({ status: 'error', message: errorMessage(err) })
|
|
72
|
-
}
|
|
73
|
-
})
|
|
74
|
-
|
|
75
|
-
return () => {
|
|
76
|
-
cancelled = true
|
|
77
|
-
}
|
|
78
|
-
}, [session, nodeId, nonce])
|
|
79
|
-
|
|
80
|
-
return { ...state, reload }
|
|
81
|
-
}
|
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
createShell,
|
|
3
|
-
type IntentMessage,
|
|
4
|
-
type IntentRegistry,
|
|
5
|
-
type KernelClient,
|
|
6
|
-
type Shell,
|
|
7
|
-
} from '@astrale-os/shell'
|
|
8
|
-
import { useEffect, useMemo, useState } from 'react'
|
|
9
|
-
|
|
10
|
-
export type ShellStatus = 'loading' | 'ready' | 'standalone'
|
|
11
|
-
|
|
12
|
-
export type ShellState = {
|
|
13
|
-
status: ShellStatus
|
|
14
|
-
/** The kernel handle once `ready` — view hooks call through `session.call`. */
|
|
15
|
-
session: KernelClient | null
|
|
16
|
-
/** Current target node id — updates on `setTarget` hot-swaps. */
|
|
17
|
-
nodeId: string | undefined
|
|
18
|
-
/** Why we fell back to `standalone` (no parent / handshake timeout). */
|
|
19
|
-
reason: string | null
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Boots the real Astrale shell in sandboxed (child) mode: runs the init
|
|
24
|
-
* handshake with the parent window to obtain the kernel session + target node,
|
|
25
|
-
* then tracks `setTarget` hot-swaps. The shell's `KernelClient` owns the
|
|
26
|
-
* delegation token (refreshed by the parent) and the kernel transport, so view
|
|
27
|
-
* hooks just call `session.call('@<id>::<method>')`.
|
|
28
|
-
*
|
|
29
|
-
* Three outcomes:
|
|
30
|
-
* - `loading` — handshake in flight
|
|
31
|
-
* - `ready` — parent completed the handshake; `session`/`nodeId` populated
|
|
32
|
-
* - `standalone` — no parent or it timed out (opened directly in a tab); the
|
|
33
|
-
* view renders a self-describing fallback
|
|
34
|
-
*/
|
|
35
|
-
export function useShell(): ShellState {
|
|
36
|
-
const [status, setStatus] = useState<ShellStatus>('loading')
|
|
37
|
-
const [session, setSession] = useState<KernelClient | null>(null)
|
|
38
|
-
const [nodeId, setNodeId] = useState<string | undefined>(undefined)
|
|
39
|
-
const [reason, setReason] = useState<string | null>(null)
|
|
40
|
-
|
|
41
|
-
useEffect(() => {
|
|
42
|
-
// Not framed (opened directly in a tab): skip the handshake entirely rather
|
|
43
|
-
// than waiting out the init timeout — there is no parent to answer it.
|
|
44
|
-
if (!window.parent || window.parent === window) {
|
|
45
|
-
setReason('not running inside a parent frame')
|
|
46
|
-
setStatus('standalone')
|
|
47
|
-
return
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
let cancelled = false
|
|
51
|
-
let shell: Shell | null = null
|
|
52
|
-
|
|
53
|
-
void (async () => {
|
|
54
|
-
try {
|
|
55
|
-
const built = createShell({ mode: 'sandboxed', initTimeoutMs: 5000 })
|
|
56
|
-
await built.init()
|
|
57
|
-
if (cancelled) {
|
|
58
|
-
void built.dispose()
|
|
59
|
-
return
|
|
60
|
-
}
|
|
61
|
-
shell = built
|
|
62
|
-
|
|
63
|
-
// Hand views the shell's real `KernelClient` directly. It reads the live
|
|
64
|
-
// (parent-refreshed) token on each call and owns the transport, so view
|
|
65
|
-
// hooks just call `session.call('@<id>::<method>', params)`.
|
|
66
|
-
setSession(built.kernel)
|
|
67
|
-
setNodeId(built.targetNodeId)
|
|
68
|
-
setStatus('ready')
|
|
69
|
-
|
|
70
|
-
// Hot-swap: the parent pushes a new target via the typed `setTarget`
|
|
71
|
-
// intent; the iframe stays mounted, only `nodeId` updates.
|
|
72
|
-
built.parent?.on('intent', (msg: IntentMessage) => {
|
|
73
|
-
if (msg.envelope.name !== 'setTarget') return
|
|
74
|
-
const payload = msg.envelope.payload as IntentRegistry['setTarget']['payload']
|
|
75
|
-
if (typeof payload.nodeId === 'string') setNodeId(payload.nodeId)
|
|
76
|
-
})
|
|
77
|
-
} catch (err) {
|
|
78
|
-
if (cancelled) return
|
|
79
|
-
setReason(err instanceof Error ? err.message : String(err))
|
|
80
|
-
setStatus('standalone')
|
|
81
|
-
}
|
|
82
|
-
})()
|
|
83
|
-
|
|
84
|
-
return () => {
|
|
85
|
-
cancelled = true
|
|
86
|
-
if (shell) void shell.dispose()
|
|
87
|
-
}
|
|
88
|
-
}, [])
|
|
89
|
-
|
|
90
|
-
return useMemo(() => ({ status, session, nodeId, reason }), [status, session, nodeId, reason])
|
|
91
|
-
}
|
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tiny multi-view router for the domain's one SPA bundle.
|
|
3
|
-
*
|
|
4
|
-
* The domain declares several SPA Views, each mounted by the shell at its own
|
|
5
|
-
* path under `/ui/*` (e.g. `/ui/status-page`). The shell mounts ONE iframe per view,
|
|
6
|
-
* so inside the iframe `window.location.pathname` is that view's mount path. This
|
|
7
|
-
* bundle reads that path and renders the matching view — one build, many views.
|
|
8
|
-
*
|
|
9
|
-
* Two pieces:
|
|
10
|
-
* - `resolveView` — map `window.location.pathname` → a view component.
|
|
11
|
-
* - `ViewFrame` — DRYs the shell-handshake gating (`loading`/`standalone`/
|
|
12
|
-
* `ready`) every view repeats, so a view only writes its `ready` body.
|
|
13
|
-
*
|
|
14
|
-
* To add a view: write a `ViewComponent` (usually wrapping `ViewFrame`), add a
|
|
15
|
-
* `ROUTES` entry keyed by its mount path in `src/app.tsx`, and register a
|
|
16
|
-
* matching `defineView({ mount })` in the domain's `views/`.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import type { KernelClient } from '@astrale-os/shell'
|
|
20
|
-
import type { ReactNode } from 'react'
|
|
21
|
-
|
|
22
|
-
import type { ShellState } from './use-shell'
|
|
23
|
-
|
|
24
|
-
/** A view: given the shell state, render its tree. */
|
|
25
|
-
export type ViewComponent = (shell: ShellState) => ReactNode
|
|
26
|
-
|
|
27
|
-
/** Mount path → view component (key e.g. `/ui/status-page`). */
|
|
28
|
-
export type ViewRoutes = Record<string, ViewComponent>
|
|
29
|
-
|
|
30
|
-
/** Strip a single trailing slash (but keep a bare `/`). */
|
|
31
|
-
function normalizePath(pathname: string): string {
|
|
32
|
-
return pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Resolve the view for a mount path — exact match, tolerating a trailing slash.
|
|
37
|
-
* Returns `undefined` when no route is registered (caller renders a fallback).
|
|
38
|
-
*/
|
|
39
|
-
export function resolveView(
|
|
40
|
-
routes: ViewRoutes,
|
|
41
|
-
pathname: string = window.location.pathname,
|
|
42
|
-
): ViewComponent | undefined {
|
|
43
|
-
return routes[normalizePath(pathname)]
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Shared shell-handshake gate. Renders the `.wrap`/`.card` shell and:
|
|
48
|
-
* - `loading` → title + subline + "Waiting for the shell handshake…"
|
|
49
|
-
* - `standalone` → title + subline + the "No parent shell" banner copy
|
|
50
|
-
* - `ready` → `children(session, nodeId)` (session is non-null here)
|
|
51
|
-
*
|
|
52
|
-
* Mirrors the markup/classes the original single-view app used, so styling is
|
|
53
|
-
* unchanged across views.
|
|
54
|
-
*/
|
|
55
|
-
export function ViewFrame({
|
|
56
|
-
shell,
|
|
57
|
-
title,
|
|
58
|
-
subline,
|
|
59
|
-
children,
|
|
60
|
-
}: {
|
|
61
|
-
shell: ShellState
|
|
62
|
-
title: string
|
|
63
|
-
subline?: string
|
|
64
|
-
children: (session: KernelClient, nodeId: string | undefined) => ReactNode
|
|
65
|
-
}) {
|
|
66
|
-
const { status, session, nodeId, reason } = shell
|
|
67
|
-
|
|
68
|
-
return (
|
|
69
|
-
<div className="wrap">
|
|
70
|
-
<div className="card">
|
|
71
|
-
{status === 'loading' && (
|
|
72
|
-
<>
|
|
73
|
-
<h1 className="title">{title}</h1>
|
|
74
|
-
{subline && <p className="subline">{subline}</p>}
|
|
75
|
-
<p className="muted">Waiting for the shell handshake…</p>
|
|
76
|
-
</>
|
|
77
|
-
)}
|
|
78
|
-
|
|
79
|
-
{status === 'standalone' && (
|
|
80
|
-
<>
|
|
81
|
-
<h1 className="title">{title}</h1>
|
|
82
|
-
{subline && <p className="subline">{subline}</p>}
|
|
83
|
-
<div className="banner">
|
|
84
|
-
No parent shell ({reason}). This view is meant to be mounted by the Astrale GUI, which
|
|
85
|
-
hands it a target node and a kernel session. Showing a standalone preview.
|
|
86
|
-
</div>
|
|
87
|
-
<p className="body muted">
|
|
88
|
-
When mounted by the shell, this card renders the node you opened.
|
|
89
|
-
</p>
|
|
90
|
-
</>
|
|
91
|
-
)}
|
|
92
|
-
|
|
93
|
-
{status === 'ready' && session && children(session, nodeId)}
|
|
94
|
-
</div>
|
|
95
|
-
</div>
|
|
96
|
-
)
|
|
97
|
-
}
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
/** Shared display formatters — pure, presentation-side (used across views). */
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Coarse relative time for `lastCheckedAt`-style ISO stamps:
|
|
5
|
-
* `just now` / `5m ago` / `2h ago` / a `YYYY-MM-DD` date for anything older than
|
|
6
|
-
* 30 days. Returns `—` for a missing stamp and the raw string for an unparseable
|
|
7
|
-
* one.
|
|
8
|
-
*/
|
|
9
|
-
export function relativeTime(iso: string | undefined, now = Date.now()): string {
|
|
10
|
-
if (!iso) return '—'
|
|
11
|
-
const t = Date.parse(iso)
|
|
12
|
-
if (Number.isNaN(t)) return iso
|
|
13
|
-
const diff = now - t
|
|
14
|
-
if (diff < 0) return 'just now'
|
|
15
|
-
const s = Math.floor(diff / 1000)
|
|
16
|
-
if (s < 45) return 'just now'
|
|
17
|
-
const m = Math.floor(s / 60)
|
|
18
|
-
if (m < 60) return `${m}m ago`
|
|
19
|
-
const h = Math.floor(m / 60)
|
|
20
|
-
if (h < 48) return `${h}h ago`
|
|
21
|
-
const d = Math.floor(h / 24)
|
|
22
|
-
if (d < 30) return `${d}d ago`
|
|
23
|
-
return new Date(t).toISOString().slice(0, 10)
|
|
24
|
-
}
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The design system — generic, feature-AGNOSTIC presentational primitives +
|
|
3
|
-
* display formatters, no domain knowledge and no kernel session. Import from the
|
|
4
|
-
* barrel: `import { Panel, KV, relativeTime } from '@/ui'`. A view's own
|
|
5
|
-
* health/status chips and other domain-shaped widgets live alongside their
|
|
6
|
-
* feature, not here.
|
|
7
|
-
*/
|
|
8
|
-
export { EmptyState, ErrorBanner, Panel, Spinner } from './surface'
|
|
9
|
-
export { ExternalLink, KV, Mono } from './value'
|
|
10
|
-
export { relativeTime } from './format'
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
/** Containers + feedback surfaces — pure presentational. */
|
|
2
|
-
import type { ReactNode } from 'react'
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* A titled card section. `actions` render in the header (e.g. a button); `tone:
|
|
6
|
-
* 'danger'` tints the border/title for destructive panels.
|
|
7
|
-
*/
|
|
8
|
-
export function Panel({
|
|
9
|
-
title,
|
|
10
|
-
actions,
|
|
11
|
-
tone,
|
|
12
|
-
children,
|
|
13
|
-
}: {
|
|
14
|
-
title: string
|
|
15
|
-
actions?: ReactNode
|
|
16
|
-
tone?: 'danger'
|
|
17
|
-
children: ReactNode
|
|
18
|
-
}) {
|
|
19
|
-
return (
|
|
20
|
-
<section className={tone === 'danger' ? 'panel panel-danger' : 'panel'}>
|
|
21
|
-
<header className="panel-head">
|
|
22
|
-
<h2 className="panel-title">{title}</h2>
|
|
23
|
-
{actions && <div className="panel-actions">{actions}</div>}
|
|
24
|
-
</header>
|
|
25
|
-
{children}
|
|
26
|
-
</section>
|
|
27
|
-
)
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** A red alert banner for a failed call. */
|
|
31
|
-
export function ErrorBanner({ children }: { children: ReactNode }) {
|
|
32
|
-
return (
|
|
33
|
-
<div className="banner banner-error" role="alert">
|
|
34
|
-
{children}
|
|
35
|
-
</div>
|
|
36
|
-
)
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** Teaching empty state — what's missing and an optional command that fixes it. */
|
|
40
|
-
export function EmptyState({ children, hint }: { children: ReactNode; hint?: string }) {
|
|
41
|
-
return (
|
|
42
|
-
<div className="empty">
|
|
43
|
-
<p className="empty-text">{children}</p>
|
|
44
|
-
{hint && <code className="empty-hint">{hint}</code>}
|
|
45
|
-
</div>
|
|
46
|
-
)
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** A spinner with a label, for in-flight loads. */
|
|
50
|
-
export function Spinner({ label }: { label: string }) {
|
|
51
|
-
return (
|
|
52
|
-
<p className="muted loading-line">
|
|
53
|
-
<span className="spinner" aria-hidden="true" /> {label}
|
|
54
|
-
</p>
|
|
55
|
-
)
|
|
56
|
-
}
|