@volter/twin-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/LICENSE +202 -0
- package/README.md +223 -0
- package/dist/r2/src/endpoint.d.ts +14 -0
- package/dist/r2/src/endpoint.js +30 -0
- package/dist/r2/src/fetch.d.ts +7 -0
- package/dist/r2/src/fetch.js +230 -0
- package/dist/r2/src/generated/surface.gen.json +1 -0
- package/dist/r2/src/manifest.d.ts +22 -0
- package/dist/r2/src/manifest.js +69 -0
- package/dist/r2/src/semantics/buckets.d.ts +18 -0
- package/dist/r2/src/semantics/buckets.js +145 -0
- package/dist/r2/src/semantics/objects.d.ts +8 -0
- package/dist/r2/src/semantics/objects.js +252 -0
- package/dist/r2/src/wire.d.ts +54 -0
- package/dist/r2/src/wire.js +120 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +26 -0
- package/dist/src/cloudflare-budget.d.ts +22 -0
- package/dist/src/cloudflare-budget.js +40 -0
- package/dist/src/cloudflare-capabilities.d.ts +6 -0
- package/dist/src/cloudflare-capabilities.js +1161 -0
- package/dist/src/cloudflare-common.d.ts +59 -0
- package/dist/src/cloudflare-common.js +142 -0
- package/dist/src/cloudflare-conformance.d.ts +6 -0
- package/dist/src/cloudflare-conformance.js +251 -0
- package/dist/src/cloudflare-connector.d.ts +53 -0
- package/dist/src/cloudflare-connector.js +418 -0
- package/dist/src/cloudflare-openapi-operations.d.ts +10 -0
- package/dist/src/cloudflare-openapi-operations.js +3336 -0
- package/dist/src/cloudflare-provisioning.d.ts +5 -0
- package/dist/src/cloudflare-provisioning.js +2097 -0
- package/dist/src/cloudflare-r2.d.ts +121 -0
- package/dist/src/cloudflare-r2.js +138 -0
- package/dist/src/cloudflare-server.d.ts +17 -0
- package/dist/src/cloudflare-server.js +101 -0
- package/dist/src/cloudflare-twin.d.ts +14 -0
- package/dist/src/cloudflare-twin.js +762 -0
- package/dist/src/cloudflare-worker-assets.d.ts +25 -0
- package/dist/src/cloudflare-worker-assets.js +192 -0
- package/dist/src/index.d.ts +9 -0
- package/dist/src/index.js +69 -0
- package/dist/src/screens/dashboard.d.ts +3 -0
- package/dist/src/screens/dashboard.js +265 -0
- package/package.json +56 -0
- package/src/cli.ts +25 -0
- package/src/cloudflare-budget.ts +61 -0
- package/src/cloudflare-capabilities.ts +1107 -0
- package/src/cloudflare-common.ts +183 -0
- package/src/cloudflare-conformance.ts +204 -0
- package/src/cloudflare-connector.ts +443 -0
- package/src/cloudflare-openapi-operations.ts +3344 -0
- package/src/cloudflare-provisioning.ts +1969 -0
- package/src/cloudflare-r2.ts +173 -0
- package/src/cloudflare-server.ts +109 -0
- package/src/cloudflare-twin.ts +714 -0
- package/src/cloudflare-worker-assets.ts +207 -0
- package/src/index.ts +112 -0
- package/src/screens/dashboard.tsx +297 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// R2's account-side state, shared by the control plane (cloudflare-provisioning.ts), the dashboard (screens/dashboard.tsx)
|
|
2
|
+
// and the S3-compatible lane (../r2/): the R2 API tokens a person makes on the dashboard, which are the lane's
|
|
3
|
+
// credentials, and the custom domains connected to a bucket, which are where the bucket is public.
|
|
4
|
+
//
|
|
5
|
+
// R2 API TOKENS (https://developers.cloudflare.com/r2/api/tokens/): "You can generate an API token to serve as the Access
|
|
6
|
+
// Key for usage with existing S3-compatible SDKs"; a token is an Account API token ("Only users with the Super
|
|
7
|
+
// Administrator role can view or create them. These tokens remain valid until manually revoked.") or a User API token
|
|
8
|
+
// ("tied to your individual Cloudflare user"). Its permission is one of four, and "If you select the Object Read and
|
|
9
|
+
// Write or Object Read permissions, you can scope your token to a set of buckets". Its S3 credentials: "Access Key ID: The
|
|
10
|
+
// id of the API token. Secret Access Key: The SHA-256 hash of the API token value." A token's secret is kept here as
|
|
11
|
+
// bookkeeping (`_r2_api_token`, never on any wire) so the lane can tell who signed a request.
|
|
12
|
+
//
|
|
13
|
+
// CUSTOM DOMAINS (https://developers.cloudflare.com/r2/buckets/public-buckets/): a domain of a zone in the bucket's
|
|
14
|
+
// account is connected to the bucket; "The status takes a few minutes to change from Initializing to Active". Where the
|
|
15
|
+
// documentation stops: "a few minutes" is five here, the move stamped at the moment it fell due.
|
|
16
|
+
import { createHash } from 'node:crypto';
|
|
17
|
+
import { applyTwinWrite, projectResources } from '@volter/world-core';
|
|
18
|
+
|
|
19
|
+
const SERVICE = 'cloudflare';
|
|
20
|
+
type Row = Record<string, unknown> & { id: string; type: string };
|
|
21
|
+
const sha256 = (s: string): string => createHash('sha256').update(s).digest('hex');
|
|
22
|
+
const all = (type: string, root?: string, withDeleted = false): Row[] =>
|
|
23
|
+
(projectResources(SERVICE, root) as unknown as Row[]).filter((r) => r.type === type && (withDeleted || r.deleted !== true));
|
|
24
|
+
const put = (type: string, id: string, operation: string, fields: Record<string, unknown>, root: string | undefined, occurredAt: string): Promise<unknown> =>
|
|
25
|
+
applyTwinWrite(SERVICE, { operation, subjectType: type, subjectId: id, fields, occurredAt, actor: { kind: 'agent' } }, root);
|
|
26
|
+
|
|
27
|
+
/** The S3 API's endpoint suffix: "The API is available via the https://<ACCOUNT_ID>.r2.cloudflarestorage.com endpoint"
|
|
28
|
+
* (https://developers.cloudflare.com/r2/api/s3/api/). */
|
|
29
|
+
export const R2_ENDPOINT_SUFFIX = '.r2.cloudflarestorage.com';
|
|
30
|
+
|
|
31
|
+
// ── API tokens ──────────────────────────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
export type R2Permission = 'admin-rw' | 'admin-ro' | 'object-rw' | 'object-ro';
|
|
34
|
+
/** The four permissions, in the tokens page's words. */
|
|
35
|
+
export const R2_PERMISSIONS: ReadonlyArray<{ value: R2Permission; label: string; description: string }> = [
|
|
36
|
+
{ value: 'admin-rw', label: 'Admin Read & Write', description: 'Allows the ability to create, list, and delete buckets, edit bucket configuration, read, write, and list objects, and read and write to data catalog tables and associated metadata.' },
|
|
37
|
+
{ value: 'admin-ro', label: 'Admin Read only', description: 'Allows the ability to list buckets and view bucket configuration, read and list objects, and read from the data catalog tables and associated metadata.' },
|
|
38
|
+
{ value: 'object-rw', label: 'Object Read & Write', description: 'Allows the ability to read, write, and list objects in specific buckets.' },
|
|
39
|
+
{ value: 'object-ro', label: 'Object Read only', description: 'Allows the ability to read and list objects in specific buckets.' },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
export type R2Token = Row & { account_id: string; name: string; kind: 'account' | 'user'; owner: string; permission: R2Permission; buckets: '*' | string[]; access_key_id: string; secret_access_key: string; revoked?: boolean };
|
|
43
|
+
|
|
44
|
+
export function r2Tokens(accountId: string, root?: string): R2Token[] {
|
|
45
|
+
return all('_r2_api_token', root).filter((t) => t.account_id === accountId && t.revoked !== true) as R2Token[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A token's value, id and S3 credentials, made as the dashboard makes them. The value and the id are drawn from the
|
|
49
|
+
* account, the name and how many tokens the World holds, so a World mints the same ones every run. */
|
|
50
|
+
export async function createR2Token(input: { accountId: string; name: string; kind: 'account' | 'user'; owner: string; permission: R2Permission; buckets: '*' | string[] }, root: string | undefined, occurredAt: string): Promise<{ token: R2Token; value: string }> {
|
|
51
|
+
const n = all('_r2_api_token', root, true).length + 1;
|
|
52
|
+
const seed = `r2-token:${input.accountId}:${input.name}:${n}`;
|
|
53
|
+
const value = createHash('sha256').update(seed).digest('base64url').slice(0, 40);
|
|
54
|
+
const id = sha256(`${seed}:id`).slice(0, 32);
|
|
55
|
+
await put('_r2_api_token', id, 'r2_api_token.create', {
|
|
56
|
+
account_id: input.accountId, name: input.name, kind: input.kind, owner: input.owner, permission: input.permission, buckets: input.buckets,
|
|
57
|
+
access_key_id: id, secret_access_key: sha256(value), created_on: occurredAt, revoked: false,
|
|
58
|
+
}, root, occurredAt);
|
|
59
|
+
return { token: all('_r2_api_token', root).find((t) => t.id === id) as R2Token, value };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function revokeR2Token(id: string, root: string | undefined, occurredAt: string): Promise<void> {
|
|
63
|
+
await put('_r2_api_token', id, 'r2_api_token.revoke', { revoked: true }, root, occurredAt);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The live token whose Access Key ID this is, in this account. */
|
|
67
|
+
export function r2Credential(accountId: string, accessKeyId: string, root?: string): R2Token | undefined {
|
|
68
|
+
return r2Tokens(accountId, root).find((t) => t.access_key_id === accessKeyId);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** What an operation needs of a token: a bucket's objects read or written, a bucket's configuration read or edited, or
|
|
72
|
+
* the account's buckets listed, made or deleted. */
|
|
73
|
+
export type R2Need = { object: 'read' | 'write'; bucket: string } | { admin: 'read' | 'write' };
|
|
74
|
+
|
|
75
|
+
/** Whether the token's permission, as the tokens page states it, allows the need. */
|
|
76
|
+
export function r2Allows(token: R2Token, need: R2Need): boolean {
|
|
77
|
+
if ('admin' in need) return token.permission === 'admin-rw' || (token.permission === 'admin-ro' && need.admin === 'read');
|
|
78
|
+
if (token.permission === 'admin-rw') return true;
|
|
79
|
+
if (token.permission === 'admin-ro') return need.object === 'read';
|
|
80
|
+
const inScope = token.buckets === '*' || token.buckets.includes(need.bucket);
|
|
81
|
+
return inScope && (token.permission === 'object-rw' || need.object === 'read');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Temporary credentials ───────────────────────────────────────────────────────────────────────
|
|
85
|
+
// https://developers.cloudflare.com/r2/api/s3/temporary-credentials/: "short-lived, scoped S3 credentials derived from an
|
|
86
|
+
// existing R2 API token ... they include a session token and expire automatically", "bound to exactly one bucket", to a
|
|
87
|
+
// permission (object-read-only, object-read-write, admin-read-only, admin-read-write) and optionally to prefixes and
|
|
88
|
+
// objects; "A temporary credential cannot exceed the permissions of its parent token", and "If you revoke the parent API
|
|
89
|
+
// token, all temporary credentials derived from it stop working immediately". Minted by the Temporary Credentials API,
|
|
90
|
+
// POST /accounts/{account_id}/r2/temp-access-credentials (cloudflare-provisioning.ts).
|
|
91
|
+
|
|
92
|
+
export type R2TempCredential = Row & { account_id: string; bucket: string; parent: string; permission: R2Permission; prefixes: string[]; objects: string[]; access_key_id: string; secret_access_key: string; session_token: string; expires_at: string };
|
|
93
|
+
export const TEMP_PERMISSIONS: Record<string, R2Permission> = { 'object-read-only': 'object-ro', 'object-read-write': 'object-rw', 'admin-read-only': 'admin-ro', 'admin-read-write': 'admin-rw' };
|
|
94
|
+
|
|
95
|
+
/** Whether a parent token's grant covers a temporary credential's permission on a bucket. */
|
|
96
|
+
export function r2ParentCovers(parent: R2Token, permission: R2Permission, bucket: string): boolean {
|
|
97
|
+
if (!(parent.buckets === '*' || parent.buckets.includes(bucket))) return false;
|
|
98
|
+
if (parent.permission === 'admin-rw') return true;
|
|
99
|
+
if (parent.permission === 'admin-ro') return permission === 'admin-ro' || permission === 'object-ro';
|
|
100
|
+
if (parent.permission === 'object-rw') return permission === 'object-rw' || permission === 'object-ro';
|
|
101
|
+
return permission === 'object-ro';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function createR2TempCredential(input: { accountId: string; bucket: string; parent: R2Token; permission: R2Permission; ttlSeconds: number; prefixes: string[]; objects: string[] }, root: string | undefined, occurredAt: string): Promise<R2TempCredential> {
|
|
105
|
+
const n = all('_r2_temp_credential', root, true).length + 1;
|
|
106
|
+
const seed = `r2-temp:${input.accountId}:${input.bucket}:${input.parent.access_key_id}:${n}`;
|
|
107
|
+
const id = sha256(`${seed}:id`).slice(0, 32);
|
|
108
|
+
const session = createHash('sha256').update(`${seed}:session`).digest('base64url');
|
|
109
|
+
await put('_r2_temp_credential', id, 'r2_temp_credential.create', {
|
|
110
|
+
account_id: input.accountId, bucket: input.bucket, parent: input.parent.access_key_id, permission: input.permission, prefixes: input.prefixes, objects: input.objects,
|
|
111
|
+
access_key_id: id, secret_access_key: sha256(`${seed}:secret`), session_token: session, expires_at: new Date(Date.parse(occurredAt) + input.ttlSeconds * 1000).toISOString(),
|
|
112
|
+
}, root, occurredAt);
|
|
113
|
+
return all('_r2_temp_credential', root).find((c) => c.id === id) as R2TempCredential;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The temporary credential an Access Key ID and a session token name in this account, while its parent is live. */
|
|
117
|
+
export function r2TempCredential(accountId: string, accessKeyId: string, sessionToken: string, root?: string): { credential: R2TempCredential; parent: R2Token } | undefined {
|
|
118
|
+
const credential = all('_r2_temp_credential', root).find((c) => c.account_id === accountId && c.access_key_id === accessKeyId && c.session_token === sessionToken) as R2TempCredential | undefined;
|
|
119
|
+
const parent = credential ? r2Credential(accountId, credential.parent, root) : undefined;
|
|
120
|
+
return credential && parent ? { credential, parent } : undefined;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Who a Cloudflare API bearer is for R2's REST routes: an R2 API token by its value (its Secret Access Key is the value's
|
|
124
|
+
* SHA-256), or a user API token of a member of the account. */
|
|
125
|
+
export function r2BearerToken(accountId: string, bearer: string, root?: string): R2Token | undefined {
|
|
126
|
+
const secret = sha256(bearer);
|
|
127
|
+
return r2Tokens(accountId, root).find((t) => t.secret_access_key === secret);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── Custom domains ──────────────────────────────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
const ACTIVE_AFTER_MS = 5 * 60_000;
|
|
133
|
+
export type R2CustomDomain = Row & { account_id: string; bucket: string; domain: string; enabled: boolean; zone_id: string; zone_name: string; min_tls: string; ciphers?: string[]; ownership: string; ssl: string; connected_at: string };
|
|
134
|
+
export const r2DomainId = (accountId: string, bucket: string, domain: string): string => `${accountId}:${bucket}:${domain}`;
|
|
135
|
+
|
|
136
|
+
export function r2CustomDomains(root?: string): R2CustomDomain[] {
|
|
137
|
+
return all('r2_custom_domain', root) as R2CustomDomain[];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function connectR2CustomDomain(input: { accountId: string; bucket: string; domain: string; enabled: boolean; zoneId: string; zoneName: string; minTLS?: string; ciphers?: string[] }, root: string | undefined, occurredAt: string): Promise<R2CustomDomain> {
|
|
141
|
+
const id = r2DomainId(input.accountId, input.bucket, input.domain);
|
|
142
|
+
await put('r2_custom_domain', id, 'r2_custom_domain.create', {
|
|
143
|
+
account_id: input.accountId, bucket: input.bucket, domain: input.domain, enabled: input.enabled, zone_id: input.zoneId, zone_name: input.zoneName,
|
|
144
|
+
min_tls: input.minTLS ?? '1.0', ...(input.ciphers ? { ciphers: input.ciphers } : {}), ownership: 'pending', ssl: 'initializing', connected_at: occurredAt, deleted: false,
|
|
145
|
+
}, root, occurredAt);
|
|
146
|
+
return r2CustomDomains(root).find((d) => d.id === id)!;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function updateR2CustomDomain(id: string, fields: { enabled?: boolean; minTLS?: string; ciphers?: string[] }, root: string | undefined, occurredAt: string): Promise<void> {
|
|
150
|
+
await put('r2_custom_domain', id, 'r2_custom_domain.update', {
|
|
151
|
+
...(fields.enabled !== undefined ? { enabled: fields.enabled } : {}), ...(fields.minTLS !== undefined ? { min_tls: fields.minTLS } : {}), ...(fields.ciphers !== undefined ? { ciphers: fields.ciphers } : {}),
|
|
152
|
+
}, root, occurredAt);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function removeR2CustomDomain(id: string, root: string | undefined, occurredAt: string): Promise<void> {
|
|
156
|
+
await put('r2_custom_domain', id, 'r2_custom_domain.delete', { deleted: true }, root, occurredAt);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Cloudflare's own move: a connected domain turns Active once its minutes have passed, stamped when it fell due. Run
|
|
160
|
+
* before anything that reads a domain answers. */
|
|
161
|
+
export async function catchUpR2CustomDomains(root: string | undefined, now: string): Promise<void> {
|
|
162
|
+
const at = Date.parse(now);
|
|
163
|
+
for (const d of r2CustomDomains(root)) {
|
|
164
|
+
const due = Date.parse(d.connected_at) + ACTIVE_AFTER_MS;
|
|
165
|
+
if (d.ownership !== 'active' && due <= at) await put('r2_custom_domain', d.id, 'r2_custom_domain.activate', { ownership: 'active', ssl: 'active' }, root, new Date(due).toISOString());
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The bucket a host serves publicly: a custom domain connected to it, Active and enabled. */
|
|
170
|
+
export function r2PublicDomain(host: string, root?: string): R2CustomDomain | undefined {
|
|
171
|
+
const h = host.toLowerCase();
|
|
172
|
+
return r2CustomDomains(root).find((d) => d.domain === h && d.enabled && d.ownership === 'active');
|
|
173
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Cloudflare twin HTTP server.
|
|
2
|
+
//
|
|
3
|
+
// FETCH-FIRST (runtime contract R12b): the surface is the plain `createCloudflareTwinFetch` and
|
|
4
|
+
// the SERVER is one line of `Bun.serve` around it, so the standalone (R1) and hosted lanes execute
|
|
5
|
+
// identical serving bytes. This is a CUSTOM fetch, not the kernel adapter
|
|
6
|
+
// (`createTwinFetchFromHandler`): the handler's `raw` replies (a Worker script download, a
|
|
7
|
+
// bodiless 204) are served verbatim rather than JSON-encoded — openai-server.ts is the reference
|
|
8
|
+
// for that lane.
|
|
9
|
+
import { serveHttp } from '@volter/world-core';
|
|
10
|
+
import { handleCloudflareTwinRequest } from './cloudflare-twin.ts';
|
|
11
|
+
import { R2_ENDPOINT_SUFFIX, r2CustomDomains } from './cloudflare-r2.ts';
|
|
12
|
+
import { DASHBOARD_HOST, dashboardDoors } from './screens/dashboard.tsx';
|
|
13
|
+
import { createR2LaneFetch } from '../r2/src/fetch.ts';
|
|
14
|
+
import { worldNow, statefulTwinManifest } from '@volter/world-core';
|
|
15
|
+
import { createHash } from 'node:crypto';
|
|
16
|
+
|
|
17
|
+
/** Options every Cloudflare-twin HTTP surface needs, independent of who owns the socket. */
|
|
18
|
+
export interface CloudflareTwinFetchOptions {
|
|
19
|
+
root?: string;
|
|
20
|
+
readOnly?: boolean;
|
|
21
|
+
/** the R2 lane's fetch (../r2/src/fetch.ts), when the caller has one already */
|
|
22
|
+
lane?: (request: Request) => Promise<Response>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Every answer carries Cloudflare's own headers for when and which request it was: Date on the World's clock and a cf-ray
|
|
26
|
+
// (16 hex digits and a colo), derived from the instant, the request and a counter.
|
|
27
|
+
let served = 0;
|
|
28
|
+
function stamped(res: Response, request: Request): Response {
|
|
29
|
+
const at = worldNow();
|
|
30
|
+
const headers = new Headers(res.headers);
|
|
31
|
+
headers.set('date', new Date(at).toUTCString());
|
|
32
|
+
if (!headers.has('cf-ray')) headers.set('cf-ray', `${createHash('sha256').update(`${at}:${request.method}:${request.url}:${served++}`).digest('hex').slice(0, 16)}-SJC`);
|
|
33
|
+
return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createCloudflareTwinFetch(options: CloudflareTwinFetchOptions = {}): (request: Request) => Promise<Response> {
|
|
37
|
+
const inner = cloudflareTwinFetchUnstamped(options);
|
|
38
|
+
return async (request: Request) => stamped(await inner(request), request);
|
|
39
|
+
}
|
|
40
|
+
// The hosts one pack answers: the API v4 control plane (api.cloudflare.com), the dashboard (dash.cloudflare.com: sign-in, R2's
|
|
41
|
+
// API tokens, a bucket's custom domains; screens/dashboard.tsx), R2's S3 endpoint (`<ACCOUNT_ID>.r2.cloudflarestorage.com`)
|
|
42
|
+
// and a custom domain connected to an R2 bucket, both the R2 lane's (../r2/). The host is the vendor host the World's
|
|
43
|
+
// injector names in x-volter-twin-original-host, else the request's Host.
|
|
44
|
+
function hostOf(request: Request): string {
|
|
45
|
+
return (request.headers.get('x-volter-twin-original-host') ?? request.headers.get('host') ?? new URL(request.url).host).split(':')[0]!.toLowerCase();
|
|
46
|
+
}
|
|
47
|
+
function cloudflareTwinFetchUnstamped(options: CloudflareTwinFetchOptions): (request: Request) => Promise<Response> {
|
|
48
|
+
const lane = options.lane ?? createR2LaneFetch({ ...(options.root !== undefined ? { root: options.root } : {}), ...(options.readOnly !== undefined ? { readOnly: options.readOnly } : {}) });
|
|
49
|
+
return async function cloudflareTwinFetch(request: Request): Promise<Response> {
|
|
50
|
+
try {
|
|
51
|
+
const url = new URL(request.url);
|
|
52
|
+
const host = hostOf(request);
|
|
53
|
+
if (host === DASHBOARD_HOST) {
|
|
54
|
+
return (await dashboardDoors(request, options.root, worldNow())) ?? new Response('Not Found', { status: 404, headers: { 'content-type': 'text/plain' } });
|
|
55
|
+
}
|
|
56
|
+
if (host.endsWith(R2_ENDPOINT_SUFFIX) || r2CustomDomains(options.root).some((d) => d.domain === host)) return await lane(request);
|
|
57
|
+
// GET /twin/hosts — the custom domains connected to R2 buckets, which the World routes here as DNS routes them to
|
|
58
|
+
// Cloudflare (the descriptor's hostsClaimed); the same list the dispatch above answers on
|
|
59
|
+
if (request.method === 'GET' && url.pathname === '/twin/hosts') {
|
|
60
|
+
return Response.json({ hosts: [...new Set(r2CustomDomains(options.root).map((d) => d.domain))].sort() });
|
|
61
|
+
}
|
|
62
|
+
// GET /twin — the discovery manifest (education inside the twin).
|
|
63
|
+
if (request.method === 'GET' && url.pathname.replace(/\/+$/, '') === '/twin') {
|
|
64
|
+
return Response.json(statefulTwinManifest({ vendor: 'cloudflare', twinOf: 'the Cloudflare API v4 control plane', stores: 'accounts, zones, and remotely-managed Tunnel configurations/tokens' }));
|
|
65
|
+
}
|
|
66
|
+
const body = request.method !== 'GET' && request.method !== 'HEAD' ? await request.text() : '';
|
|
67
|
+
const headers: Record<string, string> = {};
|
|
68
|
+
request.headers.forEach((value, key) => { headers[key.toLowerCase()] = value; });
|
|
69
|
+
const result = await handleCloudflareTwinRequest({
|
|
70
|
+
method: request.method,
|
|
71
|
+
path: `${url.pathname}${url.search}`,
|
|
72
|
+
body,
|
|
73
|
+
headers,
|
|
74
|
+
readOnly: options.readOnly ?? false,
|
|
75
|
+
occurredAt: worldNow(),
|
|
76
|
+
...(options.root !== undefined ? { root: options.root } : {}),
|
|
77
|
+
});
|
|
78
|
+
if (result.raw) {
|
|
79
|
+
// Non-JSON responses (Worker script download; 204 subdomain delete has no body).
|
|
80
|
+
return new Response(result.body === null || result.body === undefined ? null : String(result.body), {
|
|
81
|
+
status: result.status,
|
|
82
|
+
headers: result.headers ?? {},
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return new Response(JSON.stringify(result.body), {
|
|
86
|
+
status: result.status,
|
|
87
|
+
headers: { 'content-type': 'application/json', ...(result.headers ?? {}) },
|
|
88
|
+
});
|
|
89
|
+
} catch (error) {
|
|
90
|
+
return Response.json({
|
|
91
|
+
success: false,
|
|
92
|
+
errors: [{ code: 1000, message: error instanceof Error ? error.message : 'Internal error' }],
|
|
93
|
+
messages: [],
|
|
94
|
+
result: null,
|
|
95
|
+
}, { status: 500 });
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function createCloudflareTwinServer(options: { root?: string; port?: number; readOnly?: boolean } = {}): Promise<{ port: number; url: string; stop: () => void }> {
|
|
101
|
+
const server = await serveHttp({
|
|
102
|
+
hostname: '127.0.0.1',
|
|
103
|
+
port: options.port ?? 0,
|
|
104
|
+
idleTimeout: 60,
|
|
105
|
+
fetch: createCloudflareTwinFetch(options),
|
|
106
|
+
});
|
|
107
|
+
const port = server.port ?? options.port ?? 0;
|
|
108
|
+
return { port, url: `http://127.0.0.1:${port}`, stop: () => server.stop(true) };
|
|
109
|
+
}
|