@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.
Files changed (58) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +223 -0
  3. package/dist/r2/src/endpoint.d.ts +14 -0
  4. package/dist/r2/src/endpoint.js +30 -0
  5. package/dist/r2/src/fetch.d.ts +7 -0
  6. package/dist/r2/src/fetch.js +230 -0
  7. package/dist/r2/src/generated/surface.gen.json +1 -0
  8. package/dist/r2/src/manifest.d.ts +22 -0
  9. package/dist/r2/src/manifest.js +69 -0
  10. package/dist/r2/src/semantics/buckets.d.ts +18 -0
  11. package/dist/r2/src/semantics/buckets.js +145 -0
  12. package/dist/r2/src/semantics/objects.d.ts +8 -0
  13. package/dist/r2/src/semantics/objects.js +252 -0
  14. package/dist/r2/src/wire.d.ts +54 -0
  15. package/dist/r2/src/wire.js +120 -0
  16. package/dist/src/cli.d.ts +2 -0
  17. package/dist/src/cli.js +26 -0
  18. package/dist/src/cloudflare-budget.d.ts +22 -0
  19. package/dist/src/cloudflare-budget.js +40 -0
  20. package/dist/src/cloudflare-capabilities.d.ts +6 -0
  21. package/dist/src/cloudflare-capabilities.js +1161 -0
  22. package/dist/src/cloudflare-common.d.ts +59 -0
  23. package/dist/src/cloudflare-common.js +142 -0
  24. package/dist/src/cloudflare-conformance.d.ts +6 -0
  25. package/dist/src/cloudflare-conformance.js +251 -0
  26. package/dist/src/cloudflare-connector.d.ts +53 -0
  27. package/dist/src/cloudflare-connector.js +418 -0
  28. package/dist/src/cloudflare-openapi-operations.d.ts +10 -0
  29. package/dist/src/cloudflare-openapi-operations.js +3336 -0
  30. package/dist/src/cloudflare-provisioning.d.ts +5 -0
  31. package/dist/src/cloudflare-provisioning.js +2097 -0
  32. package/dist/src/cloudflare-r2.d.ts +121 -0
  33. package/dist/src/cloudflare-r2.js +138 -0
  34. package/dist/src/cloudflare-server.d.ts +17 -0
  35. package/dist/src/cloudflare-server.js +101 -0
  36. package/dist/src/cloudflare-twin.d.ts +14 -0
  37. package/dist/src/cloudflare-twin.js +762 -0
  38. package/dist/src/cloudflare-worker-assets.d.ts +25 -0
  39. package/dist/src/cloudflare-worker-assets.js +192 -0
  40. package/dist/src/index.d.ts +9 -0
  41. package/dist/src/index.js +69 -0
  42. package/dist/src/screens/dashboard.d.ts +3 -0
  43. package/dist/src/screens/dashboard.js +265 -0
  44. package/package.json +56 -0
  45. package/src/cli.ts +25 -0
  46. package/src/cloudflare-budget.ts +61 -0
  47. package/src/cloudflare-capabilities.ts +1107 -0
  48. package/src/cloudflare-common.ts +183 -0
  49. package/src/cloudflare-conformance.ts +204 -0
  50. package/src/cloudflare-connector.ts +443 -0
  51. package/src/cloudflare-openapi-operations.ts +3344 -0
  52. package/src/cloudflare-provisioning.ts +1969 -0
  53. package/src/cloudflare-r2.ts +173 -0
  54. package/src/cloudflare-server.ts +109 -0
  55. package/src/cloudflare-twin.ts +714 -0
  56. package/src/cloudflare-worker-assets.ts +207 -0
  57. package/src/index.ts +112 -0
  58. package/src/screens/dashboard.tsx +297 -0
@@ -0,0 +1,145 @@
1
+ import { r2CustomDomains, removeR2CustomDomain } from "../../../src/cloudflare-r2.js";
2
+ import { corsXml, doc, endpointOf, hostOf, lifecycleXml, locationConstraintOf, parseCors, parseLifecycle, x, xmlAnswer } from "../wire.js";
3
+ export const accountOf = (ctx) => endpointOf(hostOf(ctx.call.request)).account;
4
+ export const jurisdictionOf = (ctx) => endpointOf(hostOf(ctx.call.request)).jurisdiction;
5
+ export const bucketId = (ctx, name) => `${accountOf(ctx)}:${name}`;
6
+ /** A bucket as this endpoint reaches it: "When using a jurisdiction endpoint, you will not be able to access R2 resources
7
+ * outside of that jurisdiction" (Data location), and a bucket in a jurisdiction is reached only through its endpoint. */
8
+ export const bucketOf = (ctx, name) => {
9
+ const b = ctx.row('Bucket', bucketId(ctx, name));
10
+ return b && String(b.jurisdiction ?? 'default') === jurisdictionOf(ctx) ? b : undefined;
11
+ };
12
+ export const objectsIn = (ctx, name) => ctx.rowsRaw('Object').filter((o) => o.bucket_id === bucketId(ctx, name));
13
+ // https://developers.cloudflare.com/r2/api/error-codes/: "10006 NoSuchBucket 404 The specified bucket does not exist."
14
+ export const noSuchBucket = (ctx) => ctx.refuse({ status: 404, code: 'NoSuchBucket', message: 'The specified bucket does not exist.' });
15
+ // Object lifecycles: "Buckets have a default lifecycle rule to expire multipart uploads seven days after initiation."
16
+ // Where the documentation stops: the rule's ID is the twin's.
17
+ export const DEFAULT_LIFECYCLE = [{ id: 'Default Multipart Abort Rule', enabled: true, prefix: '', abortMultipartDays: 7 }];
18
+ // error-codes: "Bucket names must be 3-63 chars, lowercase alphanumeric and hyphens, start/end with alphanumeric."
19
+ const BUCKET_NAME = /^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/;
20
+ // S3 API compatibility: "the region for an R2 bucket is auto. ... an empty value and us-east-1 will alias to the auto
21
+ // region. This also applies to the LocationConstraint for the CreateBucket API."
22
+ const REGIONS = new Set(['auto', '', 'us-east-1']);
23
+ // Data location: "You can set the Location Hint via the LocationConstraint parameter using the S3 API", one of the hints
24
+ // its table lists (wnam, enam, weur, eeur, apac, oc); the page's own example sends "WNAM"
25
+ export const LOCATION_HINTS = new Set(['wnam', 'enam', 'weur', 'eeur', 'apac', 'oc']);
26
+ // R2's aws-sdk-js-v3 page prints a ListBuckets answer with its Buckets and an Owner (`Owner: { DisplayName: '...', ID: '...' }`,
27
+ // https://developers.cloudflare.com/r2/examples/aws/aws-sdk-js-v3/). Where the documentation stops: the page redacts the
28
+ // owner's values, and the twin answers the account's id for both.
29
+ async function listBuckets(ctx) {
30
+ const account = accountOf(ctx);
31
+ // an endpoint reaches the buckets of its own jurisdiction only (Data location); that it lists only those is the twin's reading
32
+ const mine = ctx.rowsRaw('Bucket').filter((b) => b.account_id === account && String(b.jurisdiction ?? 'default') === jurisdictionOf(ctx)).sort((a, b) => String(a.name).localeCompare(String(b.name)));
33
+ return xmlAnswer(doc('ListAllMyBucketsResult', [x('Buckets', mine.map((b) => x('Bucket', [x('Name', String(b.name)), x('CreationDate', String(b.creation_date))]))), x('Owner', [x('DisplayName', account), x('ID', account)])]));
34
+ }
35
+ async function headBucket(ctx) {
36
+ return bucketOf(ctx, String(ctx.call.params.Bucket)) ? ctx.raw(null) : ctx.raw(null, { status: 404 });
37
+ }
38
+ // S3 API compatibility: "the region for an R2 bucket is auto"
39
+ async function getBucketLocation(ctx) {
40
+ if (!bucketOf(ctx, String(ctx.call.params.Bucket)))
41
+ return noSuchBucket(ctx);
42
+ return xmlAnswer('<?xml version="1.0" encoding="UTF-8"?><LocationConstraint xmlns="http://s3.amazonaws.com/doc/2006-03-01/">auto</LocationConstraint>');
43
+ }
44
+ // Data security: "All objects stored in R2, including their metadata, are encrypted at rest ... Objects are encrypted
45
+ // using AES-256"; nothing configures it (PutBucketEncryption is unimplemented)
46
+ async function getBucketEncryption(ctx) {
47
+ return (bucketOf(ctx, String(ctx.call.params.Bucket))
48
+ ? xmlAnswer(doc('ServerSideEncryptionConfiguration', [x('Rule', [x('ApplyServerSideEncryptionByDefault', [x('SSEAlgorithm', 'AES256')])])]))
49
+ : noSuchBucket(ctx));
50
+ }
51
+ async function getBucketCors(ctx) {
52
+ const b = bucketOf(ctx, String(ctx.call.params.Bucket));
53
+ if (!b)
54
+ return noSuchBucket(ctx);
55
+ const rules = (b.cors ?? []);
56
+ // Where R2's documentation stops: a bucket with no policy answers S3's code for it
57
+ return rules.length ? xmlAnswer(corsXml(rules)) : ctx.refuse({ status: 404, code: 'NoSuchCORSConfiguration', message: 'The CORS configuration does not exist' });
58
+ }
59
+ async function deleteBucketCors(ctx) {
60
+ const name = String(ctx.call.params.Bucket);
61
+ if (!bucketOf(ctx, name))
62
+ return noSuchBucket(ctx);
63
+ await ctx.write('Bucket', bucketId(ctx, name), { cors: [] }, 'DeleteBucketCors');
64
+ return ctx.raw(null, { status: 204 });
65
+ }
66
+ async function getBucketLifecycleConfiguration(ctx) {
67
+ const b = bucketOf(ctx, String(ctx.call.params.Bucket));
68
+ if (!b)
69
+ return noSuchBucket(ctx);
70
+ return xmlAnswer(lifecycleXml((b.lifecycle ?? DEFAULT_LIFECYCLE)));
71
+ }
72
+ /** A bucket as CreateBucket makes it, in the endpoint's jurisdiction ("Once an R2 bucket is created, the jurisdiction cannot
73
+ * be changed", Data location), with its location hint when one was given, and R2's default lifecycle rule. */
74
+ export async function createBucketRow(ctx, name, hint) {
75
+ await ctx.write('Bucket', bucketId(ctx, name), {
76
+ account_id: accountOf(ctx), name, creation_date: ctx.occurredAt, location: hint ?? null, storage_class: 'Standard', jurisdiction: jurisdictionOf(ctx), lifecycle: DEFAULT_LIFECYCLE, deleted: false,
77
+ }, 'CreateBucket');
78
+ }
79
+ export function bucketSemantics() {
80
+ return {
81
+ CreateBucket: async (ctx) => {
82
+ const name = String(ctx.call.params.Bucket);
83
+ if (!BUCKET_NAME.test(name))
84
+ return ctx.refuse({ status: 400, code: 'InvalidBucketName', message: 'Bucket name does not meet naming requirements.' });
85
+ const location = locationConstraintOf(ctx.text);
86
+ const hint = location !== undefined && LOCATION_HINTS.has(location.toLowerCase()) ? location.toLowerCase() : undefined;
87
+ // Where R2's documentation stops: a location that is neither auto nor a hint is refused with S3's code for it
88
+ if (location !== undefined && !REGIONS.has(location) && !hint)
89
+ return ctx.refuse({ status: 400, code: 'InvalidLocationConstraint', message: 'The specified location-constraint is not valid.' });
90
+ // error-codes: "10073 BucketConflict 409 Bucket name already exists." (a name is one per account, whatever its jurisdiction)
91
+ if (ctx.row('Bucket', bucketId(ctx, name)))
92
+ return ctx.refuse({ status: 409, code: 'BucketConflict', message: 'Bucket name already exists.' });
93
+ await createBucketRow(ctx, name, hint);
94
+ return ctx.raw(null, { headers: { location: `/${name}` } });
95
+ },
96
+ HeadBucket: headBucket,
97
+ ListBuckets: listBuckets,
98
+ DeleteBucket: async (ctx) => {
99
+ const name = String(ctx.call.params.Bucket);
100
+ if (!bucketOf(ctx, name))
101
+ return noSuchBucket(ctx);
102
+ // error-codes: "10008 BucketNotEmpty 409 Cannot delete bucket that contains objects."
103
+ if (objectsIn(ctx, name).length)
104
+ return ctx.refuse({ status: 409, code: 'BucketNotEmpty', message: 'Cannot delete bucket that contains objects.' });
105
+ await ctx.write('Bucket', bucketId(ctx, name), { deleted: true }, 'DeleteBucket');
106
+ // public buckets: a custom domain stays connected "until you remove it or delete the bucket"
107
+ for (const d of r2CustomDomains(ctx.root).filter((x) => x.account_id === accountOf(ctx) && x.bucket === name))
108
+ await removeR2CustomDomain(d.id, ctx.root, ctx.occurredAt);
109
+ return ctx.raw(null, { status: 204 });
110
+ },
111
+ GetBucketLocation: getBucketLocation,
112
+ GetBucketEncryption: getBucketEncryption,
113
+ PutBucketCors: async (ctx) => {
114
+ const name = String(ctx.call.params.Bucket);
115
+ if (!bucketOf(ctx, name))
116
+ return noSuchBucket(ctx);
117
+ const rules = parseCors(ctx.text);
118
+ if (!rules.length || rules.some((r) => !r.allowedOrigins.length || !r.allowedMethods.length))
119
+ return ctx.refuse({ status: 400, code: 'MalformedXML', message: 'The XML you provided was not well-formed or did not validate against our published schema.' });
120
+ await ctx.write('Bucket', bucketId(ctx, name), { cors: rules }, 'PutBucketCors');
121
+ return ctx.raw(null);
122
+ },
123
+ GetBucketCors: getBucketCors,
124
+ DeleteBucketCors: deleteBucketCors,
125
+ // PutBucketLifecycleConfiguration replaces the bucket's configuration with the one it sends; its rules are applied as
126
+ // the World clock moves (../fetch.ts, expireObjects)
127
+ PutBucketLifecycleConfiguration: async (ctx) => {
128
+ const name = String(ctx.call.params.Bucket);
129
+ if (!bucketOf(ctx, name))
130
+ return noSuchBucket(ctx);
131
+ const rules = parseLifecycle(ctx.text);
132
+ const badDate = (d) => d !== undefined && Number.isNaN(Date.parse(d));
133
+ if (!rules.length || rules.some((r) => (r.expireDays !== undefined && !(r.expireDays > 0)) || badDate(r.expireDate) || (r.transitions ?? []).some((tr) => badDate(tr.date) || (tr.date === undefined && !((tr.days ?? 0) > 0)))))
134
+ return ctx.refuse({ status: 400, code: 'MalformedXML', message: 'The XML you provided was not well-formed or did not validate against our published schema.' });
135
+ // R2 has two storage classes (its compatibility table: "✅ Storage Class: ✅ x-amz-storage-class ✅ STANDARD ✅
136
+ // STANDARD_IA"). A transition to another is refused with S3's code for it: that code, and the refusal, are the twin's
137
+ // choice where R2's documentation stops.
138
+ if (rules.some((r) => (r.transitions ?? []).some((tr) => tr.storageClass !== 'STANDARD_IA')))
139
+ return ctx.refuse({ status: 400, code: 'InvalidStorageClass', message: 'The storage class you specified is not valid' });
140
+ await ctx.write('Bucket', bucketId(ctx, name), { lifecycle: rules }, 'PutBucketLifecycleConfiguration');
141
+ return ctx.raw(null);
142
+ },
143
+ GetBucketLifecycleConfiguration: getBucketLifecycleConfiguration,
144
+ };
145
+ }
@@ -0,0 +1,8 @@
1
+ import { type Semantics } from '@volter/world-core';
2
+ type Row = Record<string, unknown>;
3
+ export declare const objectId: (account: string, bucket: string, key: string) => string;
4
+ export declare function objectBytes(o: Row, root?: string): Promise<Uint8Array<ArrayBuffer>>;
5
+ /** The headers an object's GET and HEAD answer carry: what was stored, as it was sent. */
6
+ export declare function objectHeaders(o: Row): Record<string, string>;
7
+ export declare function objectSemantics(): Record<string, Semantics>;
8
+ export {};
@@ -0,0 +1,252 @@
1
+ // R2's objects through the S3 API. An object is a `r2_object` row (`<account>:<bucket>/<key>`) holding what R2 keeps of
2
+ // it: its system metadata, which R2 implements on PutObject ("System Metadata: Content-Type, Cache-Control,
3
+ // Content-Disposition, Content-Encoding, Content-Language, Expires, Content-MD5", S3 API compatibility), its custom
4
+ // metadata, its ETag, and a reference to its bytes, which are kept in the World's blob store (state beside the tree).
5
+ import { join } from 'node:path';
6
+ import { blobDigest, getActiveBlobStore, readResourceBlob, worldPaths } from '@volter/world-core';
7
+ import { bytesOf, doc, esc, md5Etag, parseDelete, x, xmlAnswer } from "../wire.js";
8
+ import { accountOf, bucketId, bucketOf, createBucketRow, noSuchBucket, objectsIn } from "./buckets.js";
9
+ const SERVICE = 'cloudflare';
10
+ export const objectId = (account, bucket, key) => `${account}:${bucket}/${key}`;
11
+ const blobPath = (sha) => join('r2-blobs', 'sha256', sha.slice(0, 2), sha);
12
+ /** Bytes land in the blob store before the row that references them. */
13
+ async function putBytes(bytes, root) {
14
+ const sha = blobDigest(bytes);
15
+ const key = join(worldPaths(SERVICE, root).resources, blobPath(sha));
16
+ const store = getActiveBlobStore();
17
+ if (!await store.exists(key))
18
+ await store.put(key, bytes);
19
+ return sha;
20
+ }
21
+ export async function objectBytes(o, root) {
22
+ const bytes = await readResourceBlob(SERVICE, blobPath(String(o.blob_sha256)), root);
23
+ if (!bytes || blobDigest(bytes) !== o.blob_sha256)
24
+ throw new Error(`R2 object ${String(o.key)}: its bytes are missing or corrupt`);
25
+ return new Uint8Array(bytes);
26
+ }
27
+ // the system metadata R2 keeps, by header
28
+ const SYSTEM = [
29
+ ['content-type', 'content_type'], ['cache-control', 'cache_control'], ['content-disposition', 'content_disposition'],
30
+ ['content-encoding', 'content_encoding'], ['content-language', 'content_language'], ['expires', 'expires'],
31
+ ];
32
+ function metadataOf(request) {
33
+ const system = {};
34
+ for (const [h, f] of SYSTEM) {
35
+ const v = request.headers.get(h);
36
+ if (v !== null)
37
+ system[f] = v;
38
+ }
39
+ const custom = {};
40
+ request.headers.forEach((v, k) => { if (k.toLowerCase().startsWith('x-amz-meta-'))
41
+ custom[k.toLowerCase().slice('x-amz-meta-'.length)] = v; });
42
+ return { system, custom };
43
+ }
44
+ /** The headers an object's GET and HEAD answer carry: what was stored, as it was sent. */
45
+ export function objectHeaders(o) {
46
+ const out = {
47
+ etag: String(o.etag), 'last-modified': new Date(String(o.last_modified)).toUTCString(), 'content-length': String(o.size),
48
+ 'content-type': String(o.content_type ?? 'application/octet-stream'), 'accept-ranges': 'bytes',
49
+ };
50
+ for (const [h, f] of SYSTEM)
51
+ if (h !== 'content-type' && typeof o[f] === 'string')
52
+ out[h] = String(o[f]);
53
+ for (const [k, v] of Object.entries((o.metadata ?? {})))
54
+ out[`x-amz-meta-${k}`] = v;
55
+ return out;
56
+ }
57
+ // error-codes: "10007 NoSuchKey 404 The specified object key does not exist."; the page's XML example carries "The
58
+ // specified key does not exist."
59
+ const noSuchKey = (ctx) => ctx.refuse({ status: 404, code: 'NoSuchKey', message: 'The specified key does not exist.' });
60
+ const target = (ctx) => {
61
+ const bucket = String(ctx.call.params.Bucket);
62
+ const key = String(ctx.call.params.Key);
63
+ return { bucket, key, id: objectId(accountOf(ctx), bucket, key) };
64
+ };
65
+ /** The one Range form clients send for a part of an object (`bytes=a-b`, `bytes=a-`, `bytes=-n`), or undefined. */
66
+ function rangeOf(header, size) {
67
+ const m = header ? /^bytes=(\d*)-(\d*)$/.exec(header.trim()) : null;
68
+ return !m || (m[1] === '' && m[2] === '') ? undefined : byteRange(m[1], m[2], size);
69
+ }
70
+ function byteRange(from, to, size) {
71
+ const start = from === '' ? Math.max(0, size - Number(to)) : Number(from);
72
+ const end = from === '' || to === '' ? size - 1 : Math.min(Number(to), size - 1);
73
+ return start > end || start >= size ? 'unsatisfiable' : { start, end };
74
+ }
75
+ async function read(ctx, head) {
76
+ const { bucket, id } = target(ctx);
77
+ if (!bucketOf(ctx, bucket))
78
+ return head ? ctx.raw(null, { status: 404 }) : noSuchBucket(ctx);
79
+ const o = ctx.row('Object', id);
80
+ if (!o)
81
+ return head ? ctx.raw(null, { status: 404 }) : noSuchKey(ctx);
82
+ const headers = objectHeaders(o);
83
+ // conditional reads (R2 implements If-Match, If-None-Match, If-Modified-Since, If-Unmodified-Since)
84
+ const ifNone = ctx.call.request.headers.get('if-none-match');
85
+ if (ifNone && (ifNone === '*' || ifNone.split(',').map((s) => s.trim()).includes(String(o.etag))))
86
+ return ctx.raw(null, { status: 304, headers: { etag: String(o.etag) } });
87
+ const ifMatch = ctx.call.request.headers.get('if-match');
88
+ // error-codes: "10031 PreconditionFailed 412 Conditional headers (If-Match, If-Unmodified-Since, etc.) were not satisfied."
89
+ if (ifMatch && ifMatch !== '*' && !ifMatch.split(',').map((s) => s.trim()).includes(String(o.etag)))
90
+ return head ? ctx.raw(null, { status: 412 }) : ctx.refuse({ status: 412, code: 'PreconditionFailed', message: 'At least one of the pre-conditions you specified did not hold' });
91
+ if (head)
92
+ return ctx.raw(null, { headers });
93
+ const bytes = await objectBytes(o, ctx.root);
94
+ const range = rangeOf(ctx.call.request.headers.get('range'), bytes.length);
95
+ // error-codes: "10039 InvalidRange 416 Requested byte range is not satisfiable."
96
+ if (range === 'unsatisfiable')
97
+ return ctx.refuse({ status: 416, code: 'InvalidRange', message: 'The requested range is not satisfiable' });
98
+ if (range)
99
+ return ctx.raw(bytes.slice(range.start, range.end + 1), { status: 206, headers: { ...headers, 'content-length': String(range.end - range.start + 1), 'content-range': `bytes ${range.start}-${range.end}/${bytes.length}` } });
100
+ return ctx.raw(bytes, { headers });
101
+ }
102
+ function listed(ctx, bucket, prefix, delimiter, after, max) {
103
+ const keys = objectsIn(ctx, bucket).filter((o) => String(o.key).startsWith(prefix) && String(o.key) > after).sort((a, b) => (String(a.key) < String(b.key) ? -1 : 1));
104
+ const contents = [];
105
+ const prefixes = [];
106
+ let last;
107
+ for (const o of keys) {
108
+ const key = String(o.key);
109
+ const cut = delimiter ? key.indexOf(delimiter, prefix.length) : -1;
110
+ const common = cut >= 0 ? key.slice(0, cut + delimiter.length) : undefined;
111
+ if (common && prefixes.includes(common))
112
+ continue;
113
+ if (contents.length + prefixes.length === max)
114
+ return { contents, prefixes, truncated: true, ...(last ? { last } : {}) };
115
+ if (common)
116
+ prefixes.push(common);
117
+ else
118
+ contents.push(o);
119
+ last = common ?? key;
120
+ }
121
+ return { contents, prefixes, truncated: false, ...(last ? { last } : {}) };
122
+ }
123
+ /** A listed object. ListObjects (v1) lists each with its Owner, as S3's response syntax does and R2's aws-sdk-js page prints
124
+ * (`Owner: [Object]`, https://developers.cloudflare.com/r2/examples/aws/aws-sdk-js/); ListObjectsV2 leaves it out, as its
125
+ * aws-sdk-js-v3 page prints (`Owner: undefined`). Where the documentation stops: the owner's values are the account's id,
126
+ * the twin's choice (R2's aws-cli page prints a ListBuckets owner whose DisplayName and ID are one 32-hex id). */
127
+ const entry = (o, owner) => x('Contents', [x('Key', String(o.key)), x('LastModified', String(o.last_modified)), x('ETag', String(o.etag)), x('Size', Number(o.size)), x('StorageClass', String(o.storage_class ?? 'STANDARD')), ...(owner ? [x('Owner', [x('DisplayName', owner), x('ID', owner)])] : [])]);
128
+ const maxKeysOf = (q) => Math.min(1000, Math.max(0, Number(q.get('max-keys') ?? 1000) || 0));
129
+ async function listObjects(ctx) {
130
+ const bucket = String(ctx.call.params.Bucket);
131
+ if (!bucketOf(ctx, bucket))
132
+ return noSuchBucket(ctx);
133
+ const q = new URL(ctx.call.request.url).searchParams;
134
+ const prefix = q.get('prefix') ?? '';
135
+ const delimiter = q.get('delimiter') ?? '';
136
+ const marker = q.get('marker') ?? '';
137
+ const max = maxKeysOf(q);
138
+ const page = listed(ctx, bucket, prefix, delimiter, marker, max);
139
+ return xmlAnswer(doc('ListBucketResult', [
140
+ x('Name', bucket), x('Prefix', prefix), x('Marker', marker), ...(delimiter ? [x('Delimiter', delimiter)] : []), x('MaxKeys', max),
141
+ x('IsTruncated', page.truncated ? 'true' : 'false'), ...(page.truncated && delimiter && page.last ? [x('NextMarker', page.last)] : []),
142
+ ...page.contents.map((o) => entry(o, accountOf(ctx))), ...page.prefixes.map((p) => x('CommonPrefixes', [x('Prefix', p)])),
143
+ ]));
144
+ }
145
+ // CopyObject: the source names `<bucket>/<key>` in x-amz-copy-source (URL-encoded, a leading slash allowed); R2
146
+ // implements x-amz-metadata-directive, so REPLACE takes the request's metadata and COPY (the default) the source's
147
+ async function copyObject(ctx) {
148
+ const { bucket, key, id } = target(ctx);
149
+ if (!bucketOf(ctx, bucket))
150
+ return noSuchBucket(ctx);
151
+ const raw = decodeURIComponent(String(ctx.call.request.headers.get('x-amz-copy-source') ?? '')).replace(/^\//, '').split('?')[0];
152
+ const cut = raw.indexOf('/');
153
+ const from = cut > 0 ? ctx.row('Object', objectId(accountOf(ctx), raw.slice(0, cut), raw.slice(cut + 1))) : undefined;
154
+ if (!from)
155
+ return noSuchKey(ctx);
156
+ const directive = (ctx.call.request.headers.get('x-amz-metadata-directive') ?? 'COPY').toUpperCase();
157
+ const replace = directive === 'REPLACE';
158
+ const { system, custom } = metadataOf(ctx.call.request);
159
+ const kept = {};
160
+ for (const [, f] of SYSTEM)
161
+ if (from[f] !== undefined)
162
+ kept[f] = from[f];
163
+ // R2's MERGE (S3 extensions: "MERGE is a combination of COPY and REPLACE, which will COPY any metadata keys from the
164
+ // source object and REPLACE those that are specified in the request with the new value")
165
+ if (directive === 'MERGE') {
166
+ Object.assign(kept, system);
167
+ Object.assign(custom, { ...(from.metadata ?? {}), ...custom });
168
+ }
169
+ await ctx.write('Object', id, {
170
+ bucket_id: `${accountOf(ctx)}:${bucket}`, bucket, key, size: from.size, etag: from.etag, ...(replace ? system : kept), metadata: replace || directive === 'MERGE' ? custom : (from.metadata ?? {}),
171
+ storage_class: ctx.call.request.headers.get('x-amz-storage-class') ?? from.storage_class ?? 'STANDARD', last_modified: ctx.occurredAt, blob_sha256: from.blob_sha256, deleted: false,
172
+ }, 'CopyObject');
173
+ return xmlAnswer(`<?xml version="1.0" encoding="UTF-8"?><CopyObjectResult><LastModified>${esc(ctx.occurredAt)}</LastModified><ETag>${esc(String(from.etag))}</ETag></CopyObjectResult>`);
174
+ }
175
+ export function objectSemantics() {
176
+ return {
177
+ PutObject: async (ctx) => {
178
+ const { bucket, key, id } = target(ctx);
179
+ if (!bucketOf(ctx, bucket)) {
180
+ // S3 extensions: "Add a cf-create-bucket-if-missing header with the value true to implicitly create the bucket if
181
+ // it does not exist yet", as a CreateBucket with LocationConstraint auto would
182
+ if (ctx.call.request.headers.get('cf-create-bucket-if-missing') !== 'true' || ctx.row('Bucket', bucketId(ctx, bucket)))
183
+ return noSuchBucket(ctx);
184
+ await createBucketRow(ctx, bucket);
185
+ }
186
+ // a conditional write: R2 implements PutObject's "✅ Conditional Operations: ✅ If-Match ✅ If-Modified-Since ✅
187
+ // If-None-Match ✅ If-Unmodified-Since" (S3 API compatibility); error-codes: "10031 PreconditionFailed 412"
188
+ const current = ctx.row('Object', id);
189
+ const ifMatch = ctx.call.request.headers.get('if-match');
190
+ const ifNone = ctx.call.request.headers.get('if-none-match');
191
+ if ((ifMatch && (!current || (ifMatch !== '*' && !ifMatch.split(',').map((s) => s.trim()).includes(String(current.etag))))) || (ifNone && current && (ifNone === '*' || ifNone.split(',').map((s) => s.trim()).includes(String(current.etag))))) {
192
+ return ctx.refuse({ status: 412, code: 'PreconditionFailed', message: 'At least one of the pre-conditions you specified did not hold' });
193
+ }
194
+ // error-codes: "10020 InvalidObjectName 400 ... Maximum key length is 1024 bytes."
195
+ if (new TextEncoder().encode(key).length > 1024)
196
+ return ctx.refuse({ status: 400, code: 'InvalidObjectName', message: 'Object key contains invalid characters or is too long.' });
197
+ const bytes = bytesOf(ctx.call.request);
198
+ const sha = await putBytes(bytes, ctx.root);
199
+ const { system, custom } = metadataOf(ctx.call.request);
200
+ const etag = md5Etag(bytes);
201
+ const storage = ctx.call.request.headers.get('x-amz-storage-class') ?? 'STANDARD';
202
+ await ctx.write('Object', id, { bucket_id: `${accountOf(ctx)}:${bucket}`, bucket, key, size: bytes.length, etag, ...system, metadata: custom, storage_class: storage, last_modified: ctx.occurredAt, blob_sha256: sha, deleted: false }, 'PutObject');
203
+ return ctx.raw(null, { headers: { etag } });
204
+ },
205
+ GetObject: (ctx) => read(ctx, false),
206
+ HeadObject: (ctx) => read(ctx, true),
207
+ // S3's DeleteObject answers 204 whether or not the key existed
208
+ DeleteObject: async (ctx) => {
209
+ const { bucket, id } = target(ctx);
210
+ if (!bucketOf(ctx, bucket))
211
+ return noSuchBucket(ctx);
212
+ if (ctx.row('Object', id))
213
+ await ctx.write('Object', id, { deleted: true }, 'DeleteObject');
214
+ return ctx.raw(null, { status: 204 });
215
+ },
216
+ DeleteObjects: async (ctx) => {
217
+ const bucket = String(ctx.call.params.Bucket);
218
+ if (!bucketOf(ctx, bucket))
219
+ return noSuchBucket(ctx);
220
+ const { keys, quiet } = parseDelete(ctx.text);
221
+ if (!keys.length || keys.length > 1000)
222
+ return ctx.refuse({ status: 400, code: 'MalformedXML', message: 'The XML you provided was not well-formed or did not validate against our published schema.' });
223
+ for (const key of keys) {
224
+ const id = objectId(accountOf(ctx), bucket, key);
225
+ if (ctx.row('Object', id))
226
+ await ctx.write('Object', id, { deleted: true }, 'DeleteObjects');
227
+ }
228
+ return xmlAnswer(doc('DeleteResult', quiet ? [] : keys.map((k) => x('Deleted', [x('Key', k)]))));
229
+ },
230
+ ListObjectsV2: async (ctx) => {
231
+ const bucket = String(ctx.call.params.Bucket);
232
+ if (!bucketOf(ctx, bucket))
233
+ return noSuchBucket(ctx);
234
+ const q = new URL(ctx.call.request.url).searchParams;
235
+ const prefix = q.get('prefix') ?? '';
236
+ const delimiter = q.get('delimiter') ?? '';
237
+ const token = q.get('continuation-token');
238
+ const startAfter = q.get('start-after') ?? '';
239
+ const after = token ? Buffer.from(token, 'base64url').toString('utf8') : startAfter;
240
+ const max = maxKeysOf(q);
241
+ const page = listed(ctx, bucket, prefix, delimiter, after, max);
242
+ return xmlAnswer(doc('ListBucketResult', [
243
+ x('Name', bucket), x('Prefix', prefix), ...(delimiter ? [x('Delimiter', delimiter)] : []), x('MaxKeys', max), x('KeyCount', page.contents.length + page.prefixes.length),
244
+ x('IsTruncated', page.truncated ? 'true' : 'false'), ...(token ? [x('ContinuationToken', token)] : []), ...(startAfter ? [x('StartAfter', startAfter)] : []),
245
+ ...(page.truncated && page.last ? [x('NextContinuationToken', Buffer.from(page.last).toString('base64url'))] : []),
246
+ ...page.contents.map((o) => entry(o)), ...page.prefixes.map((p) => x('CommonPrefixes', [x('Prefix', p)])),
247
+ ]));
248
+ },
249
+ ListObjects: listObjects,
250
+ CopyObject: copyObject,
251
+ };
252
+ }
@@ -0,0 +1,54 @@
1
+ export { endpointOf, JURISDICTIONS } from './endpoint.js';
2
+ /** The host a request came to: the vendor host the World's injector names, else its Host, else its URL's. */
3
+ export declare function hostOf(request: Request): string;
4
+ export declare function esc(s: string | number): string;
5
+ /** An element and its children or text. */
6
+ export declare const x: (tag: string, inner: string | number | string[]) => string;
7
+ export declare const doc: (root: string, inner: string[]) => string;
8
+ export declare const xmlAnswer: (body: string, status?: number, headers?: Record<string, string>) => Response;
9
+ export declare function unesc(s: string): string;
10
+ export type CorsRule = {
11
+ id?: string;
12
+ allowedOrigins: string[];
13
+ allowedMethods: string[];
14
+ allowedHeaders: string[];
15
+ exposeHeaders: string[];
16
+ maxAgeSeconds?: number;
17
+ };
18
+ export declare function parseCors(xml: string): CorsRule[];
19
+ export declare function corsXml(rules: CorsRule[]): string;
20
+ /** The first rule that allows the origin, the method and the headers a preflight asks for. */
21
+ export declare function corsRuleFor(rules: CorsRule[], origin: string, method: string, requested: string[]): CorsRule | undefined;
22
+ /** The Access-Control-* headers a matching rule gives an answer ("AllowedOrigins: Specifies the value for the
23
+ * Access-Control-Allow-Origin header", and likewise for the methods, headers, exposed headers and max age). */
24
+ export declare function corsHeaders(rule: CorsRule, origin: string, requested: string[], preflight: boolean): Record<string, string>;
25
+ export declare const requestedHeaders: (request: Request) => string[];
26
+ /** A rule's prefix is sent in its Filter or, in S3's older form, on the rule itself, or not at all (R2's lifecycle page
27
+ * sends a transition rule with neither, for the whole bucket); it is answered as it was sent. A
28
+ * transition moves an object to Infrequent Access (Object lifecycles: "transition objects to Infrequent Access storage
29
+ * after 30 days", its example `Transitions: [{ Days: 30, StorageClass: "STANDARD_IA" }]`). */
30
+ export type LifecycleRule = {
31
+ id: string;
32
+ enabled: boolean;
33
+ prefix: string;
34
+ prefixOnRule?: boolean;
35
+ noPrefix?: boolean;
36
+ expireDays?: number;
37
+ expireDate?: string;
38
+ abortMultipartDays?: number;
39
+ transitions?: Array<{
40
+ days?: number;
41
+ date?: string;
42
+ storageClass: string;
43
+ }>;
44
+ };
45
+ export declare function parseLifecycle(xml: string): LifecycleRule[];
46
+ export declare function lifecycleXml(rules: LifecycleRule[]): string;
47
+ export declare function parseDelete(xml: string): {
48
+ keys: string[];
49
+ quiet: boolean;
50
+ };
51
+ export declare const locationConstraintOf: (xml: string) => string | undefined;
52
+ export declare const md5Etag: (bytes: Uint8Array) => string;
53
+ export declare function withBytes(request: Request): Promise<Request>;
54
+ export declare const bytesOf: (request: Request) => Uint8Array;
@@ -0,0 +1,120 @@
1
+ // The S3 wire as R2 speaks it: where a request's account and bucket are, the XML bodies R2 answers and reads, and a
2
+ // bucket's CORS rules applied to browsers. Ported from the aws pack's S3 twin (aws-s3-xml.ts, aws-s3-cors.ts), which a
3
+ // pack may not import (architecture B4), cut to what R2 implements.
4
+ import { createHash } from 'node:crypto';
5
+ export { endpointOf, JURISDICTIONS } from "./endpoint.js";
6
+ // ── addressing ───────────────────────────────────────────────────────────────────────────────
7
+ /** The host a request came to: the vendor host the World's injector names, else its Host, else its URL's. */
8
+ export function hostOf(request) {
9
+ return (request.headers.get('x-volter-twin-original-host') ?? request.headers.get('host') ?? new URL(request.url).host).split(':')[0].toLowerCase();
10
+ }
11
+ // ── XML ──────────────────────────────────────────────────────────────────────────────────────
12
+ const DECL = '<?xml version="1.0" encoding="UTF-8"?>';
13
+ const NS = 'http://s3.amazonaws.com/doc/2006-03-01/';
14
+ export function esc(s) {
15
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;');
16
+ }
17
+ /** An element and its children or text. */
18
+ export const x = (tag, inner) => `<${tag}>${Array.isArray(inner) ? inner.join('') : esc(inner)}</${tag}>`;
19
+ export const doc = (root, inner) => `${DECL}<${root} xmlns="${NS}">${inner.join('')}</${root}>`;
20
+ export const xmlAnswer = (body, status = 200, headers = {}) => new Response(body, { status, headers: { 'content-type': 'application/xml', ...headers } });
21
+ export function unesc(s) {
22
+ return s.replace(/&(?:lt|gt|quot|apos|amp|#\d+|#x[0-9a-fA-F]+);/g, decodeEntity);
23
+ }
24
+ /** One XML entity, named or numeric, as its character. */
25
+ function decodeEntity(m) {
26
+ const named = { '&lt;': '<', '&gt;': '>', '&quot;': '"', '&apos;': "'", '&amp;': '&' };
27
+ if (named[m])
28
+ return named[m];
29
+ const body = m.slice(2, -1);
30
+ const code = body[0] === 'x' ? Number.parseInt(body.slice(1), 16) : Number(body);
31
+ return Number.isInteger(code) && code >= 0 && code <= 0x10ffff ? String.fromCodePoint(code) : m;
32
+ }
33
+ const allOf = (xml, tag) => [...xml.matchAll(new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'g'))].map((m) => unesc(m[1].trim()));
34
+ const oneOf = (xml, tag) => allOf(xml, tag)[0];
35
+ export function parseCors(xml) {
36
+ return [...xml.matchAll(/<CORSRule>([\s\S]*?)<\/CORSRule>/g)].map((m) => {
37
+ const b = m[1];
38
+ const id = oneOf(b, 'ID');
39
+ const age = oneOf(b, 'MaxAgeSeconds');
40
+ return { ...(id ? { id } : {}), allowedOrigins: allOf(b, 'AllowedOrigin'), allowedMethods: allOf(b, 'AllowedMethod'), allowedHeaders: allOf(b, 'AllowedHeader'), exposeHeaders: allOf(b, 'ExposeHeader'), ...(age ? { maxAgeSeconds: Number(age) } : {}) };
41
+ });
42
+ }
43
+ export function corsXml(rules) {
44
+ return doc('CORSConfiguration', rules.map((r) => x('CORSRule', [
45
+ ...(r.id ? [x('ID', r.id)] : []), ...r.allowedOrigins.map((o) => x('AllowedOrigin', o)), ...r.allowedMethods.map((m) => x('AllowedMethod', m)),
46
+ ...r.allowedHeaders.map((h) => x('AllowedHeader', h)), ...r.exposeHeaders.map((h) => x('ExposeHeader', h)), ...(r.maxAgeSeconds !== undefined ? [x('MaxAgeSeconds', r.maxAgeSeconds)] : []),
47
+ ])));
48
+ }
49
+ function wildMatch(pattern, value, fold) {
50
+ const p = fold ? pattern.toLowerCase() : pattern;
51
+ const v = fold ? value.toLowerCase() : value;
52
+ const star = p.indexOf('*');
53
+ if (star < 0)
54
+ return p === v;
55
+ return v.length >= p.length - 1 && v.startsWith(p.slice(0, star)) && v.endsWith(p.slice(star + 1));
56
+ }
57
+ /** The first rule that allows the origin, the method and the headers a preflight asks for. */
58
+ export function corsRuleFor(rules, origin, method, requested) {
59
+ return rules.find((r) => r.allowedOrigins.some((o) => wildMatch(o, origin, false))
60
+ && r.allowedMethods.some((m) => m === '*' || m.toUpperCase() === method.toUpperCase())
61
+ && requested.every((h) => r.allowedHeaders.some((a) => wildMatch(a, h, true))));
62
+ }
63
+ /** The Access-Control-* headers a matching rule gives an answer ("AllowedOrigins: Specifies the value for the
64
+ * Access-Control-Allow-Origin header", and likewise for the methods, headers, exposed headers and max age). */
65
+ export function corsHeaders(rule, origin, requested, preflight) {
66
+ return {
67
+ 'access-control-allow-origin': origin,
68
+ 'access-control-allow-methods': rule.allowedMethods.join(', '),
69
+ vary: 'Origin, Access-Control-Request-Headers, Access-Control-Request-Method',
70
+ ...(preflight && requested.length ? { 'access-control-allow-headers': requested.join(', ') } : {}),
71
+ ...(preflight && rule.maxAgeSeconds !== undefined ? { 'access-control-max-age': String(rule.maxAgeSeconds) } : {}),
72
+ ...(rule.exposeHeaders.length ? { 'access-control-expose-headers': rule.exposeHeaders.join(', ') } : {}),
73
+ };
74
+ }
75
+ export const requestedHeaders = (request) => (request.headers.get('access-control-request-headers') ?? '').split(',').map((h) => h.trim()).filter(Boolean);
76
+ export function parseLifecycle(xml) {
77
+ return [...xml.matchAll(/<Rule>([\s\S]*?)<\/Rule>/g)].map((m) => {
78
+ const b = m[1];
79
+ const expiration = /<Expiration>([\s\S]*?)<\/Expiration>/.exec(b)?.[1];
80
+ const abort = /<AbortIncompleteMultipartUpload>([\s\S]*?)<\/AbortIncompleteMultipartUpload>/.exec(b)?.[1];
81
+ const days = expiration ? oneOf(expiration, 'Days') : undefined;
82
+ const date = expiration ? oneOf(expiration, 'Date') : undefined;
83
+ const after = abort ? oneOf(abort, 'DaysAfterInitiation') : undefined;
84
+ const transitions = [...b.matchAll(/<Transition>([\s\S]*?)<\/Transition>/g)].map((x) => {
85
+ const d = oneOf(x[1], 'Days');
86
+ const on = oneOf(x[1], 'Date');
87
+ return { ...(d !== undefined ? { days: Number(d) } : {}), ...(on !== undefined ? { date: on } : {}), storageClass: oneOf(x[1], 'StorageClass') ?? '' };
88
+ });
89
+ return {
90
+ id: oneOf(b, 'ID') ?? '', enabled: (oneOf(b, 'Status') ?? 'Enabled') === 'Enabled', prefix: oneOf(b, 'Prefix') ?? '', ...(/<Filter>/.test(b) ? {} : /<Prefix>/.test(b) ? { prefixOnRule: true } : { noPrefix: true }),
91
+ ...(days ? { expireDays: Number(days) } : {}), ...(date ? { expireDate: date } : {}), ...(after ? { abortMultipartDays: Number(after) } : {}), ...(transitions.length ? { transitions } : {}),
92
+ };
93
+ });
94
+ }
95
+ export function lifecycleXml(rules) {
96
+ return doc('LifecycleConfiguration', rules.map((r) => x('Rule', [
97
+ x('ID', r.id), ...(r.noPrefix ? [] : [r.prefixOnRule ? x('Prefix', r.prefix) : x('Filter', [x('Prefix', r.prefix)])]), x('Status', r.enabled ? 'Enabled' : 'Disabled'),
98
+ ...(r.transitions ?? []).map((tr) => x('Transition', [...(tr.date !== undefined ? [x('Date', tr.date)] : [x('Days', tr.days ?? 0)]), x('StorageClass', tr.storageClass)])),
99
+ ...(r.expireDays !== undefined ? [x('Expiration', [x('Days', r.expireDays)])] : r.expireDate !== undefined ? [x('Expiration', [x('Date', r.expireDate)])] : []),
100
+ ...(r.abortMultipartDays !== undefined ? [x('AbortIncompleteMultipartUpload', [x('DaysAfterInitiation', r.abortMultipartDays)])] : []),
101
+ ])));
102
+ }
103
+ // ── bodies ───────────────────────────────────────────────────────────────────────────────────
104
+ export function parseDelete(xml) {
105
+ return { keys: [...xml.matchAll(/<Object>([\s\S]*?)<\/Object>/g)].map((m) => oneOf(m[1], 'Key') ?? ''), quiet: /<Quiet>\s*true\s*<\/Quiet>/i.test(xml) };
106
+ }
107
+ export const locationConstraintOf = (xml) => oneOf(xml, 'LocationConstraint');
108
+ export const md5Etag = (bytes) => `"${createHash('md5').update(bytes).digest('hex')}"`;
109
+ /** A request's body as the bytes it carried. The derived dispatch reads a body as text to find its parameters, which no
110
+ * binary object survives, so the lane's fetch reads the bytes first and keeps them here for the handler. */
111
+ const BODIES = new WeakMap();
112
+ export async function withBytes(request) {
113
+ if (request.method === 'GET' || request.method === 'HEAD' || request.method === 'OPTIONS')
114
+ return request;
115
+ const bytes = new Uint8Array(await request.arrayBuffer());
116
+ const kept = new Request(request.url, { method: request.method, headers: request.headers, body: bytes });
117
+ BODIES.set(kept, bytes);
118
+ return kept;
119
+ }
120
+ export const bytesOf = (request) => BODIES.get(request) ?? new Uint8Array(0);
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+ import { keepProcessAlive } from '@volter/world-core/lifecycle';
3
+ // world-cloudflare CLI: serve the KERNEL-BACKED Cloudflare API twin, or run conformance. State
4
+ // lives in the @volter/world-core action log (no in-memory side-store). Conformance is dev-only +
5
+ // lazy-imported so the bin runs without @volter/world-tooling (E2).
6
+ import { hasFlag, optionValue } from '@volter/world-core/args';
7
+ import { createCloudflareTwinServer } from "./cloudflare-server.js";
8
+ const [cmd, ...rest] = process.argv.slice(2);
9
+ const port = Number(optionValue(rest, '--port', String(process.env.PORT ?? '0'))) || undefined;
10
+ const root = optionValue(rest, '--root') || undefined;
11
+ const readOnly = hasFlag(rest, '--read-only'); // a twin accepts writes unless started read-only
12
+ if (cmd === 'serve' || cmd === undefined) {
13
+ const s = await createCloudflareTwinServer({ readOnly, ...(root ? { root } : {}), ...(port ? { port } : {}) });
14
+ process.stdout.write(`cloudflare twin (accounts, zones, Cloudflare Tunnels, DNS records, Workers, R2, Hyperdrive, Queues)${readOnly ? ' [read-only]' : ''} at ${s.url}\n`);
15
+ await keepProcessAlive();
16
+ }
17
+ else if (cmd === 'conformance') {
18
+ const { checkCloudflareConformance } = await import("./cloudflare-conformance.js");
19
+ const report = await checkCloudflareConformance();
20
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
21
+ if (!report.ok)
22
+ process.exitCode = 1;
23
+ }
24
+ else {
25
+ process.stdout.write('Usage: world-cloudflare serve|conformance [--port N] [--root DIR] [--read-only]\n');
26
+ }