@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
package/src/params.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `CloudflareParams` — the provider-typed config for the Cloudflare adapter.
|
|
3
|
+
*
|
|
4
|
+
* One type covers every env; per-env differences are just optional fields. The
|
|
5
|
+
* same `{ dev, prod, canary, … }` map a developer writes in `astrale.config.ts`
|
|
6
|
+
* is `Record<string, CloudflareParams>`.
|
|
7
|
+
*
|
|
8
|
+
* Three deployment shapes fall out of which fields are set:
|
|
9
|
+
* • dev (local) — `watch()` runs `wrangler dev`; URL = http://localhost:<port>
|
|
10
|
+
* • dev-remote — `deploy()` with no `route`; URL = https://<name>.<sub>.workers.dev
|
|
11
|
+
* • remote (custom) — `deploy()` with `route`; URL = https://<route>
|
|
12
|
+
*/
|
|
13
|
+
export interface CloudflareParams {
|
|
14
|
+
/**
|
|
15
|
+
* Custom route / hostname for a routed deploy, e.g. `'crm.acme.dev'`. Omit to
|
|
16
|
+
* deploy to `*.workers.dev`. Ignored by local `wrangler dev` (URL is
|
|
17
|
+
* localhost) but still used as the placeholder base domain there.
|
|
18
|
+
*/
|
|
19
|
+
route?: string
|
|
20
|
+
/** Gitignored secrets file (whole contents = secrets), e.g. `'.env.dev'`. */
|
|
21
|
+
secrets?: string
|
|
22
|
+
/** Local dev port for `wrangler dev`. Default 8787. */
|
|
23
|
+
port?: number
|
|
24
|
+
/** Override the Worker name. Default: derived from the domain origin. */
|
|
25
|
+
workerName?: string
|
|
26
|
+
/** Run `wrangler dev --remote` (edge isolate) instead of local workerd. */
|
|
27
|
+
remote?: boolean
|
|
28
|
+
/** Extra plain (non-secret) vars to inject as Worker vars. */
|
|
29
|
+
vars?: Record<string, string>
|
|
30
|
+
/**
|
|
31
|
+
* Escape hatch: raw wrangler config deep-merged over the generated base. Use
|
|
32
|
+
* it to declare extra bindings the adapter has no typed field for — KV, R2,
|
|
33
|
+
* D1, queues, service bindings, extra `compatibility_flags`, cron `triggers`,
|
|
34
|
+
* Hyperdrive, etc.
|
|
35
|
+
*
|
|
36
|
+
* wrangler: {
|
|
37
|
+
* kv_namespaces: [{ binding: 'CACHE', id: '<kv-id>' }],
|
|
38
|
+
* r2_buckets: [{ binding: 'FILES', bucket_name: 'my-bucket' }],
|
|
39
|
+
* }
|
|
40
|
+
*
|
|
41
|
+
* The merge is additive: arrays concat (so your `services` keep the adapter's
|
|
42
|
+
* `SELF` binding and your `compatibility_flags` keep `nodejs_compat`); new
|
|
43
|
+
* keys are added as-is. The adapter-owned keys `name`, `main`, `assets`,
|
|
44
|
+
* `routes` are rejected — use `workerName` and `route` instead. Avoid
|
|
45
|
+
* the reserved binding names `SELF` and `ASSETS`. Durable Objects need extra
|
|
46
|
+
* worker-entry codegen and aren't supported through this field yet.
|
|
47
|
+
*/
|
|
48
|
+
wrangler?: Record<string, unknown>
|
|
49
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure parsers for `wrangler` stdout. Kept separate from the process-spawning in
|
|
3
|
+
* `wrangler-cli.ts` so the brittle bit — scraping wrangler's human output — is
|
|
4
|
+
* unit-testable against captured fixtures without shelling out.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const READY_RE = /(?:Ready on|Listening on|⎔.*?at)\s+(https?:\/\/[^\s]+)/i
|
|
8
|
+
const WORKERS_DEV_RE = /(https:\/\/[^\s]+\.workers\.dev)/i
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Local dev URL from `wrangler dev` output, normalized to an origin on the given
|
|
12
|
+
* port. Returns `undefined` when the output hasn't reported readiness yet.
|
|
13
|
+
*
|
|
14
|
+
* The fallback (when no explicit "Ready on" line) only accepts a localhost URL
|
|
15
|
+
* on the EXPECTED port — wrangler may print unrelated localhost URLs (e.g. an
|
|
16
|
+
* inspector/devtools endpoint on a different port) before readiness, and taking
|
|
17
|
+
* one of those would point `astrale instance install <url>` at a dead endpoint.
|
|
18
|
+
*/
|
|
19
|
+
export function parseDevReadyUrl(text: string, port: number): string | undefined {
|
|
20
|
+
const ready = READY_RE.exec(text)
|
|
21
|
+
if (ready) return normalizeLocal(ready[1]!, port)
|
|
22
|
+
// `port` is a number, so it's safe to interpolate into the pattern. The
|
|
23
|
+
// negative lookahead stops `:8787` from matching inside `:87870`.
|
|
24
|
+
const localhostOnPort = new RegExp(
|
|
25
|
+
`(https?://(?:localhost|127\\.0\\.0\\.1):${port})(?![0-9])`,
|
|
26
|
+
'i',
|
|
27
|
+
)
|
|
28
|
+
const m = localhostOnPort.exec(text)
|
|
29
|
+
return m ? normalizeLocal(m[1]!, port) : undefined
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Authoritative public URL from `wrangler deploy` output. A custom-domain
|
|
34
|
+
* `route` wins (the deploy attaches it); otherwise the printed `*.workers.dev`
|
|
35
|
+
* URL. Returns `undefined` when neither is available (caller surfaces the raw
|
|
36
|
+
* output).
|
|
37
|
+
*/
|
|
38
|
+
export function parseDeployUrl(text: string, route?: string): string | undefined {
|
|
39
|
+
if (route) return `https://${route.replace(/\/.*$/, '')}`
|
|
40
|
+
const m = WORKERS_DEV_RE.exec(text)
|
|
41
|
+
return m ? m[1] : undefined
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Normalize a local dev URL to an origin: `0.0.0.0` → `localhost`, fill the port. */
|
|
45
|
+
export function normalizeLocal(url: string, port: number): string {
|
|
46
|
+
try {
|
|
47
|
+
const u = new URL(url)
|
|
48
|
+
if (u.hostname === '0.0.0.0') u.hostname = 'localhost'
|
|
49
|
+
if (!u.port) u.port = String(port)
|
|
50
|
+
return u.origin
|
|
51
|
+
} catch {
|
|
52
|
+
return `http://localhost:${port}`
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin wrapper around the `wrangler` binary — the only place the adapter shells
|
|
3
|
+
* out. `watch` runs `wrangler dev` (local workerd) and resolves once the worker
|
|
4
|
+
* reports its local URL; `deploy` runs `wrangler deploy` and parses the
|
|
5
|
+
* authoritative public URL from the output; secrets go through `wrangler secret
|
|
6
|
+
* bulk`. The wrangler binary is resolved from the project's `node_modules/.bin`,
|
|
7
|
+
* falling back to `npx wrangler`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ChildProcess } from 'node:child_process'
|
|
11
|
+
|
|
12
|
+
import { spawn } from 'node:child_process'
|
|
13
|
+
import { existsSync } from 'node:fs'
|
|
14
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
15
|
+
import { tmpdir } from 'node:os'
|
|
16
|
+
import { join } from 'node:path'
|
|
17
|
+
|
|
18
|
+
import { parseDeployUrl, parseDevReadyUrl } from './parse-output'
|
|
19
|
+
|
|
20
|
+
function wranglerCommand(projectDir: string): { cmd: string; prefix: string[] } {
|
|
21
|
+
const local = join(projectDir, 'node_modules', '.bin', 'wrangler')
|
|
22
|
+
if (existsSync(local)) return { cmd: local, prefix: [] }
|
|
23
|
+
return { cmd: 'npx', prefix: ['--yes', 'wrangler'] }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface DevHandle {
|
|
27
|
+
url: string
|
|
28
|
+
stop(): Promise<void>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function runWranglerDev(args: {
|
|
32
|
+
projectDir: string
|
|
33
|
+
configPath: string
|
|
34
|
+
port: number
|
|
35
|
+
remote: boolean
|
|
36
|
+
onReload: () => void
|
|
37
|
+
onLog?: (line: string) => void
|
|
38
|
+
}): Promise<DevHandle> {
|
|
39
|
+
const { cmd, prefix } = wranglerCommand(args.projectDir)
|
|
40
|
+
const wranglerArgs = [
|
|
41
|
+
...prefix,
|
|
42
|
+
'dev',
|
|
43
|
+
'--config',
|
|
44
|
+
args.configPath,
|
|
45
|
+
'--port',
|
|
46
|
+
String(args.port),
|
|
47
|
+
'--local-protocol',
|
|
48
|
+
'http',
|
|
49
|
+
'--ip',
|
|
50
|
+
'127.0.0.1',
|
|
51
|
+
...(args.remote ? ['--remote'] : []),
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
const child = spawn(cmd, wranglerArgs, {
|
|
55
|
+
cwd: args.projectDir,
|
|
56
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
57
|
+
env: { ...process.env, WRANGLER_SEND_METRICS: 'false' },
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const fallbackUrl = `http://localhost:${args.port}`
|
|
61
|
+
let resolved = false
|
|
62
|
+
|
|
63
|
+
const url = await new Promise<string>((resolve, reject) => {
|
|
64
|
+
const onData = (buf: Buffer) => {
|
|
65
|
+
const text = buf.toString()
|
|
66
|
+
for (const line of text.split('\n')) {
|
|
67
|
+
if (line.trim()) args.onLog?.(line)
|
|
68
|
+
}
|
|
69
|
+
if (!resolved) {
|
|
70
|
+
const ready = parseDevReadyUrl(text, args.port)
|
|
71
|
+
if (ready) {
|
|
72
|
+
resolved = true
|
|
73
|
+
resolve(ready)
|
|
74
|
+
}
|
|
75
|
+
} else if (/Reloading|Detected changes|Updated/i.test(text)) {
|
|
76
|
+
args.onReload()
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
child.stdout?.on('data', onData)
|
|
80
|
+
child.stderr?.on('data', onData)
|
|
81
|
+
child.on('exit', (code) => {
|
|
82
|
+
if (!resolved) {
|
|
83
|
+
resolved = true
|
|
84
|
+
reject(new Error(`wrangler dev exited before becoming ready (code ${code ?? 'null'}).`))
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
child.on('error', (err) => {
|
|
88
|
+
if (!resolved) {
|
|
89
|
+
resolved = true
|
|
90
|
+
reject(err)
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
// Safety net: if we never matched a URL but the process is alive, assume the
|
|
94
|
+
// conventional localhost URL after a grace period.
|
|
95
|
+
setTimeout(() => {
|
|
96
|
+
if (!resolved && child.exitCode === null) {
|
|
97
|
+
resolved = true
|
|
98
|
+
resolve(fallbackUrl)
|
|
99
|
+
}
|
|
100
|
+
}, 15_000).unref?.()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
return { url, stop: () => stopChild(child) }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface DeployArgs {
|
|
107
|
+
configPath: string
|
|
108
|
+
/** SELF-less config for the first-deploy two-pass (see `deployWithSelfFallback`). */
|
|
109
|
+
fallbackConfigPath?: string
|
|
110
|
+
/** The worker's own name, to scope the self-binding-missing detection. */
|
|
111
|
+
workerName?: string
|
|
112
|
+
route?: string
|
|
113
|
+
onLog?: (line: string) => void
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function runWranglerDeploy(
|
|
117
|
+
args: DeployArgs & { projectDir: string },
|
|
118
|
+
): Promise<{ url: string; raw: string }> {
|
|
119
|
+
const { cmd, prefix } = wranglerCommand(args.projectDir)
|
|
120
|
+
const deploy = (configPath: string) =>
|
|
121
|
+
runCapture(cmd, [...prefix, 'deploy', '--config', configPath], args.projectDir, args.onLog)
|
|
122
|
+
return deployWithSelfFallback(deploy, args)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Deploy with a one-time SELF-binding first-deploy recovery. A fresh worker that
|
|
127
|
+
* declares a `services: SELF` binding can't deploy (its own script doesn't exist
|
|
128
|
+
* yet); on THAT specific failure we deploy the SELF-less fallback to create the
|
|
129
|
+
* script, then redeploy WITH SELF so autobinding works. Steady state (every
|
|
130
|
+
* deploy after the first) is a single pass. The `deploy` runner is injected so
|
|
131
|
+
* the orchestration is unit-testable without shelling out.
|
|
132
|
+
*/
|
|
133
|
+
export async function deployWithSelfFallback(
|
|
134
|
+
deploy: (configPath: string) => Promise<{ code: number; out: string }>,
|
|
135
|
+
args: DeployArgs,
|
|
136
|
+
): Promise<{ url: string; raw: string }> {
|
|
137
|
+
let { code, out } = await deploy(args.configPath)
|
|
138
|
+
|
|
139
|
+
if (code !== 0 && args.fallbackConfigPath && isSelfBindingMissing(out, args.workerName)) {
|
|
140
|
+
args.onLog?.(
|
|
141
|
+
'SELF service binding not resolvable yet (first deploy) — creating the worker, then re-binding SELF',
|
|
142
|
+
)
|
|
143
|
+
const first = await deploy(args.fallbackConfigPath)
|
|
144
|
+
if (first.code !== 0) {
|
|
145
|
+
throw new Error(
|
|
146
|
+
`wrangler deploy (no-self pass) failed (code ${first.code}):\n${first.out.slice(-2000)}`,
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
;({ code, out } = await deploy(args.configPath))
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (code !== 0) {
|
|
153
|
+
throw new Error(`wrangler deploy failed (code ${code}):\n${out.slice(-2000)}`)
|
|
154
|
+
}
|
|
155
|
+
const url = parseDeployUrl(out, args.route)
|
|
156
|
+
if (!url) {
|
|
157
|
+
throw new Error(`could not determine deployed URL from wrangler output:\n${out.slice(-2000)}`)
|
|
158
|
+
}
|
|
159
|
+
return { url, raw: out }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* True when wrangler failed specifically because the worker's OWN `SELF` service
|
|
164
|
+
* binding can't resolve yet (first deploy of a fresh script). Requires the
|
|
165
|
+
* worker name so an unrelated missing-service error doesn't trigger the two-pass.
|
|
166
|
+
*/
|
|
167
|
+
export function isSelfBindingMissing(out: string, workerName?: string): boolean {
|
|
168
|
+
const bindingError =
|
|
169
|
+
/could not resolve binding/i.test(out) ||
|
|
170
|
+
/service\b[^\n]*\bnot found/i.test(out) ||
|
|
171
|
+
/script\b[^\n]*\bnot found/i.test(out)
|
|
172
|
+
if (!bindingError || !/\bSELF\b/.test(out)) return false
|
|
173
|
+
return workerName ? out.includes(workerName) : true
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function bulkPutSecrets(args: {
|
|
177
|
+
projectDir: string
|
|
178
|
+
configPath: string
|
|
179
|
+
secrets: Record<string, string>
|
|
180
|
+
onLog?: (line: string) => void
|
|
181
|
+
}): Promise<void> {
|
|
182
|
+
const names = Object.keys(args.secrets)
|
|
183
|
+
if (names.length === 0) return
|
|
184
|
+
const { cmd, prefix } = wranglerCommand(args.projectDir)
|
|
185
|
+
const dir = await mkdtemp(join(tmpdir(), 'astrale-secrets-'))
|
|
186
|
+
const file = join(dir, 'secrets.json')
|
|
187
|
+
try {
|
|
188
|
+
await writeFile(file, JSON.stringify(args.secrets))
|
|
189
|
+
const { code, out } = await runCapture(
|
|
190
|
+
cmd,
|
|
191
|
+
[...prefix, 'secret', 'bulk', file, '--config', args.configPath],
|
|
192
|
+
args.projectDir,
|
|
193
|
+
args.onLog,
|
|
194
|
+
)
|
|
195
|
+
if (code !== 0) {
|
|
196
|
+
throw new Error(`wrangler secret bulk failed (code ${code}):\n${out.slice(-1500)}`)
|
|
197
|
+
}
|
|
198
|
+
} finally {
|
|
199
|
+
await rm(dir, { recursive: true, force: true })
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── internals ──────────────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
function runCapture(
|
|
206
|
+
cmd: string,
|
|
207
|
+
args: string[],
|
|
208
|
+
cwd: string,
|
|
209
|
+
onLog?: (line: string) => void,
|
|
210
|
+
): Promise<{ code: number; out: string }> {
|
|
211
|
+
return new Promise((resolve, reject) => {
|
|
212
|
+
const child = spawn(cmd, args, {
|
|
213
|
+
cwd,
|
|
214
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
215
|
+
env: { ...process.env, WRANGLER_SEND_METRICS: 'false' },
|
|
216
|
+
})
|
|
217
|
+
let out = ''
|
|
218
|
+
const onData = (buf: Buffer) => {
|
|
219
|
+
const text = buf.toString()
|
|
220
|
+
out += text
|
|
221
|
+
for (const line of text.split('\n')) if (line.trim()) onLog?.(line)
|
|
222
|
+
}
|
|
223
|
+
child.stdout?.on('data', onData)
|
|
224
|
+
child.stderr?.on('data', onData)
|
|
225
|
+
child.on('exit', (code) => resolve({ code: code ?? 1, out }))
|
|
226
|
+
child.on('error', reject)
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function stopChild(child: ChildProcess): Promise<void> {
|
|
231
|
+
if (child.exitCode !== null) return
|
|
232
|
+
await new Promise<void>((resolve) => {
|
|
233
|
+
child.on('exit', () => resolve())
|
|
234
|
+
child.kill('SIGTERM')
|
|
235
|
+
setTimeout(() => {
|
|
236
|
+
if (child.exitCode === null) child.kill('SIGKILL')
|
|
237
|
+
resolve()
|
|
238
|
+
}, 4000).unref?.()
|
|
239
|
+
})
|
|
240
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Secrets for an env live in `.env.<env>` (e.g. .env.dev, .env.prod), pointed at
|
|
2
|
+
# by `secrets:` in astrale.config.ts. The ENTIRE file is treated as secrets:
|
|
3
|
+
# injected into the local runtime in dev, pushed to the Worker secret store in
|
|
4
|
+
# prod. These files are gitignored — copy this to `.env.dev` and fill in.
|
|
5
|
+
#
|
|
6
|
+
# EXAMPLE_API_KEY=sk-...
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# astrale-domain
|
|
2
|
+
|
|
3
|
+
A standalone [Astrale](https://astrale.ai) domain, deployed as a Cloudflare Worker.
|
|
4
|
+
|
|
5
|
+
> **Requires [Bun](https://bun.sh)** — the `astrale-domain` CLI behind `pnpm dev`
|
|
6
|
+
> / `pnpm prod` imports `astrale.config.ts` and your ★ files directly, so it
|
|
7
|
+
> needs a TypeScript-native runtime.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pnpm install
|
|
11
|
+
pnpm dev # wrangler dev → prints a local URL
|
|
12
|
+
astrale instance install <url> # mount it on an instance (CLI)
|
|
13
|
+
pnpm prod # deploy prod → prints a URL
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## What you write (★)
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
schema/ ★ classes + interfaces (the data model)
|
|
20
|
+
methods/ ★ the execute() handlers
|
|
21
|
+
views/ ★ iframe-mountable UIs
|
|
22
|
+
functions/ ★ standalone Functions (e.g. the post-install seed)
|
|
23
|
+
astrale.config.ts identity (origin) · requires · postInstall · adapter
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Everything else — the Worker entry, the wrangler config, the signing identity —
|
|
27
|
+
is generated under `.astrale/` (gitignored) by the adapter. You never edit it.
|
|
28
|
+
|
|
29
|
+
## The signing identity (back it up!)
|
|
30
|
+
|
|
31
|
+
The first `pnpm dev`/`pnpm prod` generates an Ed25519 keypair at
|
|
32
|
+
`.astrale/identity.ts`. That private key **is** this domain's identity: every
|
|
33
|
+
instance that installs the domain verifies its credentials against it. The file
|
|
34
|
+
is gitignored, so a fresh clone or a CI runner silently mints a **new** key —
|
|
35
|
+
and redeploying with a new key breaks verification on every existing install.
|
|
36
|
+
Back it up, or move it to a managed secret before deploying from more than one
|
|
37
|
+
machine.
|
|
38
|
+
|
|
39
|
+
## Extra bindings (KV, R2, D1, queues, …)
|
|
40
|
+
|
|
41
|
+
The adapter owns the wrangler config, but you can declare extra bindings from
|
|
42
|
+
`astrale.config.ts` via a `wrangler` block on any env — it's deep-merged into
|
|
43
|
+
the generated config:
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
adapter: cloudflare({
|
|
47
|
+
dev: {
|
|
48
|
+
secrets: '.env.dev',
|
|
49
|
+
wrangler: {
|
|
50
|
+
kv_namespaces: [{ binding: 'CACHE', id: '<kv-id>' }],
|
|
51
|
+
r2_buckets: [{ binding: 'FILES', bucket_name: 'my-bucket' }],
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
})
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The merge is additive — arrays concat, so your `services` keep the adapter's
|
|
58
|
+
`SELF` binding and your `compatibility_flags` keep `nodejs_compat`. The
|
|
59
|
+
adapter-owned keys `name`, `main`, `assets`, `routes` are rejected (use
|
|
60
|
+
`workerName` and `route`/`zone`); don't reuse the reserved binding names
|
|
61
|
+
`SELF`/`ASSETS`. Durable Objects aren't supported through this field yet.
|
|
62
|
+
|
|
63
|
+
## The loop
|
|
64
|
+
|
|
65
|
+
- **Edit a handler** (`methods/`) → hot-reloads at the same URL. Nothing to reinstall.
|
|
66
|
+
- **Edit the schema** (`schema/`) → rebuilds the graph; reinstall with `astrale instance install <url>`.
|
|
67
|
+
- **`postInstall`** (the static `Note.seed` method) runs once after install, as
|
|
68
|
+
the system identity, so the domain can seed itself and set its own grants. It
|
|
69
|
+
must be a class-hosted static addressed by a typed colon-path
|
|
70
|
+
(`/:origin:class.X:seed`) — the kernel refuses tree paths here.
|
|
71
|
+
|
|
72
|
+
## Identity
|
|
73
|
+
|
|
74
|
+
`origin` (in `astrale.config.ts`, defaulting to `schema.domain`) is the domain's
|
|
75
|
+
stable name — the same in every env; only the URL changes. The deployed Worker
|
|
76
|
+
computes its own `binding.remoteUrl` at install from its public URL, so there's
|
|
77
|
+
no hardcoded URL anywhere in your code.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* astrale.config.ts — the single source of truth for this domain's identity and
|
|
3
|
+
* deployment. `origin` defaults to `schema.domain`; `postInstall` points at the
|
|
4
|
+
* static `Note.seed` method the kernel runs after install (a typed colon-path —
|
|
5
|
+
* the kernel refuses tree paths here, since they can't prove their origin);
|
|
6
|
+
* `adapter` chooses the target.
|
|
7
|
+
*
|
|
8
|
+
* pnpm dev # wrangler dev → prints a local URL
|
|
9
|
+
* astrale instance install <url> # mount it on an instance
|
|
10
|
+
* pnpm prod # deploy prod → prints a URL
|
|
11
|
+
*/
|
|
12
|
+
import { cloudflare } from '@astrale-os/adapter-cloudflare'
|
|
13
|
+
import { defineDomain } from '@astrale-os/devkit'
|
|
14
|
+
|
|
15
|
+
import { schema } from './schema'
|
|
16
|
+
|
|
17
|
+
export default defineDomain({
|
|
18
|
+
schema,
|
|
19
|
+
// origin defaults to `schema.domain`. Set it to another domain's origin to
|
|
20
|
+
// deliberately alias that identity (you'll get a DANGER prompt at install).
|
|
21
|
+
postInstall: `/:${schema.domain}:class.Note:seed`,
|
|
22
|
+
adapter: cloudflare({
|
|
23
|
+
// Local dev: `wrangler dev`. No route → URL is http://localhost:8787.
|
|
24
|
+
dev: { secrets: '.env.dev' },
|
|
25
|
+
// Custom-domain prod. Drop `route` to ship to *.workers.dev instead.
|
|
26
|
+
prod: { route: 'astrale-domain.example.dev', secrets: '.env.prod' },
|
|
27
|
+
// Need extra bindings (KV, R2, D1, queues, …)? Add a `wrangler` block to any
|
|
28
|
+
// env — it's deep-merged into the generated config (arrays concat, so the
|
|
29
|
+
// adapter's `SELF` binding and `nodejs_compat` are preserved):
|
|
30
|
+
// dev: {
|
|
31
|
+
// secrets: '.env.dev',
|
|
32
|
+
// wrangler: { kv_namespaces: [{ binding: 'CACHE', id: '<kv-id>' }] },
|
|
33
|
+
// },
|
|
34
|
+
}),
|
|
35
|
+
})
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# astrale-domain-client — SPA for domain views
|
|
2
|
+
|
|
3
|
+
A small React + Vite SPA that renders the domain's `ui-note` View. It is loaded
|
|
4
|
+
inside an iframe mounted by the Astrale shell, runs the shell handshake to learn
|
|
5
|
+
its target node id and a delegation token, fetches that Note from the kernel,
|
|
6
|
+
and renders its title/body. Built into `../dist-client/` and served by the
|
|
7
|
+
generated worker (`.astrale/`) under `/ui/*` via its `ASSETS` binding.
|
|
8
|
+
|
|
9
|
+
## Self-contained on purpose
|
|
10
|
+
|
|
11
|
+
This client depends only on `react`, `react-dom`, `vite`, and
|
|
12
|
+
`@vitejs/plugin-react` — all on public npm. It does **not** import
|
|
13
|
+
`@astrale-os/shell`, `@astrale-os/kernel-client`, or `@astrale-os/ui-components`,
|
|
14
|
+
so the template builds with no registry auth and no workspace `link:`s. Two
|
|
15
|
+
small subsets are reimplemented inline:
|
|
16
|
+
|
|
17
|
+
- the shell child-handshake (`src/lib/shell.ts`), and
|
|
18
|
+
- a minimal JSON kernel client (`src/lib/kernel.ts`).
|
|
19
|
+
|
|
20
|
+
## Layout
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
src/
|
|
24
|
+
main.tsx # entry → createRoot(App)
|
|
25
|
+
app.tsx # the ui-note view (renders the loaded Note)
|
|
26
|
+
lib/
|
|
27
|
+
shell.ts # inline shell child handshake (postMessage + MessagePort)
|
|
28
|
+
use-shell.ts # React hook over the handshake (status + live session)
|
|
29
|
+
kernel.ts # inline kernel JSON client (kernelCall + prop readers)
|
|
30
|
+
use-node.ts # React hook: fetch a node via @<id>::get
|
|
31
|
+
styles.css # self-contained styles (no Tailwind)
|
|
32
|
+
__tests__/
|
|
33
|
+
harness.ts # fake shell parent + fake kernel (fetch stub)
|
|
34
|
+
app.test.tsx # handshake → render → setTarget → tokenRefresh
|
|
35
|
+
kernel.test.ts # kernelCall wire shape + prop readers
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Dev loops
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pnpm dev # vite build --watch → ../dist-client/ (worker auto-reloads, no HMR)
|
|
42
|
+
pnpm dev:hmr # vite dev on http://127.0.0.1:5173/ (React fast-refresh)
|
|
43
|
+
pnpm test # vitest run (happy-dom; fake parent + fake kernel, JSON-only)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
For HMR, set `VIEW_DEV_URL=http://127.0.0.1:5173` in the worker's `.dev.vars`;
|
|
47
|
+
the generated worker forwards `/ui/*` to vite.
|
|
48
|
+
|
|
49
|
+
## How it connects
|
|
50
|
+
|
|
51
|
+
1. The domain declares the View in `../views/note.ts` with `mount: '/ui/note'`.
|
|
52
|
+
The SDK points the View node's iframe at `<serving url>/ui/note`.
|
|
53
|
+
2. The shell mounts that iframe and completes the handshake: it transfers a
|
|
54
|
+
`MessagePort` and sends a `ctrl:handshake` carrying `kernelUrl`, a
|
|
55
|
+
`delegationToken` (+ `tokenExpiresAt`), and the `targetNodeId`. `useShell()`
|
|
56
|
+
surfaces a live session and tracks `setTarget` hot-swaps.
|
|
57
|
+
3. `useNode(session, nodeId)` POSTs `@<id>::get` to `kernelUrl` with the
|
|
58
|
+
delegation token as `authorization` and renders the Note's `title`/`body`
|
|
59
|
+
(props are read by key suffix, since the domain origin is unknown at build
|
|
60
|
+
time). The iframe authenticates with the handshake token ONLY — the parent
|
|
61
|
+
minted it for this kernel, so the audience already matches (no cookie, no
|
|
62
|
+
whoami, no client-side minting).
|
|
63
|
+
|
|
64
|
+
### Token refresh
|
|
65
|
+
|
|
66
|
+
The parent pushes a fresh credential over the port as `ctrl:tokenRefresh`
|
|
67
|
+
before the current one expires. `shell.ts` swaps it into a mutable `currentToken`
|
|
68
|
+
so the next `kernelCall` (e.g. on the next `setTarget` or remount) uses it —
|
|
69
|
+
`getToken()` always returns the live token, never the one captured at handshake.
|
|
70
|
+
|
|
71
|
+
## Deferred — kept minimal on purpose
|
|
72
|
+
|
|
73
|
+
The inline kernel client is **JSON-only** and read-only for this template:
|
|
74
|
+
|
|
75
|
+
- no msgpack codec (the `application/vnd.astrale.kernel+msgpack` body),
|
|
76
|
+
- no streaming (`output: 'stream'`) or binary (`output: 'binary'`) responses,
|
|
77
|
+
- no redirect following — a `{ redirect }` envelope (remote-domain Functions)
|
|
78
|
+
throws rather than re-minting against the target worker,
|
|
79
|
+
- no client-side credential minting / `whoami` (the handshake token is used
|
|
80
|
+
as-is), and
|
|
81
|
+
- no writes — only `@<id>::get`.
|
|
82
|
+
|
|
83
|
+
These all live in `@astrale-os/kernel-client` / `@astrale-os/shell`. To grow
|
|
84
|
+
past the template, pull those in (and `link:` them in dev) instead of extending
|
|
85
|
+
the inline subset.
|