@astrale-os/adapter-cloudflare 0.3.0 → 0.3.2
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/LICENSE +202 -0
- package/package.json +4 -4
- package/template/.agents/skills/astrale-cli/SKILL.md +633 -0
- package/template/.domain-studio/comments.json +4 -0
- package/template/.env.example +3 -6
- package/template/CLAUDE.md +8 -6
- package/template/README.md +28 -19
- package/template/client/__tests__/app.test.tsx +39 -248
- package/template/client/__tests__/harness.ts +0 -47
- package/template/client/__tests__/shell.test.ts +46 -0
- package/template/client/package.json +1 -1
- package/template/client/src/app.tsx +6 -8
- package/template/client/src/shell/index.ts +1 -0
- package/template/client/src/shell/transformers.ts +36 -33
- package/template/client/src/shell/use-node.ts +21 -13
- package/template/client/src/ui/index.ts +4 -7
- package/template/core/README.md +23 -0
- package/template/deps.ts +10 -9
- package/template/domain.ts +11 -9
- package/template/env.ts +3 -5
- package/template/functions/index.ts +14 -25
- package/template/icons.ts +25 -0
- package/template/integrations/README.md +22 -0
- package/template/package.json +3 -3
- package/template/runtime/index.ts +25 -72
- package/template/schema/index.ts +10 -10
- package/template/tsconfig.json +1 -0
- package/template/views/index.ts +15 -12
- package/template/client/__tests__/kernel.test.ts +0 -77
- package/template/client/__tests__/seam.test.tsx +0 -115
- package/template/client/src/status/components/StatusCard.tsx +0 -160
- package/template/client/src/status/components/index.ts +0 -1
- package/template/client/src/status/hooks/index.ts +0 -3
- package/template/client/src/status/hooks/useCheck.mutation.ts +0 -16
- package/template/client/src/status/hooks/useCheckable.query.ts +0 -72
- package/template/client/src/status/index.ts +0 -7
- package/template/client/src/status/status.api.ts +0 -29
- package/template/client/src/status/status.mappers.ts +0 -102
- package/template/client/src/status/status.types.ts +0 -26
- package/template/client/src/ui/StatusBadge.tsx +0 -31
- package/template/client/src/views/status.tsx +0 -28
- package/template/core/monitor/health.ts +0 -34
- package/template/core/monitor/index.ts +0 -9
- package/template/core/monitor/keys.ts +0 -41
- package/template/core/monitor/node.ts +0 -63
- package/template/integrations/prober/http.ts +0 -32
- package/template/integrations/prober/mock.ts +0 -18
- package/template/integrations/prober/port.ts +0 -26
- package/template/integrations/prober/registry.ts +0 -65
- package/template/runtime/monitoring/index.ts +0 -8
- package/template/runtime/monitoring/monitor/check.ts +0 -29
- package/template/runtime/monitoring/monitor/index.ts +0 -10
- package/template/runtime/monitoring/monitor/seed.ts +0 -104
- package/template/runtime/monitoring/monitor/watch.ts +0 -31
- package/template/runtime/monitoring/page/add.ts +0 -21
- package/template/runtime/monitoring/page/check.ts +0 -50
- package/template/runtime/monitoring/page/create.ts +0 -24
- package/template/runtime/monitoring/page/index.ts +0 -9
- package/template/runtime/shared.ts +0 -21
- package/template/schema/monitor.ts +0 -92
- package/template/views/status-page.ts +0 -16
- package/template/views/welcome.ts +0 -35
|
@@ -4,6 +4,12 @@ import { useCallback, useEffect, useState } from 'react'
|
|
|
4
4
|
|
|
5
5
|
import { errorMessage, type KernelNode } from './client'
|
|
6
6
|
|
|
7
|
+
type KernelReadClient = KernelClient & {
|
|
8
|
+
get(
|
|
9
|
+
path: string,
|
|
10
|
+
): Promise<{ node: { id: string } | null; wire: { nodes: readonly KernelNode[] } }>
|
|
11
|
+
}
|
|
12
|
+
|
|
7
13
|
export type NodeState =
|
|
8
14
|
| { status: 'idle' }
|
|
9
15
|
| { status: 'loading' }
|
|
@@ -11,16 +17,8 @@ export type NodeState =
|
|
|
11
17
|
| { status: 'error'; message: string }
|
|
12
18
|
|
|
13
19
|
/**
|
|
14
|
-
* Fetch a node by id
|
|
15
|
-
*
|
|
16
|
-
* `session` is a stable reference for the view's lifetime, so listing it as a
|
|
17
|
-
* dep never re-fires on its own — and the shell's client reads the fresh token
|
|
18
|
-
* at call time, so a `ctrl:tokenRefresh` needs no re-fetch.
|
|
19
|
-
*
|
|
20
|
-
* Returns the node state plus a `reload()` that re-fetches the current node —
|
|
21
|
-
* the view calls it after an instance method (e.g. `::check`) mutates the node
|
|
22
|
-
* so the fresh props render. It bumps an internal `nonce` dep, re-firing the
|
|
23
|
-
* effect.
|
|
20
|
+
* Fetch a node by id through `shell.kernel.get('@<id>')`.
|
|
21
|
+
* `null` means missing or masked; `reload()` refetches the current id.
|
|
24
22
|
*/
|
|
25
23
|
export function useNode(
|
|
26
24
|
session: KernelClient,
|
|
@@ -40,11 +38,21 @@ export function useNode(
|
|
|
40
38
|
}
|
|
41
39
|
|
|
42
40
|
let cancelled = false
|
|
41
|
+
const kernel = session as KernelReadClient
|
|
43
42
|
setState({ status: 'loading' })
|
|
44
|
-
|
|
45
|
-
.
|
|
43
|
+
kernel
|
|
44
|
+
.get(`@${nodeId}`)
|
|
46
45
|
.then((result) => {
|
|
47
|
-
if (
|
|
46
|
+
if (cancelled) return
|
|
47
|
+
const node =
|
|
48
|
+
result.node === null
|
|
49
|
+
? null
|
|
50
|
+
: (result.wire.nodes.find((n) => n.id === result.node?.id) ?? null)
|
|
51
|
+
if (node === null) {
|
|
52
|
+
setState({ status: 'error', message: 'Node not found or not visible' })
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
setState({ status: 'ok', node })
|
|
48
56
|
})
|
|
49
57
|
.catch((err: unknown) => {
|
|
50
58
|
if (!cancelled) {
|
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The design system — generic, feature-AGNOSTIC presentational primitives +
|
|
3
|
-
* display formatters, no domain knowledge and no kernel session.
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* Import from the barrel: `import { Panel, KV, relativeTime } from '@/ui'`.
|
|
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.
|
|
8
7
|
*/
|
|
9
8
|
export { EmptyState, ErrorBanner, Panel, Spinner } from './surface'
|
|
10
|
-
export { StatusBadge } from './StatusBadge'
|
|
11
|
-
export type { HealthStatus } from './StatusBadge'
|
|
12
9
|
export { ExternalLink, KV, Mono } from './value'
|
|
13
10
|
export { relativeTime } from './format'
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# core/ — pure domain logic
|
|
2
|
+
|
|
3
|
+
This folder holds the **pure, transport-agnostic logic** for each bounded
|
|
4
|
+
context — no I/O, no `fetch`, no clock/RNG, no kernel session. Just functions
|
|
5
|
+
over plain data that `runtime/` handlers call. Being pure makes it the easy part
|
|
6
|
+
to unit-test.
|
|
7
|
+
|
|
8
|
+
A context usually gets its own subfolder:
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
core/<context>/
|
|
12
|
+
keys.ts compiled-schema accessors (class paths, prop keys) — the ONE
|
|
13
|
+
place graph-facing strings come from; read them off `D` from
|
|
14
|
+
`schema/`, never hand-write them
|
|
15
|
+
logic.ts the pure rules (validation, computation, state transitions)
|
|
16
|
+
index.ts re-export the context's surface
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Keep the impure bits (network calls, kernel reads/writes, entropy) out of here —
|
|
20
|
+
those live behind a port in `integrations/` and in the `runtime/` handlers.
|
|
21
|
+
|
|
22
|
+
Delete this README once you add your first context. Load the `astrale-domain`
|
|
23
|
+
skill for the modeling patterns.
|
package/template/deps.ts
CHANGED
|
@@ -7,20 +7,21 @@ import type { Env } from './env'
|
|
|
7
7
|
* it's where ports/clients are wired once instead of being re-derived in every
|
|
8
8
|
* handler.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* It starts as a PASSTHROUGH — handlers get the raw `Env` as `ctx.deps`.
|
|
11
|
+
* Transform here the moment a handler should depend on a PORT (built from
|
|
12
|
+
* `integrations/`) instead of raw config — that's the whole point of this seam:
|
|
13
13
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* import { buildExampleRegistry, type ExampleRegistry } from './integrations/example/registry'
|
|
15
|
+
* export interface Deps extends ExampleRegistry {}
|
|
16
|
+
* export function deps(env: Env): Deps {
|
|
17
|
+
* return buildExampleRegistry(env)
|
|
18
|
+
* }
|
|
17
19
|
*/
|
|
18
|
-
import { buildProberRegistry, type ProberRegistry } from './integrations/prober/registry'
|
|
19
20
|
|
|
20
21
|
/** Typed dependency container handed to every method as `ctx.deps`. */
|
|
21
|
-
export interface Deps extends
|
|
22
|
+
export interface Deps extends Env {}
|
|
22
23
|
|
|
23
24
|
/** Map the worker env to the handler deps (the seam `defineDomain({ deps })` mounts). */
|
|
24
25
|
export function deps(env: Env): Deps {
|
|
25
|
-
return
|
|
26
|
+
return env
|
|
26
27
|
}
|
package/template/domain.ts
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* domain.ts — the WORKER-SAFE domain definition: what the domain IS (its
|
|
3
3
|
* `schema`, `methods`, `deps`, `views`, `functions`) plus its identity
|
|
4
|
-
* (`origin` defaults to `schema.domain`; `postInstall`).
|
|
5
|
-
* EXPLICITLY here — a renamed or mistyped module is a
|
|
6
|
-
* call, never a silently-missing route. The handlers live
|
|
7
|
-
* composition root), the pure logic in `core/`, the
|
|
8
|
-
* `integrations/` — but the worker only ever sees this one
|
|
4
|
+
* (`origin` defaults to `schema.domain`; `requires`; optional `postInstall`).
|
|
5
|
+
* Everything is wired EXPLICITLY here — a renamed or mistyped module is a
|
|
6
|
+
* compile error at this call, never a silently-missing route. The handlers live
|
|
7
|
+
* in `runtime/` (the composition root), the pure logic in `core/`, the
|
|
8
|
+
* external-API ports in `integrations/` — but the worker only ever sees this one
|
|
9
|
+
* wired definition.
|
|
10
|
+
*
|
|
11
|
+
* The maps it wires (`methods`, `views`, `functions`) start empty — this is a
|
|
12
|
+
* blank skeleton. Fill them in their folders and they flow through here with no
|
|
13
|
+
* change needed. Add `postInstall: functions.<key>` once you author a seed
|
|
14
|
+
* function — the kernel calls it once after install, as __SYSTEM__.
|
|
9
15
|
*
|
|
10
16
|
* The generated worker (`.astrale/worker.gen.ts`) imports THIS and spreads it,
|
|
11
17
|
* so your folder layout is invisible to it — reorganize freely; only this wiring
|
|
@@ -27,9 +33,5 @@ export const domain = defineDomain({
|
|
|
27
33
|
deps,
|
|
28
34
|
views,
|
|
29
35
|
functions,
|
|
30
|
-
// Reference the `seed` function directly — the SDK derives its path from the
|
|
31
|
-
// `functions` map, so a rename can't leave a stale string. The kernel calls it
|
|
32
|
-
// once after install, as __SYSTEM__. (A class method would be `'class.X:seed'`.)
|
|
33
|
-
postInstall: functions.seed,
|
|
34
36
|
requires: [],
|
|
35
37
|
})
|
package/template/env.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Worker runtime env. The adapter injects `WORKER_URL` and the bindings; your
|
|
3
|
-
* handlers receive this as `ctx.deps`. Secrets from
|
|
4
|
-
* extra string fields here. Add typed fields as you
|
|
3
|
+
* handlers receive this (mapped by `deps.ts`) as `ctx.deps`. Secrets from
|
|
4
|
+
* `.env.<env>` arrive as extra string fields here. Add typed fields as you
|
|
5
|
+
* reference new secrets/vars.
|
|
5
6
|
*/
|
|
6
7
|
export interface Env {
|
|
7
8
|
/** The worker's canonical serving URL (its `iss` identity), injected by the
|
|
@@ -19,8 +20,5 @@ export interface Env {
|
|
|
19
20
|
/** Dev-only: forward /ui/* to a running Vite dev server. */
|
|
20
21
|
VIEW_DEV_URL?: string
|
|
21
22
|
|
|
22
|
-
/** `http` only — per-probe timeout in ms (default 10000). */
|
|
23
|
-
PROBE_TIMEOUT_MS?: string
|
|
24
|
-
|
|
25
23
|
[key: string]: unknown
|
|
26
24
|
}
|
|
@@ -2,32 +2,21 @@
|
|
|
2
2
|
* Standalone-function registry — one entry per callable that is NOT a method of
|
|
3
3
|
* a class. Each becomes a first-class domain MEMBER: a `Function` node at
|
|
4
4
|
* `/<origin>/functions/<key>` with an `of_domain` edge (slug `function.<key>`),
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* addressable by the semantic domain path `/:<origin>:function.<key>` on top of
|
|
6
|
+
* its worker route `/functions/<key>`. The map key is the slug.
|
|
7
|
+
*
|
|
8
|
+
* It starts EMPTY. Add a function (e.g. a webhook, or a `postInstall` seed) with
|
|
9
|
+
* `defineRemoteFunction` and list it here; reference it from `domain.ts` (e.g.
|
|
10
|
+
* `postInstall: functions.seed`) so a rename can't leave a stale path string:
|
|
11
|
+
*
|
|
12
|
+
* seed: defineRemoteFunction({
|
|
13
|
+
* inputSchema: z.object({}),
|
|
14
|
+
* outputSchema: z.object({ seeded: z.number().int() }),
|
|
15
|
+
* authorize: async () => undefined,
|
|
16
|
+
* execute: ({ kernel }) => ({ seeded: 0 }),
|
|
17
|
+
* }),
|
|
9
18
|
*
|
|
10
19
|
* A standalone function's `execute` receives the same request context as a
|
|
11
20
|
* method MINUS `self` (it is not node-bound): `{ params, kernel, deps, auth, … }`.
|
|
12
21
|
*/
|
|
13
|
-
|
|
14
|
-
import { z } from 'zod'
|
|
15
|
-
|
|
16
|
-
import type { Deps } from '../deps'
|
|
17
|
-
|
|
18
|
-
import { monitor } from '../runtime/monitoring'
|
|
19
|
-
|
|
20
|
-
export const functions = {
|
|
21
|
-
// Post-install bootstrap, wired as `postInstall` in `domain.ts`. The kernel
|
|
22
|
-
// calls it ONCE after install, as __SYSTEM__ (its delegation minted for this
|
|
23
|
-
// function's own identity), so the domain comes up demonstrable. Must stay
|
|
24
|
-
// idempotent — a re-install runs it again. `auth: 'required'` (the default)
|
|
25
|
-
// makes `ctx.kernel` non-null; the body delegates to the per-operation seed
|
|
26
|
-
// logic in `runtime/monitor/seed.ts`.
|
|
27
|
-
seed: defineRemoteFunction<Record<string, never>, { seeded: number }, Deps>({
|
|
28
|
-
inputSchema: z.object({}),
|
|
29
|
-
outputSchema: z.object({ seeded: z.number().int() }),
|
|
30
|
-
authorize: async () => undefined,
|
|
31
|
-
execute: ({ kernel, deps }) => monitor.seed(kernel, deps.prober()),
|
|
32
|
-
}),
|
|
33
|
-
}
|
|
22
|
+
export const functions = {}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Class icons — raw SVG markup attached to a class via `nodeClass({ icon })`.
|
|
3
|
+
* The kernel materializes the icon onto the class `self` node (the `Iconable`
|
|
4
|
+
* interface) at boot; clients resolve an instance's icon through its class.
|
|
5
|
+
*
|
|
6
|
+
* Markup is raw inner SVG — lift glyphs verbatim from lucide (ISC) and the
|
|
7
|
+
* `svg()` wrapper supplies the `<svg>` shell. `stroke="currentColor"` so the
|
|
8
|
+
* glyph follows text color. Add one constant per class:
|
|
9
|
+
*
|
|
10
|
+
* import { BOX_ICON } from '../icons'
|
|
11
|
+
* export const Widget = nodeClass({ icon: BOX_ICON, props: { … } })
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const svg = (inner: string): string =>
|
|
15
|
+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">${inner}</svg>`
|
|
16
|
+
|
|
17
|
+
/** lucide `box` — a generic starter glyph for your first class. */
|
|
18
|
+
export const BOX_ICON = svg(
|
|
19
|
+
'<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>',
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
/** lucide `sparkles` — for an AI / generative class. */
|
|
23
|
+
export const SPARKLES_ICON = svg(
|
|
24
|
+
'<path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .962 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.962 0z"/><path d="M20 3v4"/><path d="M22 5h-4"/><path d="M4 17v2"/><path d="M5 18H3"/>',
|
|
25
|
+
)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# integrations/ — external-API ports + adapters
|
|
2
|
+
|
|
3
|
+
This is the seam between your domain's logic and the outside world (an HTTP API,
|
|
4
|
+
an SDK, a queue, …). The pattern keeps `runtime/` logic depending on a narrow
|
|
5
|
+
**port** (an interface), never on `fetch` or a vendor SDK directly — so a handler
|
|
6
|
+
is testable against a fake and the backend can be swapped per env or per node.
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
integrations/<service>/
|
|
10
|
+
port.ts the interface the logic depends on (the narrowest surface)
|
|
11
|
+
http.ts a real adapter implementing the port
|
|
12
|
+
mock.ts an offline/deterministic adapter, for tests
|
|
13
|
+
registry.ts picks an adapter from `Env` (and optionally the target node),
|
|
14
|
+
exposed to handlers as part of `Deps`
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`deps.ts` builds the registry from the worker `Env` once per isolate and mounts
|
|
18
|
+
it as `ctx.deps`; handlers resolve the concrete port per request. Until you add a
|
|
19
|
+
port, `deps.ts` is a passthrough (`Deps = Env`).
|
|
20
|
+
|
|
21
|
+
Delete this README once you add your first integration. Load the
|
|
22
|
+
`astrale-domain` skill for the DI / secrets / idempotency patterns.
|
package/template/package.json
CHANGED
|
@@ -14,10 +14,10 @@
|
|
|
14
14
|
"typecheck": "tsgo --noEmit"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@astrale-os/adapter-cloudflare": ">=0.3.
|
|
18
|
-
"@astrale-os/kernel-core": ">=0.
|
|
17
|
+
"@astrale-os/adapter-cloudflare": ">=0.3.2 <1.0.0",
|
|
18
|
+
"@astrale-os/kernel-core": ">=0.6.2 <1.0.0",
|
|
19
19
|
"@astrale-os/kernel-dsl": ">=0.1.2 <1.0.0",
|
|
20
|
-
"@astrale-os/sdk": ">=0.
|
|
20
|
+
"@astrale-os/sdk": ">=0.3.0 <1.0.0",
|
|
21
21
|
"@hono/node-server": "^1.19.0",
|
|
22
22
|
"zod": "^4.3.6"
|
|
23
23
|
},
|
|
@@ -1,82 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Runtime composition root — the ONLY place request context (kernel/self/params/
|
|
3
|
-
* auth) and `deps` meet the per-feature logic.
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
3
|
+
* auth) and `deps` meet the per-feature logic. The `methods` map is what
|
|
4
|
+
* `domain.ts` mounts: one entry per class method, each delegating to the pure
|
|
5
|
+
* logic in `core/` and resolving its ports from `deps`. No business logic lives
|
|
6
|
+
* here — this file is just the wiring.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* `class.Monitor` / `class.StatusPage`, NOT a shared `interface` block. The
|
|
11
|
-
* kernel dispatches `@<node>::check` to the impl for that node's class.
|
|
8
|
+
* It starts EMPTY (`class: {}`). Once you declare a class with methods in
|
|
9
|
+
* `schema/`, wire its handlers here:
|
|
12
10
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* import { remoteClassMethods, remoteMethod } from '@astrale-os/sdk'
|
|
12
|
+
* const method = remoteMethod<Deps>()
|
|
13
|
+
* const classMethods = remoteClassMethods<Deps>()
|
|
14
|
+
*
|
|
15
|
+
* const greetMethod = method(schema, 'Greeter', 'greet', {
|
|
16
|
+
* authorize: async () => undefined,
|
|
17
|
+
* execute: ({ params }) => ({ message: `hi ${params.name}` }),
|
|
18
|
+
* })
|
|
19
|
+
*
|
|
20
|
+
* export const methods: SchemaMethodsImpl<typeof schema, Deps> = {
|
|
21
|
+
* class: { Greeter: classMethods(schema, 'Greeter', { greet: greetMethod }) },
|
|
22
|
+
* }
|
|
23
|
+
*
|
|
24
|
+
* Load the `astrale-domain` skill for the full handler/authorize/port patterns.
|
|
15
25
|
*/
|
|
16
|
-
import {
|
|
26
|
+
import type { SchemaMethodsImpl } from '@astrale-os/sdk'
|
|
17
27
|
|
|
18
28
|
import type { Deps } from '../deps'
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
import { schema } from '../schema'
|
|
22
|
-
import { monitor, page } from './monitoring'
|
|
23
|
-
|
|
24
|
-
const method = remoteMethod<Deps>()
|
|
25
|
-
const classMethods = remoteClassMethods<Deps>()
|
|
26
|
-
|
|
27
|
-
// ── Monitor ─────────────────────────────────────────────────────────
|
|
28
|
-
|
|
29
|
-
const monitorCheckMethod = method(schema, 'Monitor', 'check', {
|
|
30
|
-
authorize: async () => undefined,
|
|
31
|
-
execute: async ({ kernel, self, deps }) => {
|
|
32
|
-
const node = await self.node()
|
|
33
|
-
const url = node.props[MONITOR_KEYS.url]
|
|
34
|
-
if (typeof url !== 'string') throw new Error('Monitor node is missing its url property')
|
|
35
|
-
// Pass the node to the picker — the prober is chosen FOR this monitor.
|
|
36
|
-
return monitor.check(kernel, deps.prober({ class: node.class.raw, url }), self.path.raw, url)
|
|
37
|
-
},
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
const watchMethod = method(schema, 'Monitor', 'watch', {
|
|
41
|
-
authorize: async () => undefined,
|
|
42
|
-
execute: ({ kernel, params }) => {
|
|
43
|
-
return monitor.watch(kernel, params)
|
|
44
|
-
},
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
// ── StatusPage ──────────────────────────────────────────────────────
|
|
48
|
-
|
|
49
|
-
const pageCheckMethod = method(schema, 'StatusPage', 'check', {
|
|
50
|
-
authorize: async () => undefined,
|
|
51
|
-
execute: ({ kernel, self }) => {
|
|
52
|
-
return page.check(kernel, self.path.raw)
|
|
53
|
-
},
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
const createMethod = method(schema, 'StatusPage', 'create', {
|
|
57
|
-
authorize: async () => undefined,
|
|
58
|
-
execute: ({ kernel, params }) => {
|
|
59
|
-
return page.create(kernel, params)
|
|
60
|
-
},
|
|
61
|
-
})
|
|
62
|
-
|
|
63
|
-
const addMethod = method(schema, 'StatusPage', 'add', {
|
|
64
|
-
authorize: async () => undefined,
|
|
65
|
-
execute: ({ kernel, self, params }) => {
|
|
66
|
-
return page.add(kernel, self.path.raw, params)
|
|
67
|
-
},
|
|
68
|
-
})
|
|
29
|
+
// While the map is empty, `schema` is only referenced as a type (`typeof schema`).
|
|
30
|
+
// Drop the `type` keyword once you pass `schema` to `method()` / `classMethods()`.
|
|
31
|
+
import type { schema } from '../schema'
|
|
69
32
|
|
|
70
33
|
export const methods: SchemaMethodsImpl<typeof schema, Deps> = {
|
|
71
|
-
class: {
|
|
72
|
-
Monitor: classMethods(schema, 'Monitor', {
|
|
73
|
-
check: monitorCheckMethod,
|
|
74
|
-
watch: watchMethod,
|
|
75
|
-
}),
|
|
76
|
-
StatusPage: classMethods(schema, 'StatusPage', {
|
|
77
|
-
check: pageCheckMethod,
|
|
78
|
-
create: createMethod,
|
|
79
|
-
add: addMethod,
|
|
80
|
-
}),
|
|
81
|
-
},
|
|
34
|
+
class: {},
|
|
82
35
|
}
|
package/template/schema/index.ts
CHANGED
|
@@ -5,26 +5,26 @@
|
|
|
5
5
|
* and the worker signs JWTs under it. The scaffolder set this string from your
|
|
6
6
|
* slug; change it here to rename the domain's identity.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* The maps start EMPTY — this is prepared terrain, not a working domain. Add a
|
|
9
|
+
* context by authoring `schema/<context>.ts` (declare classes with `nodeClass`,
|
|
10
|
+
* interfaces with `nodeInterface`, edges with `edgeClass`), then import its
|
|
11
|
+
* members and list them in the `interfaces` / `classes` maps below. Wire the
|
|
12
|
+
* matching handlers in `runtime/`. Load the `astrale-domain` skill for the
|
|
13
|
+
* authoring patterns.
|
|
10
14
|
*/
|
|
11
15
|
import { defineSchema, KernelSchema } from '@astrale-os/kernel-core'
|
|
12
16
|
import { compileDomain, type Domain } from '@astrale-os/kernel-core/domain'
|
|
13
17
|
|
|
14
|
-
import { Checkable, Monitor, StatusPage, watches } from './monitor'
|
|
15
|
-
|
|
16
18
|
export const schema = defineSchema('astrale-domain.example.dev', {
|
|
17
|
-
interfaces: {
|
|
18
|
-
classes: {
|
|
19
|
+
interfaces: {},
|
|
20
|
+
classes: {},
|
|
19
21
|
imports: [KernelSchema],
|
|
20
22
|
})
|
|
21
23
|
|
|
22
24
|
/**
|
|
23
25
|
* The compiled domain — resolved class/interface paths + qualified prop keys.
|
|
24
26
|
* `compileDomain` is pure (no env), so this is safe to import from the worker
|
|
25
|
-
* and from build tooling alike.
|
|
26
|
-
*
|
|
27
|
+
* and from build tooling alike. Read graph-facing strings (class paths, prop
|
|
28
|
+
* keys) off `D` once you add classes — never hand-write them.
|
|
27
29
|
*/
|
|
28
30
|
export const D: Domain<typeof schema> = compileDomain(schema)
|
|
29
|
-
|
|
30
|
-
export * from './monitor'
|
package/template/tsconfig.json
CHANGED
package/template/views/index.ts
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* View registry — one file per view under `views/`, assembled here. Slug = map
|
|
3
|
-
* key; each becomes a View node at `/<origin>/views/<slug>` whose iframe
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* key; each becomes a View node at `/<origin>/views/<slug>` whose iframe binding
|
|
4
|
+
* the SDK stamps with the worker's live serving URL when it builds the install
|
|
5
|
+
* bundle.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* It starts EMPTY. Two shapes of view:
|
|
8
|
+
*
|
|
9
|
+
* - Inline HTML, rendered straight from the worker (no client bundle):
|
|
10
|
+
* export const welcome = defineView({ auth: 'public', render: ({ c }) =>
|
|
11
|
+
* c.html('<h1>hello</h1>') })
|
|
12
|
+
* - A `client/` SPA view, mounted under `/ui/<route>` and offered for a class:
|
|
13
|
+
* export const panel = defineView({ auth: 'public', mount: '/ui/panel',
|
|
14
|
+
* viewFor: selfOf(SomeClass) })
|
|
15
|
+
* then register a matching route in `client/src/app.tsx`.
|
|
16
|
+
*
|
|
17
|
+
* Add the view file under `views/`, import it, and list it below (key = slug).
|
|
9
18
|
*/
|
|
10
|
-
|
|
11
|
-
import { welcome } from './welcome'
|
|
12
|
-
|
|
13
|
-
export const views = {
|
|
14
|
-
welcome,
|
|
15
|
-
'ui-status-page': statusPage,
|
|
16
|
-
}
|
|
19
|
+
export const views = {}
|
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest'
|
|
2
|
-
|
|
3
|
-
import { qualifiedString, readProp, readPropBySuffix } from '@/shell'
|
|
4
|
-
import { checkableFromNode, watchedMonitorRefs } from '@/status'
|
|
5
|
-
import { relativeTime } from '@/ui'
|
|
6
|
-
|
|
7
|
-
import { checkableNode } from './harness'
|
|
8
|
-
|
|
9
|
-
describe('prop readers', () => {
|
|
10
|
-
it('readProp returns the string value at the exact key', () => {
|
|
11
|
-
expect(readProp({ a: 'x', b: 1 }, 'a')).toBe('x')
|
|
12
|
-
expect(readProp({ a: 'x' }, 'missing')).toBeUndefined()
|
|
13
|
-
expect(readProp({ a: 1 }, 'a')).toBeUndefined() // non-string
|
|
14
|
-
})
|
|
15
|
-
|
|
16
|
-
it('readPropBySuffix matches the first key ending with the suffix', () => {
|
|
17
|
-
const props = {
|
|
18
|
-
'monitors.astrale.ai:class.Monitor.property.url': 'https://x.test',
|
|
19
|
-
'kernel.astrale.ai:interface.Named.property.name': 'n',
|
|
20
|
-
}
|
|
21
|
-
expect(readPropBySuffix(props, '.property.url')).toBe('https://x.test')
|
|
22
|
-
expect(readPropBySuffix(props, '.property.status')).toBeUndefined()
|
|
23
|
-
})
|
|
24
|
-
|
|
25
|
-
it('qualifiedString prefers a domain key over the kernel one for the same suffix', () => {
|
|
26
|
-
const props = {
|
|
27
|
-
'kernel.astrale.ai:interface.Named.property.name': 'kernel name',
|
|
28
|
-
'monitors.astrale.ai:class.Monitor.property.name': 'domain name',
|
|
29
|
-
}
|
|
30
|
-
expect(qualifiedString(props, 'name')).toBe('domain name')
|
|
31
|
-
})
|
|
32
|
-
})
|
|
33
|
-
|
|
34
|
-
describe('checkableFromNode mapper', () => {
|
|
35
|
-
it('projects the name and rolled-up status of a StatusPage node', () => {
|
|
36
|
-
const record = checkableFromNode(
|
|
37
|
-
checkableNode({ id: 'page-1', name: 'Public', status: 'degraded', className: 'StatusPage' }),
|
|
38
|
-
)
|
|
39
|
-
expect(record).toMatchObject({
|
|
40
|
-
id: 'page-1',
|
|
41
|
-
name: 'Public',
|
|
42
|
-
status: 'degraded',
|
|
43
|
-
className: 'StatusPage',
|
|
44
|
-
})
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
it('falls back to the path segment for the name and unknown for a missing status', () => {
|
|
48
|
-
const record = checkableFromNode(
|
|
49
|
-
checkableNode({ id: 'page-9', path: '/monitoring/pages/edge' }),
|
|
50
|
-
)
|
|
51
|
-
expect(record.name).toBe('edge')
|
|
52
|
-
expect(record.status).toBe('unknown')
|
|
53
|
-
})
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
describe('watchedMonitorRefs mapper', () => {
|
|
57
|
-
it('projects target refs and critical edge weights', () => {
|
|
58
|
-
const refs = watchedMonitorRefs([
|
|
59
|
-
{
|
|
60
|
-
class: { raw: '/:monitors.astrale.ai:class.watches' },
|
|
61
|
-
target: { raw: '/monitoring/monitors/astrale' },
|
|
62
|
-
props: { 'monitors.astrale.ai:class.watches.property.critical': true },
|
|
63
|
-
},
|
|
64
|
-
])
|
|
65
|
-
expect(refs).toEqual([{ target: '/monitoring/monitors/astrale', critical: true }])
|
|
66
|
-
})
|
|
67
|
-
})
|
|
68
|
-
|
|
69
|
-
describe('relativeTime', () => {
|
|
70
|
-
const now = Date.parse('2026-06-14T12:00:00Z')
|
|
71
|
-
it('renders coarse buckets and a dash for missing input', () => {
|
|
72
|
-
expect(relativeTime(undefined, now)).toBe('—')
|
|
73
|
-
expect(relativeTime('2026-06-14T11:55:00Z', now)).toBe('5m ago')
|
|
74
|
-
expect(relativeTime('2026-06-14T10:00:00Z', now)).toBe('2h ago')
|
|
75
|
-
expect(relativeTime('not-a-date', now)).toBe('not-a-date')
|
|
76
|
-
})
|
|
77
|
-
})
|