@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.
Files changed (43) hide show
  1. package/package.json +53 -0
  2. package/src/client.ts +54 -0
  3. package/src/cloudflare.ts +228 -0
  4. package/src/codegen/identity.ts +40 -0
  5. package/src/codegen/merge.ts +92 -0
  6. package/src/codegen/worker.ts +105 -0
  7. package/src/codegen/wrangler.ts +95 -0
  8. package/src/index.ts +17 -0
  9. package/src/params.ts +49 -0
  10. package/src/parse-output.ts +54 -0
  11. package/src/wrangler-cli.ts +240 -0
  12. package/template/.env.example +6 -0
  13. package/template/README.md +77 -0
  14. package/template/astrale.config.ts +35 -0
  15. package/template/client/README.md +85 -0
  16. package/template/client/__tests__/app.test.tsx +191 -0
  17. package/template/client/__tests__/harness.ts +221 -0
  18. package/template/client/__tests__/kernel.test.ts +68 -0
  19. package/template/client/index.html +12 -0
  20. package/template/client/package.json +26 -0
  21. package/template/client/src/app.tsx +94 -0
  22. package/template/client/src/lib/kernel.ts +135 -0
  23. package/template/client/src/lib/shell.ts +197 -0
  24. package/template/client/src/lib/use-node.ts +66 -0
  25. package/template/client/src/lib/use-shell.ts +85 -0
  26. package/template/client/src/main.tsx +9 -0
  27. package/template/client/src/styles.css +107 -0
  28. package/template/client/tsconfig.json +25 -0
  29. package/template/client/vite.config.ts +40 -0
  30. package/template/client/vitest.config.ts +18 -0
  31. package/template/env.ts +18 -0
  32. package/template/functions/index.ts +9 -0
  33. package/template/methods/index.ts +66 -0
  34. package/template/methods/note.ts +131 -0
  35. package/template/package.json +30 -0
  36. package/template/pnpm-workspace.yaml +17 -0
  37. package/template/schema/compiled.ts +14 -0
  38. package/template/schema/index.ts +21 -0
  39. package/template/schema/note.ts +64 -0
  40. package/template/tsconfig.json +17 -0
  41. package/template/views/index.ts +10 -0
  42. package/template/views/note.ts +21 -0
  43. package/template/views/welcome.ts +35 -0
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Worker runtime env. The adapter injects `WORKER_URL` and the bindings; your
3
+ * handlers receive this as `ctx.deps`. Secrets from `.env.<env>` arrive as
4
+ * extra string fields here. Add typed fields as you reference new secrets/vars.
5
+ */
6
+ export interface Env {
7
+ /** The worker's canonical serving URL (its `iss` identity), injected by the
8
+ * adapter for routed deploys. Optional — dev / workers.dev resolve it from the
9
+ * per-request Host. */
10
+ WORKER_URL?: string
11
+ /** Workers Assets binding (serves the client SPA under /ui/*), when present. */
12
+ ASSETS?: { fetch(request: Request): Promise<Response> }
13
+ /** Self service binding (autobinding — a handler calling its own domain). */
14
+ SELF?: { fetch(request: Request): Promise<Response> }
15
+ /** Dev-only: forward /ui/* to a running Vite dev server. */
16
+ VIEW_DEV_URL?: string
17
+ [key: string]: unknown
18
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Function registry — one file per standalone Function under `functions/`,
3
+ * listed here. Each becomes a Function node at `/<origin>/core/functions/<key>`
4
+ * whose `binding.remoteUrl` the SDK stamps with the worker's public URL at
5
+ * install. (Note: the `postInstall` hook must be a CLASS-hosted static method —
6
+ * standalone Functions live at tree paths, which the kernel's origin guard
7
+ * refuses for postInstall.)
8
+ */
9
+ export const functions = {}
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Worker method wiring — the `methods` the codegen'd worker entry mounts. Thin
3
+ * adapters around the shared logic in `./note`: the only thing that lives here
4
+ * is the SDK typing and the `kernel` null-guard.
5
+ *
6
+ * - `createNote` is interface-hosted (static) → `remoteInterfaceMethods`.
7
+ * - `reference` is class-hosted (instance) → `remoteMethod` +
8
+ * `remoteClassMethods`.
9
+ * - `seed` is class-hosted (static) — the `postInstall` bootstrap.
10
+ *
11
+ * SDK-level `authorize` is an additive throw-to-deny check (returns void). For
12
+ * finer worker checks see `assertPerm` / `requireOwnership` from
13
+ * `@astrale-os/sdk`.
14
+ */
15
+ import {
16
+ remoteClassMethods,
17
+ remoteInterfaceMethods,
18
+ remoteMethod,
19
+ type SchemaMethodsImpl,
20
+ } from '@astrale-os/sdk'
21
+
22
+ import type { Env } from '../env'
23
+
24
+ import { schema } from '../schema'
25
+ import {
26
+ createNote as createNoteLogic,
27
+ reference as referenceLogic,
28
+ seed as seedLogic,
29
+ } from './note'
30
+
31
+ const method = remoteMethod<Env>()
32
+ const interfaceMethods = remoteInterfaceMethods<Env>()
33
+ const classMethods = remoteClassMethods<Env>()
34
+
35
+ const NoteOpsMethods = interfaceMethods(schema, 'NoteOps', {
36
+ createNote: {
37
+ authorize: async () => undefined,
38
+ execute: ({ kernel, params }) => {
39
+ if (!kernel) throw new Error('createNote requires a kernel credential')
40
+ return createNoteLogic(kernel, params)
41
+ },
42
+ },
43
+ })
44
+
45
+ const reference = method(schema, 'Note', 'reference', {
46
+ authorize: async () => undefined,
47
+ execute: ({ kernel, self, params }) => {
48
+ if (!kernel) throw new Error('reference requires a kernel credential')
49
+ return referenceLogic(kernel, self.path.raw, params)
50
+ },
51
+ })
52
+
53
+ const seed = method(schema, 'Note', 'seed', {
54
+ authorize: async () => undefined,
55
+ execute: ({ kernel }) => {
56
+ if (!kernel) throw new Error('seed requires a kernel credential')
57
+ return seedLogic(kernel)
58
+ },
59
+ })
60
+
61
+ const NoteMethods = classMethods(schema, 'Note', { reference, seed })
62
+
63
+ export const methods: SchemaMethodsImpl<typeof schema, Env> = {
64
+ interface: { NoteOps: NoteOpsMethods },
65
+ class: { Note: NoteMethods },
66
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Note context — method LOGIC, written once, transport-agnostic.
3
+ *
4
+ * Each handler touches the kernel ONLY through `kernel.call(...)` (the universal
5
+ * syscalls) plus its `params` and, for instance methods, the source node's
6
+ * `path.raw`. Address callables/edges with layout-independent forms — a
7
+ * `ClassPath` for the edge class, the `<id>::link` instance form, and an
8
+ * `@<id>` id-form target. Reserve an absolute path string for a node *location*.
9
+ */
10
+ import { K } from '@astrale-os/kernel-core'
11
+
12
+ import { D } from '../schema/compiled'
13
+
14
+ /** The minimal kernel surface the worker runtime exposes to a handler. */
15
+ export type CallableKernel = { call(path: string, params: unknown): Promise<unknown> }
16
+
17
+ const NODE_CREATE = K.Node.createNode.path.method.raw
18
+ const NAME_KEY = K.Named.name.key
19
+ const NOTE_CLASS = D.Note.path.class.raw
20
+ const REFERENCES_EDGE = D.references.path.class.raw
21
+ const TITLE_KEY = D.Note.title.key
22
+ const BODY_KEY = D.Note.body.key
23
+
24
+ /** Notes live under a `/notes` folder at the graph root (created by `seed`). */
25
+ const NOTES_PARENT = '/notes'
26
+
27
+ /** URL-safe slug + short random suffix to dodge same-ms collisions. */
28
+ function slugify(text: string): string {
29
+ const stem =
30
+ text
31
+ .toLowerCase()
32
+ .normalize('NFD')
33
+ .replace(/[̀-ͯ]/g, '')
34
+ .replace(/[^a-z0-9]+/g, '-')
35
+ .replace(/^-|-$/g, '')
36
+ .slice(0, 40) || 'note'
37
+ return `${stem}-${Date.now().toString(36).slice(-4)}${Math.random().toString(36).slice(2, 6)}`
38
+ }
39
+
40
+ /** `NoteOps.createNote` (static) — create a Note under `/notes`. */
41
+ export async function createNote(
42
+ kernel: CallableKernel,
43
+ params: { title: string; body: string },
44
+ ): Promise<{ id: string; path: string }> {
45
+ const path = `${NOTES_PARENT}/${slugify(params.title)}`
46
+ const created = (await kernel.call(NODE_CREATE, {
47
+ class: NOTE_CLASS,
48
+ path,
49
+ props: { [NAME_KEY]: params.title, [TITLE_KEY]: params.title, [BODY_KEY]: params.body },
50
+ })) as { id: string }
51
+ return { id: created.id, path }
52
+ }
53
+
54
+ /** `Note.reference` (instance) — link this Note to another via `references`. */
55
+ export async function reference(
56
+ kernel: CallableKernel,
57
+ selfPathRaw: string,
58
+ params: { target: string },
59
+ ): Promise<{ linked: string }> {
60
+ await kernel.call(`${selfPathRaw}::link`, {
61
+ edgeClass: REFERENCES_EDGE,
62
+ target: params.target,
63
+ })
64
+ return { linked: params.target }
65
+ }
66
+
67
+ const FOLDER_CLASS = K.Folder.path.class.raw
68
+
69
+ const STARTERS: ReadonlyArray<{ slug: string; title: string; body: string }> = [
70
+ {
71
+ slug: 'welcome',
72
+ title: 'Welcome',
73
+ body: 'This note was created by `seed` after install. Edit it freely.',
74
+ },
75
+ { slug: 'getting-started', title: 'Getting started', body: 'Call `createNote` to add your own.' },
76
+ ]
77
+
78
+ function isPathConflict(e: unknown): boolean {
79
+ return e instanceof Error && e.message.includes('PATH_CONFLICT')
80
+ }
81
+
82
+ /**
83
+ * `Note.seed` (static) — the domain's post-install bootstrap. The kernel calls
84
+ * it ONCE after install, as __SYSTEM__ (see `postInstall` in
85
+ * `astrale.config.ts`), so the domain can lay down its initial state and
86
+ * grants: the `/notes` folder, a couple of starter Notes, and a `references`
87
+ * edge between them. Idempotent: a re-run swallows `PATH_CONFLICT` per node.
88
+ */
89
+ export async function seed(kernel: CallableKernel): Promise<{ seeded: number }> {
90
+ // 1. The `/notes` folder at the graph root.
91
+ try {
92
+ await kernel.call(NODE_CREATE, {
93
+ class: FOLDER_CLASS,
94
+ path: NOTES_PARENT,
95
+ props: { [NAME_KEY]: 'notes' },
96
+ })
97
+ } catch (e) {
98
+ if (!isPathConflict(e)) throw e
99
+ }
100
+
101
+ // 2. A couple of starter Notes under it.
102
+ const ids: Record<string, string> = {}
103
+ let seeded = 0
104
+ for (const s of STARTERS) {
105
+ try {
106
+ const created = (await kernel.call(NODE_CREATE, {
107
+ class: NOTE_CLASS,
108
+ path: `${NOTES_PARENT}/${s.slug}`,
109
+ props: { [NAME_KEY]: s.title, [TITLE_KEY]: s.title, [BODY_KEY]: s.body },
110
+ })) as { id: string }
111
+ ids[s.slug] = created.id
112
+ seeded++
113
+ } catch (e) {
114
+ if (!isPathConflict(e)) throw e
115
+ }
116
+ }
117
+
118
+ // 3. Link welcome → getting-started with a `references` edge (id-form on
119
+ // both sides — layout-independent). Best-effort.
120
+ const from = ids.welcome
121
+ const to = ids['getting-started']
122
+ if (from && to) {
123
+ try {
124
+ await kernel.call(`@${from}::link`, { edgeClass: REFERENCES_EDGE, target: `@${to}` })
125
+ } catch (e) {
126
+ if (!isPathConflict(e)) throw e
127
+ }
128
+ }
129
+
130
+ return { seeded }
131
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "astrale-domain",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "astrale-domain dev",
8
+ "prod": "astrale-domain prod",
9
+ "deploy": "astrale-domain deploy",
10
+ "build": "astrale-domain build",
11
+ "typecheck": "tsgo --noEmit"
12
+ },
13
+ "dependencies": {
14
+ "@astrale-os/adapter-cloudflare": ">=0.1.0 <1.0.0",
15
+ "@astrale-os/devkit": ">=0.1.0 <1.0.0",
16
+ "@astrale-os/kernel-core": ">=0.3.0 <1.0.0",
17
+ "@astrale-os/kernel-dsl": ">=0.1.0 <1.0.0",
18
+ "@astrale-os/sdk": ">=0.1.0 <1.0.0",
19
+ "zod": "^4.3.6"
20
+ },
21
+ "devDependencies": {
22
+ "@types/node": "^22.0.0",
23
+ "@typescript/native-preview": "latest",
24
+ "typescript": "~6.0.1-rc",
25
+ "wrangler": "^4.0.0"
26
+ },
27
+ "engines": {
28
+ "node": ">=22"
29
+ }
30
+ }
@@ -0,0 +1,17 @@
1
+ # The client SPA (served under /ui/*) is a workspace package, so a single
2
+ # `pnpm install` at the project root installs both the domain and the client.
3
+ packages:
4
+ - 'client'
5
+
6
+ # pnpm blocks dependency build scripts by default; these need their postinstall
7
+ # to run (workerd is wrangler's runtime — without it `pnpm dev`/`pnpm prod`
8
+ # fail). Both spellings are set because pnpm renamed the key: v10 reads
9
+ # `onlyBuiltDependencies`, v11+ reads `allowBuilds`; each ignores the other.
10
+ onlyBuiltDependencies:
11
+ - esbuild
12
+ - sharp
13
+ - workerd
14
+ allowBuilds:
15
+ esbuild: true
16
+ sharp: true
17
+ workerd: true
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Compiled view of the schema — resolved class/interface paths + prop keys.
3
+ * `compileDomain` is pure (no env), so this is safe to import from the worker
4
+ * and from build tooling alike.
5
+ *
6
+ * - `D.Note.path.class.raw` → the Note ClassPath, graph-facing form
7
+ * - `D.references.path.class.raw` → the references edge ClassPath
8
+ * - `D.Note.title.key` → the qualified prop key for `title`
9
+ */
10
+ import { compileDomain, type Domain } from '@astrale-os/kernel-core/domain'
11
+
12
+ import { schema } from './index'
13
+
14
+ export const D: Domain<typeof schema> = compileDomain(schema)
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Schema assembly — the one place the domain's contexts come together, and the
3
+ * single source of the domain's stable identity. `schema.domain` (the first arg
4
+ * to `defineSchema`) IS the origin: `astrale.config.ts` defaults `origin` to it,
5
+ * and the worker signs JWTs under it. The scaffolder set this string from your
6
+ * slug; change it here to rename the domain's identity.
7
+ *
8
+ * Add a context: author `schema/<context>.ts`, then import its members and list
9
+ * them in the `interfaces` / `classes` maps below.
10
+ */
11
+ import { defineSchema, KernelSchema } from '@astrale-os/kernel-core'
12
+
13
+ import { Note, NoteOps, references } from './note'
14
+
15
+ export const schema = defineSchema('astrale-domain.example.dev', {
16
+ interfaces: { NoteOps },
17
+ classes: { Note, references },
18
+ imports: [KernelSchema],
19
+ })
20
+
21
+ export * from './note'
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Note context — the domain's single bounded slice.
3
+ *
4
+ * One file per context: a class (or a few tightly-related classes) plus the
5
+ * edges that bind them. Here that is the `NoteOps` interface, the `Note`
6
+ * class that implements it, and the `references` edge from one Note to
7
+ * another. To grow the domain, add `schema/<context>.ts` and register its
8
+ * members in `schema/index.ts`.
9
+ *
10
+ * - Interface `NoteOps` one static op, `createNote`. Static → the impl
11
+ * gets no `self`; it creates a brand-new Note.
12
+ * - Class `Note` implements `[NoteOps, Container]`, inheriting
13
+ * `createNote` and adding the instance method
14
+ * `reference` (links this Note to another).
15
+ * - Edge `references` Note → Note. Materialized at runtime by
16
+ * `reference` (and by `seed`).
17
+ */
18
+ import { edgeClass, KernelSchema, nodeClass, nodeInterface } from '@astrale-os/kernel-core'
19
+ import { fn } from '@astrale-os/kernel-dsl'
20
+ import { z } from 'zod'
21
+
22
+ /**
23
+ * Thin ref to a created node — what node-creating ops return. A remote method
24
+ * returns a plain `{ id, path }`, never `ref(SELF)` (whose full-Node value
25
+ * does not round-trip over the worker wire).
26
+ */
27
+ export const NoteRef = z.object({ id: z.string(), path: z.string() })
28
+
29
+ export const NoteOps = nodeInterface({
30
+ methods: {
31
+ createNote: fn({
32
+ static: true,
33
+ params: { title: z.string(), body: z.string() },
34
+ returns: NoteRef,
35
+ }),
36
+ },
37
+ })
38
+
39
+ export const Note = nodeClass({
40
+ implements: [NoteOps, KernelSchema.interfaces.Container],
41
+ props: {
42
+ title: z.string(),
43
+ body: z.string(),
44
+ },
45
+ methods: {
46
+ reference: fn({
47
+ params: { target: z.string() },
48
+ returns: z.object({ linked: z.string() }),
49
+ }),
50
+ // Post-install bootstrap (wired as `postInstall` in astrale.config.ts).
51
+ // Static: the kernel calls it ONCE after install, as __SYSTEM__, with no
52
+ // `self`. Must stay idempotent — a re-install runs it again.
53
+ seed: fn({
54
+ static: true,
55
+ returns: z.object({ seeded: z.number().int() }),
56
+ }),
57
+ },
58
+ })
59
+
60
+ export const references = edgeClass(
61
+ { as: 'from_note', types: [Note] },
62
+ { as: 'to_note', types: [Note] },
63
+ { props: { reason: z.string().optional() } },
64
+ )
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
7
+ "types": ["node"],
8
+ "strict": true,
9
+ "noEmit": true,
10
+ "skipLibCheck": true,
11
+ "verbatimModuleSyntax": true,
12
+ "esModuleInterop": true,
13
+ "resolveJsonModule": true
14
+ },
15
+ "include": ["schema", "methods", "views", "functions", "env.ts", "astrale.config.ts"],
16
+ "exclude": ["node_modules", ".astrale", "dist-client"]
17
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * View registry — one file per view under `views/`, assembled here. Slug = map
3
+ * key; each becomes a View node at `/<origin>/core/views/<slug>` whose iframe
4
+ * binding the SDK stamps with the worker's live serving URL when it builds the
5
+ * install bundle.
6
+ */
7
+ import { note } from './note'
8
+ import { welcome } from './welcome'
9
+
10
+ export const views = { welcome, 'ui-note': note }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * `ui-note` — a rich View backed by the `client/` React + Vite SPA (instead of
3
+ * an inline-HTML `render` like `welcome`). Because it declares `mount` rather
4
+ * than `render`, the View node's iframe binding points at `<serving url>/ui/note`
5
+ * — the SDK stamps it from the worker's live URL when it builds the install
6
+ * bundle. The Cloudflare adapter serves `/ui/*` from `../dist-client` (built by
7
+ * `client/` with base `/ui/`) via the Worker's `ASSETS` binding.
8
+ *
9
+ * `viewFor: selfOf(Note)` attaches a `view_for` edge to the `Note` class
10
+ * meta-node, so the GUI offers this view for any Note instance.
11
+ */
12
+ import { selfOf } from '@astrale-os/kernel-dsl'
13
+ import { defineView } from '@astrale-os/sdk'
14
+
15
+ import { Note } from '../schema/note'
16
+
17
+ export const note = defineView({
18
+ auth: 'public',
19
+ mount: '/ui/note',
20
+ viewFor: selfOf(Note),
21
+ })
@@ -0,0 +1,35 @@
1
+ /**
2
+ * `welcome` — a lightweight inline-HTML View, rendered straight from the worker
3
+ * (no SPA, no client bundle, no shell handshake). Because it has a `render`, the
4
+ * SDK mounts a worker route and the View node's iframe points at
5
+ * `<publicUrl>/views/welcome`. Add a rich, shell-connected view by dropping a
6
+ * `client/` SPA in and binding a View to `/ui/<route>` instead.
7
+ */
8
+ import { defineView } from '@astrale-os/sdk'
9
+
10
+ export const welcome = defineView({
11
+ auth: 'public',
12
+ render: ({ c }) =>
13
+ c.html(
14
+ `<!doctype html>
15
+ <html lang="en">
16
+ <head>
17
+ <meta charset="utf-8" />
18
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
19
+ <title>astrale domain</title>
20
+ <style>
21
+ body { font: 14px/1.6 system-ui, sans-serif; margin: 2rem; color: #111; max-width: 40rem; }
22
+ code { background: #f4f4f5; padding: 0.1rem 0.35rem; border-radius: 4px; }
23
+ h1 { font-size: 1.25rem; }
24
+ </style>
25
+ </head>
26
+ <body>
27
+ <h1>Your Astrale domain is live</h1>
28
+ <p>This inline-HTML view is rendered by the worker. The RPC surface and the
29
+ <code>seed</code> post-install hook run alongside it.</p>
30
+ <p>Edit <code>views/welcome.ts</code> to change this page — it hot-reloads at
31
+ the same URL.</p>
32
+ </body>
33
+ </html>`,
34
+ ),
35
+ })