@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
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@astrale-os/adapter-cloudflare",
3
+ "version": "0.1.0",
4
+ "description": "Deploy an Astrale domain as a standalone Cloudflare Worker",
5
+ "keywords": [
6
+ "adapter",
7
+ "astrale",
8
+ "cloudflare",
9
+ "domain",
10
+ "workers"
11
+ ],
12
+ "license": "MIT",
13
+ "author": "Astrale",
14
+ "files": [
15
+ "dist",
16
+ "src",
17
+ "template"
18
+ ],
19
+ "type": "module",
20
+ "main": "./src/index.ts",
21
+ "types": "./src/index.ts",
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "import": "./dist/index.js"
26
+ },
27
+ "./package.json": "./package.json"
28
+ },
29
+ "dependencies": {
30
+ "jose": "^6.1.3",
31
+ "@astrale-os/devkit": "0.1.0"
32
+ },
33
+ "devDependencies": {
34
+ "@astrale-os/ox": ">=0.1.0 <1.0.0",
35
+ "@types/node": "^22.0.0",
36
+ "@typescript/native-preview": "latest",
37
+ "oxfmt": "latest",
38
+ "oxlint": "latest",
39
+ "typescript": "~6.0.1-rc",
40
+ "vitest": "^3.0.0"
41
+ },
42
+ "engines": {
43
+ "node": ">=22"
44
+ },
45
+ "scripts": {
46
+ "typecheck": "tsgo --noEmit",
47
+ "test": "vitest run",
48
+ "lint": "oxlint .",
49
+ "format": "pnpm exec oxfmt --write .",
50
+ "format:check": "pnpm exec oxfmt --check .",
51
+ "build": "rm -rf dist && tsgo"
52
+ }
53
+ }
package/src/client.ts ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Build the optional client SPA (the Views' UI). Best-effort: if the project
3
+ * has no `client/` or no resolvable Vite, we skip — the RPC surface and inline
4
+ * Views still work without a built SPA.
5
+ */
6
+
7
+ import { spawn } from 'node:child_process'
8
+ import { existsSync } from 'node:fs'
9
+ import { join } from 'node:path'
10
+
11
+ export async function buildClient(
12
+ clientDir: string,
13
+ projectDir: string,
14
+ onLog?: (line: string) => void,
15
+ ): Promise<void> {
16
+ const viteBin = [
17
+ join(clientDir, 'node_modules', '.bin', 'vite'),
18
+ join(projectDir, 'node_modules', '.bin', 'vite'),
19
+ ].find((p) => existsSync(p))
20
+ if (!viteBin) {
21
+ // The project has a client/ but its deps aren't installed — wrangler would
22
+ // otherwise fail cryptically on the missing dist-client assets dir. Fail
23
+ // loud with the fix (the client is a workspace package — one install covers it).
24
+ throw new Error(
25
+ `client build: vite not found in ${clientDir}. Run \`pnpm install\` at the project root ` +
26
+ `first (the client/ SPA is a workspace package).`,
27
+ )
28
+ }
29
+ const { code, out } = await runCapture(viteBin, ['build'], clientDir, onLog)
30
+ if (code !== 0) {
31
+ throw new Error(`client build failed (code ${code}):\n${out.slice(-1500)}`)
32
+ }
33
+ }
34
+
35
+ function runCapture(
36
+ cmd: string,
37
+ args: string[],
38
+ cwd: string,
39
+ onLog?: (line: string) => void,
40
+ ): Promise<{ code: number; out: string }> {
41
+ return new Promise((resolve, reject) => {
42
+ const child = spawn(cmd, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] })
43
+ let out = ''
44
+ const onData = (buf: Buffer) => {
45
+ const text = buf.toString()
46
+ out += text
47
+ for (const line of text.split('\n')) if (line.trim()) onLog?.(line)
48
+ }
49
+ child.stdout?.on('data', onData)
50
+ child.stderr?.on('data', onData)
51
+ child.on('exit', (code) => resolve({ code: code ?? 1, out }))
52
+ child.on('error', reject)
53
+ })
54
+ }
@@ -0,0 +1,228 @@
1
+ /**
2
+ * `cloudflare(envs)` — the Cloudflare deployment adapter.
3
+ *
4
+ * Owns everything target-specific: it codegens the Worker entry + wrangler
5
+ * config (the dev never sees a `wrangler.jsonc`), builds the optional client
6
+ * SPA, and shells out to `wrangler`. `watch` → `wrangler dev` (local URL);
7
+ * `deploy` → `wrangler deploy` (workers.dev or a custom-domain URL); secrets →
8
+ * `wrangler secret bulk`. Both `watch` and `deploy` return the URL the devkit
9
+ * prints and `astrale instance install <url>` consumes.
10
+ */
11
+
12
+ import type { DeployCtx, DomainAdapter, WatchCtx } from '@astrale-os/devkit'
13
+
14
+ import { defineAdapter, hasStarModule } from '@astrale-os/devkit'
15
+ import { mkdir, writeFile } from 'node:fs/promises'
16
+ import { join } from 'node:path'
17
+
18
+ import type { CloudflareParams } from './params'
19
+
20
+ import { buildClient } from './client'
21
+ import { ensureIdentity } from './codegen/identity'
22
+ import { generateWorkerEntry } from './codegen/worker'
23
+ import { generateWranglerConfig } from './codegen/wrangler'
24
+ import { bulkPutSecrets, runWranglerDeploy, runWranglerDev } from './wrangler-cli'
25
+
26
+ const DEFAULT_PORT = 8787
27
+
28
+ export function cloudflare(
29
+ envs: Record<string, CloudflareParams>,
30
+ ): DomainAdapter<CloudflareParams> {
31
+ return defineAdapter<CloudflareParams>({
32
+ name: 'cloudflare',
33
+ envs,
34
+
35
+ async watch(params, ctx) {
36
+ const { configPath, port } = await prepare(params, ctx, 'dev')
37
+ if (ctx.clientDir) await buildClient(ctx.clientDir, ctx.projectDir, logTo())
38
+ const handle = await runWranglerDev({
39
+ projectDir: ctx.projectDir,
40
+ configPath,
41
+ port,
42
+ remote: Boolean(params.remote),
43
+ onReload: ctx.onReload,
44
+ onLog: logTo(),
45
+ })
46
+ return { url: handle.url, stop: handle.stop }
47
+ },
48
+
49
+ async deploy(params, ctx) {
50
+ const {
51
+ configPath,
52
+ fallbackConfigPath,
53
+ workerName: name,
54
+ } = await prepare(params, ctx, 'deploy')
55
+ if (ctx.clientDir) await buildClient(ctx.clientDir, ctx.projectDir, logTo())
56
+ const { url } = await runWranglerDeploy({
57
+ projectDir: ctx.projectDir,
58
+ configPath,
59
+ workerName: name,
60
+ ...(fallbackConfigPath ? { fallbackConfigPath } : {}),
61
+ ...(params.route ? { route: params.route } : {}),
62
+ onLog: logTo(),
63
+ })
64
+ if (Object.keys(ctx.secrets).length > 0) {
65
+ await bulkPutSecrets({
66
+ projectDir: ctx.projectDir,
67
+ configPath,
68
+ secrets: ctx.secrets,
69
+ onLog: logTo(),
70
+ })
71
+ }
72
+ // A first deploy on a fresh `*.workers.dev` host can take ~30-60s to
73
+ // propagate; an `astrale instance install <url>` issued right away would
74
+ // 404 (Cloudflare 1042). Block until the URL actually serves so the
75
+ // install line we print is immediately actionable.
76
+ await waitUntilLive(url, logTo())
77
+ return { url }
78
+ },
79
+
80
+ secretsFile(params) {
81
+ return params.secrets
82
+ },
83
+ })
84
+ }
85
+
86
+ // ── codegen orchestration ──────────────────────────────────────────────────
87
+
88
+ async function prepare(
89
+ params: CloudflareParams,
90
+ ctx: WatchCtx | DeployCtx,
91
+ mode: 'dev' | 'deploy',
92
+ ): Promise<{ configPath: string; fallbackConfigPath?: string; port: number; workerName: string }> {
93
+ const astraleDir = join(ctx.projectDir, '.astrale')
94
+ await mkdir(astraleDir, { recursive: true })
95
+ await ensureIdentity(astraleDir, ctx.domain.origin)
96
+
97
+ const name = workerName(params, ctx.domain.origin)
98
+ // Shared ★-files probe: must agree with the codegen's `import { views } from
99
+ // '../views'` resolution (folder index OR sibling file) — a folder-only
100
+ // existsSync would deploy a worker whose graph silently lacks a sibling-file
101
+ // module the diagnostic spec includes.
102
+ const hasViews = hasStarModule(ctx.projectDir, 'views')
103
+ const hasFunctions = hasStarModule(ctx.projectDir, 'functions')
104
+ const hasClient = Boolean(ctx.clientDir)
105
+
106
+ await writeFile(
107
+ join(astraleDir, 'worker.gen.ts'),
108
+ generateWorkerEntry({
109
+ origin: ctx.domain.origin,
110
+ ...(ctx.domain.postInstall ? { postInstall: ctx.domain.postInstall } : {}),
111
+ requires: ctx.domain.requires,
112
+ hasViews,
113
+ hasFunctions,
114
+ hasClient,
115
+ }),
116
+ )
117
+
118
+ // Pin the worker's canonical serving URL (its `iss` identity) for routed
119
+ // deploys: a routed worker may ALSO be reachable via `*.workers.dev`, so
120
+ // resolving the URL from the per-request Host would let `iss` drift between
121
+ // hostnames. Dev + workers.dev-only deploys are single-host, so the worker
122
+ // falls back to the per-request Host (always the canonical URL there).
123
+ // An explicit `vars.WORKER_URL` (e.g. a tunnel/proxy front) wins.
124
+ const workerUrl = mode === 'deploy' && params.route ? `https://${params.route}` : undefined
125
+
126
+ // Dev injects secrets as local vars (the gitignored .astrale config never
127
+ // ships); deploy keeps secrets out of the config and pushes them encrypted.
128
+ const vars = {
129
+ ...(workerUrl ? { WORKER_URL: workerUrl } : {}),
130
+ ...(mode === 'dev' ? { ...params.vars, ...ctx.secrets } : { ...params.vars }),
131
+ }
132
+
133
+ const baseConfig = {
134
+ workerName: name,
135
+ ...(mode === 'deploy' && params.route ? { route: params.route } : {}),
136
+ hasClient,
137
+ vars,
138
+ // Escape hatch: extra bindings (KV/R2/D1/queues/…) deep-merged on top. Same
139
+ // overlay in dev and deploy — it's infra, not env-specific plumbing — and it
140
+ // flows into both the SELF and the SELF-less fallback config below.
141
+ ...(params.wrangler ? { wrangler: params.wrangler } : {}),
142
+ }
143
+
144
+ // Always self-bind: it enables autobinding (a handler calling its own domain).
145
+ // Locally the dev registry resolves it; on deploy the first deploy of a fresh
146
+ // worker can't (the script doesn't exist yet), so we also emit a SELF-less
147
+ // fallback config that `runWranglerDeploy` uses for a one-time two-pass deploy.
148
+ const configPath = join(astraleDir, 'wrangler.gen.jsonc')
149
+ await writeFile(configPath, generateWranglerConfig({ ...baseConfig, selfBinding: true }))
150
+
151
+ let fallbackConfigPath: string | undefined
152
+ if (mode === 'deploy') {
153
+ fallbackConfigPath = join(astraleDir, 'wrangler.no-self.gen.jsonc')
154
+ await writeFile(
155
+ fallbackConfigPath,
156
+ generateWranglerConfig({ ...baseConfig, selfBinding: false }),
157
+ )
158
+ }
159
+
160
+ return {
161
+ configPath,
162
+ ...(fallbackConfigPath ? { fallbackConfigPath } : {}),
163
+ port: params.port ?? DEFAULT_PORT,
164
+ workerName: name,
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Poll the deployed URL until the worker answers (anything but the Cloudflare
170
+ * 1042 "no script on this host" 404), or give up after `timeoutMs`. Resolves
171
+ * silently on the fast path; logs only when propagation actually makes us wait.
172
+ */
173
+ async function waitUntilLive(
174
+ url: string,
175
+ onLog: (line: string) => void,
176
+ timeoutMs = 90_000,
177
+ ): Promise<void> {
178
+ const probe = new URL('/health', url)
179
+ const deadline = Date.now() + timeoutMs
180
+ let waited = false
181
+ for (;;) {
182
+ try {
183
+ const res = await fetch(probe, { redirect: 'manual' })
184
+ if (res.status !== 404) return
185
+ } catch {
186
+ // Network hiccup — treat like "not live yet" and keep polling.
187
+ }
188
+ if (Date.now() >= deadline) {
189
+ onLog(`URL not reachable yet after ${Math.round(timeoutMs / 1000)}s — it may need a moment.`)
190
+ return
191
+ }
192
+ if (!waited) {
193
+ waited = true
194
+ onLog('waiting for the URL to go live (first deploys can take ~30-60s)…')
195
+ }
196
+ await new Promise((r) => setTimeout(r, 3000))
197
+ }
198
+ }
199
+
200
+ const WORKER_NAME_RE = /^[a-z0-9][a-z0-9-]*$/
201
+
202
+ export function workerName(params: CloudflareParams, origin: string): string {
203
+ // An explicit name is the deployed worker's identity (and the SELF service
204
+ // target) — validate and reject an invalid one rather than silently rewriting
205
+ // it, so the dev fixes it instead of deploying under a surprising name.
206
+ if (params.workerName !== undefined) {
207
+ if (!WORKER_NAME_RE.test(params.workerName) || params.workerName.length > 63) {
208
+ throw new Error(
209
+ `cloudflare adapter: invalid \`workerName\` "${params.workerName}". A Cloudflare worker ` +
210
+ 'name must be lowercase, start with a letter or digit, contain only letters, digits and ' +
211
+ 'dashes, and be at most 63 chars.',
212
+ )
213
+ }
214
+ return params.workerName
215
+ }
216
+ // Derive from the origin: lowercase, collapse non-alphanumerics to a dash, then
217
+ // SLICE before trimming dashes so a cut landing mid-dash can't leave a trailing
218
+ // "-" (which wrangler rejects).
219
+ return origin
220
+ .toLowerCase()
221
+ .replace(/[^a-z0-9]+/g, '-')
222
+ .slice(0, 54)
223
+ .replace(/^-+|-+$/g, '')
224
+ }
225
+
226
+ function logTo(): (line: string) => void {
227
+ return (line: string) => process.stderr.write(`\x1b[2m ${line}\x1b[0m\n`)
228
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Ensure the domain has a stable Ed25519 signing identity at
3
+ * `.astrale/identity.ts`. Generated ONCE (first `dev`/`deploy` or by the
4
+ * scaffolder) and never regenerated — the private key IS the domain's identity,
5
+ * so rotating it would orphan every instance that installed the old key. The
6
+ * file is gitignored; for real production this key should graduate to a managed
7
+ * secret.
8
+ */
9
+
10
+ import { exportJWK, generateKeyPair } from 'jose'
11
+ import { existsSync } from 'node:fs'
12
+ import { mkdir, writeFile } from 'node:fs/promises'
13
+ import { join } from 'node:path'
14
+
15
+ export async function ensureIdentity(astraleDir: string, origin: string): Promise<void> {
16
+ const file = join(astraleDir, 'identity.ts')
17
+ if (existsSync(file)) return
18
+
19
+ const { privateKey } = await generateKeyPair('EdDSA', { extractable: true })
20
+ const jwk = (await exportJWK(privateKey)) as Record<string, unknown>
21
+ jwk.alg = 'EdDSA'
22
+ jwk.kid = `${origin}-key`
23
+
24
+ await mkdir(astraleDir, { recursive: true })
25
+ await writeFile(
26
+ file,
27
+ `// AUTO-GENERATED stable signing identity for "${origin}" — do not edit, do not commit.\n` +
28
+ `export const PRIVATE_JWK = ${JSON.stringify(jwk, null, 2)} as const\n`,
29
+ )
30
+
31
+ // Loud on purpose: the file is gitignored, so a fresh clone / CI runner
32
+ // silently mints a NEW key — and a redeploy with a new key breaks JWKS
33
+ // verification on every instance that installed the old one, with no hint
34
+ // why. Make the lifecycle visible the one time it happens.
35
+ process.stderr.write(
36
+ `\x1b[33m!\x1b[0m new signing identity generated at .astrale/identity.ts (kid ${String(jwk.kid)}).\n` +
37
+ ` This key IS "${origin}"'s identity: back it up (or move it to a managed secret) —\n` +
38
+ ` deploying from another machine without it re-keys the domain and breaks existing installs.\n`,
39
+ )
40
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Deep-merge for the generated wrangler config. `base` is what the adapter
3
+ * generates; `overlay` is the dev's `params.wrangler` escape hatch. The merge
4
+ * is additive so a dev can declare extra bindings without losing the adapter's
5
+ * invariants:
6
+ * - plain objects → recurse
7
+ * - arrays → concat, de-duplicating BOTH primitive members and
8
+ * object bindings by an identity key (`binding` /
9
+ * `pattern` / `queue` / `name`, else structural value),
10
+ * with the overlay entry overriding a base entry of the
11
+ * same identity. So a dev overlay that re-declares the
12
+ * adapter's `SELF` service (or any binding) replaces it
13
+ * instead of emitting a duplicate wrangler rejects.
14
+ * - scalars / new keys → overlay wins
15
+ *
16
+ * Protecting the load-bearing keys (`main`, `assets`, …) is policy enforced by
17
+ * the caller (`generateWranglerConfig`), not here — this stays a generic merge.
18
+ */
19
+ export function deepMergeConfig(
20
+ base: Record<string, unknown>,
21
+ overlay: Record<string, unknown>,
22
+ ): Record<string, unknown> {
23
+ const out: Record<string, unknown> = { ...base }
24
+ for (const [key, value] of Object.entries(overlay)) {
25
+ const prev = out[key]
26
+ if (isPlainObject(prev) && isPlainObject(value)) {
27
+ out[key] = deepMergeConfig(prev, value)
28
+ } else if (Array.isArray(prev) && Array.isArray(value)) {
29
+ out[key] = mergeArrays(prev, value)
30
+ } else {
31
+ out[key] = value
32
+ }
33
+ }
34
+ return out
35
+ }
36
+
37
+ /**
38
+ * Concat two arrays, de-duplicating by identity. Primitives dedup by value;
39
+ * objects dedup by an identity key (`binding`/`pattern`/`queue`/`name`, else
40
+ * their structural JSON) with the later (overlay) entry overriding an earlier
41
+ * (base) one at the base entry's position. This stops a re-declared object
42
+ * binding (e.g. a second `SELF` service) from producing a duplicate that
43
+ * wrangler rejects, while still appending genuinely distinct bindings.
44
+ */
45
+ function mergeArrays(a: unknown[], b: unknown[]): unknown[] {
46
+ const out: unknown[] = []
47
+ const seenPrimitives = new Set<unknown>()
48
+ const indexByKey = new Map<string, number>()
49
+ for (const item of [...a, ...b]) {
50
+ if (isPrimitive(item)) {
51
+ if (seenPrimitives.has(item)) continue
52
+ seenPrimitives.add(item)
53
+ out.push(item)
54
+ continue
55
+ }
56
+ const key = identityKey(item)
57
+ const existing = indexByKey.get(key)
58
+ if (existing !== undefined) {
59
+ out[existing] = item // overlay (later) overrides the base entry in place
60
+ continue
61
+ }
62
+ indexByKey.set(key, out.length)
63
+ out.push(item)
64
+ }
65
+ return out
66
+ }
67
+
68
+ /**
69
+ * Stable identity for an array's object member: the first recognized wrangler
70
+ * binding field (`binding` for services/KV/R2/D1/queue producers, `pattern` for
71
+ * routes, `queue` for consumers, `name` as a generic fallback), else the member's
72
+ * full structural JSON so identical objects dedup but distinct ones all survive.
73
+ */
74
+ function identityKey(item: unknown): string {
75
+ const rec = item as Record<string, unknown>
76
+ for (const field of ['binding', 'pattern', 'queue', 'name']) {
77
+ if (typeof rec[field] === 'string') return `${field}:${rec[field] as string}`
78
+ }
79
+ try {
80
+ return `json:${JSON.stringify(item)}`
81
+ } catch {
82
+ return `ref:${String(item)}`
83
+ }
84
+ }
85
+
86
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
87
+ return typeof v === 'object' && v !== null && !Array.isArray(v)
88
+ }
89
+
90
+ function isPrimitive(v: unknown): boolean {
91
+ return v === null || (typeof v !== 'object' && typeof v !== 'function')
92
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Codegen for the Cloudflare Worker entry (`.astrale/worker.gen.ts`).
3
+ *
4
+ * The dev writes only schema/methods/views/functions; this generates the
5
+ * `export default { fetch }` plumbing the adapter owns. Key properties:
6
+ *
7
+ * • Per-request `url` = the live serving origin (`scheme://host`). It is passed
8
+ * only to `createRemoteServer({ url })`; the SDK stamps every
9
+ * `binding.remoteUrl` from that single value at spec-build time — so deployed
10
+ * Function nodes point back at THIS worker with zero hardcoded URLs.
11
+ * • Self-issued JWKS: a Worker can't fetch its own hostname; the verifier
12
+ * resolves a credential whose `iss` is this worker from the in-memory key,
13
+ * so no self-fetch is attempted (the worker's JWKS is served as a normal
14
+ * route by createRemoteServer).
15
+ * • Self-binding (autobinding): when a handler calls back into its OWN domain
16
+ * (`ctx.callRemote`), the redirect resolves to this worker's own host — a
17
+ * forbidden same-host subrequest. We route those through the `SELF` service
18
+ * binding instead.
19
+ * • SPA: `/ui/*` is served from the `ASSETS` binding (or proxied to Vite in
20
+ * dev via `VIEW_DEV_URL`).
21
+ */
22
+
23
+ export interface WorkerCodegenOptions {
24
+ origin: string
25
+ postInstall?: string
26
+ requires: readonly string[]
27
+ hasViews: boolean
28
+ hasFunctions: boolean
29
+ hasClient: boolean
30
+ }
31
+
32
+ export function generateWorkerEntry(opts: WorkerCodegenOptions): string {
33
+ const optionalImports = [
34
+ opts.hasViews ? `import { views } from '../views'` : `const views = undefined`,
35
+ opts.hasFunctions ? `import { functions } from '../functions'` : `const functions = undefined`,
36
+ ].join('\n')
37
+
38
+ const postInstallLine =
39
+ opts.postInstall !== undefined
40
+ ? `const POST_INSTALL = ${JSON.stringify(opts.postInstall)}`
41
+ : `const POST_INSTALL = undefined`
42
+
43
+ return `// AUTO-GENERATED by @astrale-os/adapter-cloudflare — do not edit.
44
+ // Regenerated on every \`astrale-domain dev|deploy\`. Edit schema/methods/views
45
+ // instead. (origin: ${opts.origin})
46
+ import { defineRemoteDomain } from '@astrale-os/sdk'
47
+ import { createWorkerEntry } from '@astrale-os/sdk/server'
48
+
49
+ import { schema } from '../schema'
50
+ import { methods } from '../methods'
51
+ ${optionalImports}
52
+ import { PRIVATE_JWK } from './identity'
53
+
54
+ const REQUIRES = ${JSON.stringify(opts.requires)}
55
+ ${postInstallLine}
56
+
57
+ interface Env {
58
+ WORKER_URL?: string
59
+ ASSETS?: { fetch(request: Request): Promise<Response> }
60
+ SELF?: { fetch(request: Request): Promise<Response> }
61
+ VIEW_DEV_URL?: string
62
+ [key: string]: unknown
63
+ }
64
+
65
+ // All the shared worker plumbing — JWKS self-fetch shim, SELF-binding routing,
66
+ // per-URL app cache, canonical iss resolution — lives in createWorkerEntry.
67
+ export default createWorkerEntry<Env>({
68
+ // The worker's serving URL = its identity (iss). Prefer the adapter-injected
69
+ // WORKER_URL (pins one canonical host for routed deploys, so iss stays stable
70
+ // even when also reachable via *.workers.dev); fall back to the per-request
71
+ // host for dev / workers.dev-only (single-host → it IS the canonical URL).
72
+ resolveUrl: (env, requestOrigin) => env.WORKER_URL ?? requestOrigin,
73
+ selfBinding: (env) => env.SELF,
74
+ build: (url, env) => {
75
+ const domain = defineRemoteDomain()({
76
+ schema,
77
+ methods,
78
+ ...(views ? { views } : {}),
79
+ ...(functions ? { remoteFunctions: functions } : {}),
80
+ })
81
+ return {
82
+ domain,
83
+ deps: env,
84
+ url,
85
+ privateKey: PRIVATE_JWK,
86
+ ...(POST_INSTALL ? { postInstall: POST_INSTALL } : {}),
87
+ ...(REQUIRES.length ? { requires: REQUIRES } : {}),
88
+ }
89
+ },
90
+ // SPA under /ui/* (views with a client renderer).
91
+ before: (env, url, request) => {
92
+ if (env.ASSETS && (url.pathname === '/ui' || url.pathname.startsWith('/ui/'))) {
93
+ if (env.VIEW_DEV_URL) {
94
+ const devBase = env.VIEW_DEV_URL.replace(/\\/$/, '')
95
+ return fetch(new Request(\`\${devBase}\${url.pathname}\${url.search}\`, request))
96
+ }
97
+ const stripped = url.pathname.replace(/^\\/ui\\/?/, '/')
98
+ const rewritten = new URL(stripped + url.search, url.origin)
99
+ return env.ASSETS.fetch(new Request(rewritten, request))
100
+ }
101
+ return undefined
102
+ },
103
+ })
104
+ `
105
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Codegen for the Worker config (`.astrale/wrangler.gen.jsonc`). This is the
3
+ * `wrangler.jsonc` that used to be hand-copied into every domain — now an
4
+ * internal adapter detail the dev never sees.
5
+ *
6
+ * `main` is `./worker.gen.ts` (same dir). `assets.directory` is `../dist-client`
7
+ * (the project's Vite build). A `SELF` service binding enables autobinding
8
+ * (a handler calling back into its own domain). Custom-domain deploys attach a
9
+ * `custom_domain` route; otherwise the Worker ships to `*.workers.dev`.
10
+ *
11
+ * A dev's `params.wrangler` escape hatch is deep-merged on top of this base
12
+ * (extra bindings: KV, R2, D1, queues, …) — see `mergeUserConfig`.
13
+ */
14
+
15
+ import { deepMergeConfig } from './merge'
16
+
17
+ export interface WranglerCodegenOptions {
18
+ workerName: string
19
+ /** Full custom hostname for a routed deploy (e.g. 'crm.acme.dev'). */
20
+ route?: string
21
+ /** Whether the project has a built client SPA to serve under /ui/*. */
22
+ hasClient: boolean
23
+ /** Plain (non-secret) vars to bake in. */
24
+ vars?: Record<string, string>
25
+ /** Add the SELF service binding (autobinding). */
26
+ selfBinding: boolean
27
+ /**
28
+ * Raw wrangler config to deep-merge over the generated base — the escape
29
+ * hatch for extra bindings (KV, R2, D1, queues, service bindings, extra
30
+ * `compatibility_flags`, cron `triggers`, …). Arrays concat; new keys are
31
+ * added as-is. The adapter-owned keys `name`, `main`, `assets`, `routes` are
32
+ * rejected (throws) — use `workerName` / `route` instead.
33
+ */
34
+ wrangler?: Record<string, unknown>
35
+ }
36
+
37
+ const COMPATIBILITY_DATE = '2025-03-01'
38
+
39
+ export function generateWranglerConfig(opts: WranglerCodegenOptions): string {
40
+ const config: Record<string, unknown> = {
41
+ name: opts.workerName,
42
+ main: './worker.gen.ts',
43
+ compatibility_date: COMPATIBILITY_DATE,
44
+ compatibility_flags: ['nodejs_compat', 'nodejs_compat_populate_process_env'],
45
+ workers_dev: true,
46
+ observability: { enabled: true },
47
+ }
48
+
49
+ if (opts.route) {
50
+ config.routes = [{ pattern: opts.route, custom_domain: true }]
51
+ }
52
+
53
+ if (opts.hasClient) {
54
+ config.assets = {
55
+ directory: '../dist-client',
56
+ binding: 'ASSETS',
57
+ not_found_handling: 'single-page-application',
58
+ run_worker_first: true,
59
+ }
60
+ }
61
+
62
+ if (opts.selfBinding) {
63
+ config.services = [{ binding: 'SELF', service: opts.workerName }]
64
+ }
65
+
66
+ if (opts.vars && Object.keys(opts.vars).length > 0) {
67
+ config.vars = opts.vars
68
+ }
69
+
70
+ const merged = opts.wrangler ? mergeUserConfig(config, opts.wrangler) : config
71
+ return JSON.stringify(merged, null, 2) + '\n'
72
+ }
73
+
74
+ /** Keys the adapter owns and won't let `params.wrangler` clobber. */
75
+ const ADAPTER_OWNED_KEYS = ['name', 'main', 'assets', 'routes']
76
+
77
+ /**
78
+ * Deep-merge the dev's raw wrangler overlay over the generated config, after
79
+ * refusing any attempt to override an adapter-owned key. Throwing (rather than
80
+ * silently dropping) keeps a stray `main` from breaking the Worker unnoticed.
81
+ */
82
+ function mergeUserConfig(
83
+ base: Record<string, unknown>,
84
+ user: Record<string, unknown>,
85
+ ): Record<string, unknown> {
86
+ const collisions = ADAPTER_OWNED_KEYS.filter((k) => k in user)
87
+ if (collisions.length > 0) {
88
+ throw new Error(
89
+ `wrangler: cannot override adapter-managed key(s): ${collisions.join(', ')}. ` +
90
+ 'Use `workerName` for the worker name and `route` for routing; ' +
91
+ '`main` and `assets` are generated by the adapter.',
92
+ )
93
+ }
94
+ return deepMergeConfig(base, user)
95
+ }
package/src/index.ts ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * `@astrale-os/adapter-cloudflare` — deploy an Astrale domain as a standalone
3
+ * Cloudflare Worker.
4
+ *
5
+ * import { cloudflare } from '@astrale-os/adapter-cloudflare'
6
+ *
7
+ * adapter: cloudflare({
8
+ * dev: { secrets: '.env.dev' },
9
+ * prod: { route: 'crm.acme.dev', secrets: '.env.prod' },
10
+ * })
11
+ *
12
+ * `dev` runs `wrangler dev` locally; an env with no `route` ships to
13
+ * `*.workers.dev`; an env with a `route` ships to that custom domain.
14
+ */
15
+
16
+ export { cloudflare } from './cloudflare'
17
+ export type { CloudflareParams } from './params'