@takosjp/yurucommu-core 4.1.2 → 4.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
package/src/backend/index.ts
CHANGED
|
@@ -9,7 +9,6 @@ import {
|
|
|
9
9
|
wrapRuntimeBindings,
|
|
10
10
|
wrapRuntimeMessageBatch,
|
|
11
11
|
type PortableWorkerBindings,
|
|
12
|
-
type RuntimeLane,
|
|
13
12
|
} from "./runtime/lane.ts";
|
|
14
13
|
import {
|
|
15
14
|
configuredAppUrl,
|
|
@@ -939,20 +938,28 @@ function mountStaticFallback(app: YurucommuApp): void {
|
|
|
939
938
|
* a readiness probe that reported `APP_URL` missing on a Worker whose origin
|
|
940
939
|
* only its own traffic can reveal would never go ready.
|
|
941
940
|
*
|
|
942
|
-
* It runs on the
|
|
943
|
-
*
|
|
944
|
-
*
|
|
945
|
-
*
|
|
946
|
-
*
|
|
947
|
-
*
|
|
948
|
-
*
|
|
949
|
-
*
|
|
941
|
+
* It runs on EVERY lane, because the lane names the SHAPE OF THE BINDINGS and
|
|
942
|
+
* not who chose the endpoint. A Takoform-hosted Worker on the production
|
|
943
|
+
* Takoserver receives raw Cloudflare bindings — the `cloudflare` lane — and its
|
|
944
|
+
* origin is still allocated by a `WorkerEndpoint` after the `WorkerVersion`
|
|
945
|
+
* that would have carried `APP_URL` was sealed, and the Takoform module passes
|
|
946
|
+
* no `APP_URL` either. Gating the inference on the lane left exactly that
|
|
947
|
+
* install unable to ever name itself, and so never ready.
|
|
948
|
+
*
|
|
949
|
+
* What makes the request URL trustworthy is a property of the runtime rather
|
|
950
|
+
* than of the binding shape: the origin on the request is the origin the
|
|
951
|
+
* request actually arrived on, and no forwarded header is ever consulted (see
|
|
952
|
+
* runtime/public-origin.ts). The remaining risk on a Worker deployed straight
|
|
953
|
+
* to Cloudflare — it also answers on workers.dev and on every custom domain and
|
|
954
|
+
* route pattern its account holds, so the first hostname to arrive would name
|
|
955
|
+
* the instance for good — is answered the same way it always was: an operator
|
|
956
|
+
* who owns more than one of those hostnames sets `APP_URL`, which always wins.
|
|
950
957
|
*
|
|
951
958
|
* It never fails a request. When no origin can be established — the request is
|
|
952
|
-
* plain http, KV is unbound, an operator hand-wrote the pin
|
|
953
|
-
* stays unset, which `/readyz` already reports as a hard
|
|
954
|
-
* Turning that into a 500 would take the readiness probe down
|
|
955
|
-
* replace a precise answer with a generic one.
|
|
959
|
+
* plain http on a routable host, KV is unbound, an operator hand-wrote the pin
|
|
960
|
+
* — `APP_URL` simply stays unset, which `/readyz` already reports as a hard
|
|
961
|
+
* missing binding. Turning that into a 500 would take the readiness probe down
|
|
962
|
+
* with it and replace a precise answer with a generic one.
|
|
956
963
|
*/
|
|
957
964
|
function publicOriginMiddleware() {
|
|
958
965
|
let warned = false;
|
|
@@ -961,16 +968,6 @@ function publicOriginMiddleware() {
|
|
|
961
968
|
next: Next,
|
|
962
969
|
) => {
|
|
963
970
|
if (configuredAppUrl(c.env) !== null) return next();
|
|
964
|
-
// An unrecognised lane declaration is refused at the binding boundary by
|
|
965
|
-
// wrapRuntimeBindings. Reaching here with one means the app was composed
|
|
966
|
-
// directly, and the safe reading of "not a lane I know" is "do not infer".
|
|
967
|
-
let lane: RuntimeLane;
|
|
968
|
-
try {
|
|
969
|
-
lane = resolveRuntimeLane(c.env.YURUCOMMU_RUNTIME_LANE);
|
|
970
|
-
} catch {
|
|
971
|
-
return next();
|
|
972
|
-
}
|
|
973
|
-
if (lane !== "portable") return next();
|
|
974
971
|
try {
|
|
975
972
|
const origin = await establishRequestPublicOrigin(c.env, c.req.raw);
|
|
976
973
|
c.env = { ...c.env, APP_URL: origin };
|
package/src/backend/public.ts
CHANGED
|
@@ -108,8 +108,17 @@ export {
|
|
|
108
108
|
} from "./runtime/edge-queue.ts";
|
|
109
109
|
export {
|
|
110
110
|
EdgeObjectStorage,
|
|
111
|
+
EdgeObjectsBucket,
|
|
111
112
|
EdgeObjectsShapeError,
|
|
113
|
+
type EdgeObjectHttpMetadata,
|
|
114
|
+
type EdgeObjectRange,
|
|
115
|
+
type EdgeObjectsGetOptions,
|
|
116
|
+
type EdgeObjectsListOptions,
|
|
117
|
+
type EdgeR2Object,
|
|
118
|
+
type EdgeR2ObjectBody,
|
|
119
|
+
type EdgeR2Objects,
|
|
112
120
|
wrapEdgeObjects,
|
|
121
|
+
wrapEdgeObjectsAsBucket,
|
|
113
122
|
} from "./runtime/edge-objects.ts";
|
|
114
123
|
export type {
|
|
115
124
|
IKeyValueStore,
|
|
@@ -1,14 +1,60 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `edge.objects@1.0.0` → {@link ObjectStore}.
|
|
2
|
+
* `edge.objects@1.0.0` → an R2-shaped bucket, and → {@link ObjectStore}.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* ## What the Host hands over, and what R2 hands over
|
|
5
|
+
*
|
|
6
|
+
* Takoserver's facade is method-for-method a bucket (`head`, `get`, `put`,
|
|
7
|
+
* `delete`, `list`, plus the four multipart calls), which is the whole point of
|
|
8
|
+
* the Interface: an app written against R2 is supposed to port over unchanged.
|
|
9
|
+
* The RESULT objects were not: the facade's `get()` answers with a plain record
|
|
10
|
+
* of `{etag, size, contentType?, body, partial, range?}`, while a native
|
|
11
|
+
* `R2ObjectBody` also carries `text()`, `json()`, `arrayBuffer()`, `blob()`,
|
|
12
|
+
* `key`, `httpEtag`, `uploaded`, `httpMetadata` and `writeHttpMetadata()`. So
|
|
13
|
+
* the facade WAS distinguishable from R2 — by exactly the members an app is
|
|
14
|
+
* most likely to reach for. `await (await env.MEDIA.get(k)).text()`, which is
|
|
15
|
+
* legal R2, threw `o.text is not a function` on the portable lane.
|
|
16
|
+
*
|
|
17
|
+
* The wire contract is Takoserver's (ADR 0005) and does not move. This module
|
|
18
|
+
* closes the gap on THIS side: {@link EdgeObjectsBucket} wraps the binding and
|
|
19
|
+
* returns objects that carry R2's members, so R2-shaped app code compiles and
|
|
20
|
+
* runs against either host.
|
|
21
|
+
*
|
|
22
|
+
* ## The parity rule
|
|
23
|
+
*
|
|
24
|
+
* PROVIDED, with R2's names and R2's semantics: `key`, `size`, `etag`,
|
|
25
|
+
* `httpEtag`, `httpMetadata`, `customMetadata`, `range`, `writeHttpMetadata()`,
|
|
26
|
+
* and on a body answer `body`, `bodyUsed`, `arrayBuffer()`, `text()`, `json()`,
|
|
27
|
+
* `blob()`. The four body helpers and `bodyUsed` are a real `Response`'s, so a
|
|
28
|
+
* second read REJECTS with a `TypeError` exactly as R2's do rather than
|
|
29
|
+
* replaying a cached value, and reading `body` directly also marks the object
|
|
30
|
+
* used. `blob()` answers with the bytes and no `type`; the stored content type
|
|
31
|
+
* is read from `httpMetadata` / `writeHttpMetadata()`, which is where R2 keeps
|
|
32
|
+
* it too.
|
|
33
|
+
*
|
|
34
|
+
* BEST EFFORT: `uploaded`. The Host's `head` and `list` carry
|
|
35
|
+
* `uploadedAtMillis`, so it is a `Date` there. Its `get` and `put` do NOT —
|
|
36
|
+
* both Takoserver wrapper backends build their answer without it on purpose —
|
|
37
|
+
* so it is `undefined` there rather than invented. It is `Date | undefined`
|
|
38
|
+
* everywhere so one type describes all four.
|
|
39
|
+
*
|
|
40
|
+
* NOT PROVIDED, because the wire carries nothing to derive them from:
|
|
41
|
+
* `version`, `checksums`, `storageClass`. `customMetadata` is present and
|
|
42
|
+
* always `undefined`: ADR 0005 gives `edge.objects` no custom metadata at all,
|
|
43
|
+
* so "absent" is the true answer rather than a missing member.
|
|
44
|
+
*
|
|
45
|
+
* `etag` is the Host's etag VERBATIM and is opaque: the self-host wrapper sends
|
|
46
|
+
* a bare hex digest (R2's unquoted `etag` spelling) and the managed wrapper
|
|
47
|
+
* forwards R2's quoted `httpEtag`. This facade does not rewrite it, because
|
|
48
|
+
* that value is what every conditional request on either host must echo back.
|
|
49
|
+
* `httpEtag` is derived: the same value, quoted when it was not already, which
|
|
50
|
+
* is the header-safe spelling R2 guarantees.
|
|
51
|
+
*
|
|
52
|
+
* ## The narrow spots of the facade itself, which parity does not widen
|
|
6
53
|
*
|
|
7
54
|
* - NO CUSTOM METADATA. Only `contentType` survives a round trip, which is
|
|
8
55
|
* also all the provider-neutral {@link ObjectStorePutOptions} carries.
|
|
9
|
-
* - FIXED ARITIES. The Host counts `arguments.length`, so
|
|
10
|
-
*
|
|
11
|
-
* absent.
|
|
56
|
+
* - FIXED ARITIES. The Host counts `arguments.length`, so every call passes
|
|
57
|
+
* its full argument list even when the options slot is absent.
|
|
12
58
|
* - A STREAMING `put` NEEDS `contentLength`. ADR 0005 is explicit that a Host
|
|
13
59
|
* enforces the declared count while streaming and never buffers a body to
|
|
14
60
|
* discover its size. Every body shape but a bare `ReadableStream` already
|
|
@@ -16,10 +62,12 @@
|
|
|
16
62
|
* `ArrayBuffer`, a string — so the length is declared and the bytes stream
|
|
17
63
|
* through. A stream that arrives without a knowable length is buffered
|
|
18
64
|
* HERE, in the Worker, which is the honest cost of not knowing the size.
|
|
19
|
-
* - `delete` TAKES ONE KEY. The
|
|
20
|
-
* which is not atomic — the same as R2's, which also has no
|
|
21
|
-
*
|
|
22
|
-
*
|
|
65
|
+
* - `delete` TAKES ONE KEY. The bucket's array form becomes a sequence of
|
|
66
|
+
* calls, which is not atomic — the same as R2's, which also has no
|
|
67
|
+
* transaction.
|
|
68
|
+
* - AN UNRANGED `get` MUST NOT BE PARTIAL. A truncated body served as a whole
|
|
69
|
+
* object is a silent corruption, so the bytes are dropped and the call
|
|
70
|
+
* throws.
|
|
23
71
|
*
|
|
24
72
|
* AVAILABILITY: BOTH wrapper backends project `edge.objects`. The managed
|
|
25
73
|
* Cloudflare backend does it over provider-private R2
|
|
@@ -37,7 +85,11 @@ import type {
|
|
|
37
85
|
ObjectStoreObject,
|
|
38
86
|
ObjectStorePutOptions,
|
|
39
87
|
} from "./types.ts";
|
|
40
|
-
import type {
|
|
88
|
+
import type {
|
|
89
|
+
EdgeObjectBody,
|
|
90
|
+
EdgeObjectMetadata,
|
|
91
|
+
EdgeObjectsBinding,
|
|
92
|
+
} from "./edge-facades.ts";
|
|
41
93
|
import { readStream } from "./shared.ts";
|
|
42
94
|
|
|
43
95
|
/** A request or response the facade cannot express. */
|
|
@@ -48,6 +100,198 @@ export class EdgeObjectsShapeError extends TypeError {
|
|
|
48
100
|
}
|
|
49
101
|
}
|
|
50
102
|
|
|
103
|
+
/**
|
|
104
|
+
* R2's `R2HTTPMetadata`, restricted to the one field `edge.objects` carries.
|
|
105
|
+
*
|
|
106
|
+
* The other five R2 fields (`contentLanguage`, `contentDisposition`,
|
|
107
|
+
* `contentEncoding`, `cacheControl`, `cacheExpiry`) are absent on every answer
|
|
108
|
+
* because the Interface never accepted them on `put`.
|
|
109
|
+
*/
|
|
110
|
+
export interface EdgeObjectHttpMetadata {
|
|
111
|
+
readonly contentType?: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** A byte range the Host actually served, in R2's `R2Range` spelling. */
|
|
115
|
+
export interface EdgeObjectRange {
|
|
116
|
+
readonly offset: number;
|
|
117
|
+
readonly length: number;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* R2's `R2Object` over `edge.objects@1.0.0`: what `head`, `put` and a `list`
|
|
122
|
+
* entry answer with.
|
|
123
|
+
*/
|
|
124
|
+
export interface EdgeR2Object {
|
|
125
|
+
readonly key: string;
|
|
126
|
+
readonly size: number;
|
|
127
|
+
/** The Host's etag verbatim. Opaque; quoting differs by backend. */
|
|
128
|
+
readonly etag: string;
|
|
129
|
+
/** The same etag in R2's header-safe quoted spelling. */
|
|
130
|
+
readonly httpEtag: string;
|
|
131
|
+
/** A `Date` on `head` and `list`; `undefined` on `get` and `put`. */
|
|
132
|
+
readonly uploaded: Date | undefined;
|
|
133
|
+
readonly httpMetadata: EdgeObjectHttpMetadata;
|
|
134
|
+
/** Always `undefined`: `edge.objects` has no custom metadata (ADR 0005). */
|
|
135
|
+
readonly customMetadata: undefined;
|
|
136
|
+
/** Present only on a ranged `get`. */
|
|
137
|
+
readonly range?: EdgeObjectRange;
|
|
138
|
+
/** Writes the metadata this object carries onto response headers. */
|
|
139
|
+
writeHttpMetadata(headers: Headers): void;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** R2's `R2ObjectBody`: an {@link EdgeR2Object} whose bytes came with it. */
|
|
143
|
+
export interface EdgeR2ObjectBody extends EdgeR2Object {
|
|
144
|
+
readonly body: ReadableStream<Uint8Array>;
|
|
145
|
+
readonly bodyUsed: boolean;
|
|
146
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
147
|
+
text(): Promise<string>;
|
|
148
|
+
json<T = unknown>(): Promise<T>;
|
|
149
|
+
blob(): Promise<Blob>;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** R2's `R2Objects`: one page of {@link EdgeObjectsBucket.list}. */
|
|
153
|
+
export interface EdgeR2Objects {
|
|
154
|
+
readonly objects: readonly EdgeR2Object[];
|
|
155
|
+
readonly truncated: boolean;
|
|
156
|
+
readonly cursor?: string;
|
|
157
|
+
/** R2's name for the common prefixes a `delimiter` collapsed. */
|
|
158
|
+
readonly delimitedPrefixes: readonly string[];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface EdgeObjectsGetOptions {
|
|
162
|
+
readonly range?: { readonly offset: number; readonly length?: number };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export interface EdgeObjectsListOptions {
|
|
166
|
+
readonly prefix?: string;
|
|
167
|
+
readonly delimiter?: string;
|
|
168
|
+
readonly cursor?: string;
|
|
169
|
+
readonly limit?: number;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** R2 quotes its `httpEtag`; the Host's etag may or may not already be quoted. */
|
|
173
|
+
function httpEtagOf(etag: string): string {
|
|
174
|
+
if (etag.length >= 2 && etag.startsWith('"') && etag.endsWith('"')) {
|
|
175
|
+
return etag;
|
|
176
|
+
}
|
|
177
|
+
return `"${etag}"`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function writeContentType(
|
|
181
|
+
contentType: string | undefined,
|
|
182
|
+
headers: Headers,
|
|
183
|
+
): void {
|
|
184
|
+
// R2 writes only the fields its `httpMetadata` actually holds, so an object
|
|
185
|
+
// stored without a content type leaves the caller's headers alone.
|
|
186
|
+
if (contentType !== undefined) headers.set("content-type", contentType);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* R2's `R2Object`. Metadata only: `head`, `put` and every `list` entry.
|
|
191
|
+
*/
|
|
192
|
+
class EdgeR2ObjectMetadata implements EdgeR2Object {
|
|
193
|
+
readonly key: string;
|
|
194
|
+
readonly size: number;
|
|
195
|
+
readonly etag: string;
|
|
196
|
+
readonly httpEtag: string;
|
|
197
|
+
readonly uploaded: Date | undefined;
|
|
198
|
+
readonly httpMetadata: EdgeObjectHttpMetadata;
|
|
199
|
+
readonly customMetadata: undefined = undefined;
|
|
200
|
+
readonly range?: EdgeObjectRange;
|
|
201
|
+
|
|
202
|
+
constructor(
|
|
203
|
+
key: string,
|
|
204
|
+
metadata: {
|
|
205
|
+
readonly etag: string;
|
|
206
|
+
readonly size: number;
|
|
207
|
+
readonly contentType?: string;
|
|
208
|
+
readonly uploadedAtMillis?: number;
|
|
209
|
+
},
|
|
210
|
+
range?: EdgeObjectRange,
|
|
211
|
+
) {
|
|
212
|
+
this.key = key;
|
|
213
|
+
this.size = metadata.size;
|
|
214
|
+
this.etag = metadata.etag;
|
|
215
|
+
this.httpEtag = httpEtagOf(metadata.etag);
|
|
216
|
+
this.uploaded =
|
|
217
|
+
metadata.uploadedAtMillis === undefined
|
|
218
|
+
? undefined
|
|
219
|
+
: new Date(metadata.uploadedAtMillis);
|
|
220
|
+
this.httpMetadata =
|
|
221
|
+
metadata.contentType === undefined
|
|
222
|
+
? {}
|
|
223
|
+
: { contentType: metadata.contentType };
|
|
224
|
+
if (range !== undefined) this.range = range;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
writeHttpMetadata(headers: Headers): void {
|
|
228
|
+
writeContentType(this.httpMetadata.contentType, headers);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* R2's `R2ObjectBody`.
|
|
234
|
+
*
|
|
235
|
+
* The bytes are held in a `Response`, which is where the body semantics come
|
|
236
|
+
* from rather than being re-implemented: `bodyUsed` flips the moment the stream
|
|
237
|
+
* is disturbed — including by a caller that read `body` itself — and a second
|
|
238
|
+
* `text()` / `json()` / `arrayBuffer()` / `blob()` REJECTS with a `TypeError`
|
|
239
|
+
* instead of replaying the first read. That is R2's own behaviour (workerd's
|
|
240
|
+
* `R2ObjectBody` refuses a disturbed body the same way the `Body` mixin does),
|
|
241
|
+
* so a caller cannot tell the two apart by consuming twice.
|
|
242
|
+
*/
|
|
243
|
+
class EdgeR2ObjectWithBody
|
|
244
|
+
extends EdgeR2ObjectMetadata
|
|
245
|
+
implements EdgeR2ObjectBody
|
|
246
|
+
{
|
|
247
|
+
readonly #response: Response;
|
|
248
|
+
|
|
249
|
+
constructor(
|
|
250
|
+
key: string,
|
|
251
|
+
metadata: {
|
|
252
|
+
readonly etag: string;
|
|
253
|
+
readonly size: number;
|
|
254
|
+
readonly contentType?: string;
|
|
255
|
+
readonly uploadedAtMillis?: number;
|
|
256
|
+
},
|
|
257
|
+
body: ReadableStream<Uint8Array>,
|
|
258
|
+
range?: EdgeObjectRange,
|
|
259
|
+
) {
|
|
260
|
+
super(key, metadata, range);
|
|
261
|
+
// Bytes only, with no content type attached: a `Response` normalises the
|
|
262
|
+
// header it is given (appending `;charset=utf-8` to a text type, for one),
|
|
263
|
+
// and that normalisation would show up on `blob().type` as a value the Host
|
|
264
|
+
// never stored. The stored content type is read where R2 puts it —
|
|
265
|
+
// `httpMetadata` and `writeHttpMetadata()`.
|
|
266
|
+
this.#response = new Response(body as unknown as BodyInit);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
get body(): ReadableStream<Uint8Array> {
|
|
270
|
+
// A `Response` built from a stream always has one.
|
|
271
|
+
return this.#response.body as ReadableStream<Uint8Array>;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
get bodyUsed(): boolean {
|
|
275
|
+
return this.#response.bodyUsed;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
arrayBuffer(): Promise<ArrayBuffer> {
|
|
279
|
+
return this.#response.arrayBuffer();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
text(): Promise<string> {
|
|
283
|
+
return this.#response.text();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
json<T = unknown>(): Promise<T> {
|
|
287
|
+
return this.#response.json() as Promise<T>;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
blob(): Promise<Blob> {
|
|
291
|
+
return this.#response.blob();
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
51
295
|
/**
|
|
52
296
|
* The byte length of a body the Host can be told up front, or `undefined` for
|
|
53
297
|
* a bare stream whose size only the producer knows.
|
|
@@ -61,14 +305,57 @@ function knownBodyLength(value: ObjectStoreBody): number | undefined {
|
|
|
61
305
|
return undefined;
|
|
62
306
|
}
|
|
63
307
|
|
|
64
|
-
|
|
65
|
-
|
|
308
|
+
/**
|
|
309
|
+
* `edge.objects@1.0.0` as a bucket whose answers are R2's.
|
|
310
|
+
*
|
|
311
|
+
* The calls are the facade's (its option names, its ceilings, its error
|
|
312
|
+
* vocabulary); the results are R2-shaped, so app code written against
|
|
313
|
+
* `R2Bucket` reads them unchanged. See the parity rule at the top of this file
|
|
314
|
+
* for what is provided, what is best effort, and what the wire cannot supply.
|
|
315
|
+
*/
|
|
316
|
+
export class EdgeObjectsBucket {
|
|
317
|
+
constructor(private readonly binding: EdgeObjectsBinding) {}
|
|
318
|
+
|
|
319
|
+
async head(key: string): Promise<EdgeR2Object | null> {
|
|
320
|
+
const found: EdgeObjectMetadata | null = await this.binding.head(key);
|
|
321
|
+
if (!found) return null;
|
|
322
|
+
return new EdgeR2ObjectMetadata(key, found);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async get(
|
|
326
|
+
key: string,
|
|
327
|
+
options?: EdgeObjectsGetOptions,
|
|
328
|
+
): Promise<EdgeR2ObjectBody | null> {
|
|
329
|
+
const range = options?.range;
|
|
330
|
+
// Fixed arity: the Host counts `arguments.length`, so the options slot is
|
|
331
|
+
// always passed, even when it is empty.
|
|
332
|
+
const found: EdgeObjectBody | null = await this.binding.get(
|
|
333
|
+
key,
|
|
334
|
+
range === undefined ? undefined : { range },
|
|
335
|
+
);
|
|
336
|
+
if (!found) return null;
|
|
337
|
+
if (found.partial && range === undefined) {
|
|
338
|
+
// No range was asked for, so a partial body would be a truncated object
|
|
339
|
+
// served as if it were whole. Refuse rather than hand the caller bytes
|
|
340
|
+
// that do not add up to the object.
|
|
341
|
+
await found.body.cancel().catch(() => undefined);
|
|
342
|
+
throw new EdgeObjectsShapeError(
|
|
343
|
+
"edge.objects: the Host returned a partial body for an unranged get",
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
return new EdgeR2ObjectWithBody(
|
|
347
|
+
key,
|
|
348
|
+
found,
|
|
349
|
+
found.body as ReadableStream<Uint8Array>,
|
|
350
|
+
found.range,
|
|
351
|
+
);
|
|
352
|
+
}
|
|
66
353
|
|
|
67
354
|
async put(
|
|
68
355
|
key: string,
|
|
69
356
|
value: ObjectStoreBody,
|
|
70
357
|
options?: ObjectStorePutOptions,
|
|
71
|
-
): Promise<
|
|
358
|
+
): Promise<EdgeR2Object> {
|
|
72
359
|
const contentType = options?.contentType;
|
|
73
360
|
let contentLength = knownBodyLength(value);
|
|
74
361
|
// The facade's body slot has no `Blob`. A Blob's stream carries the same
|
|
@@ -84,41 +371,89 @@ export class EdgeObjectStorage implements ObjectStore {
|
|
|
84
371
|
body = buffered;
|
|
85
372
|
contentLength = buffered.byteLength;
|
|
86
373
|
}
|
|
87
|
-
await this.
|
|
374
|
+
const stored = await this.binding.put(key, body, {
|
|
88
375
|
contentLength,
|
|
89
376
|
...(contentType === undefined ? {} : { contentType }),
|
|
90
377
|
});
|
|
378
|
+
// The Host's `put` answers with `{etag, size}` and nothing else, so the
|
|
379
|
+
// returned object's `uploaded` is absent — see the parity rule above.
|
|
380
|
+
return new EdgeR2ObjectMetadata(key, {
|
|
381
|
+
etag: stored.etag,
|
|
382
|
+
size: stored.size,
|
|
383
|
+
...(contentType === undefined ? {} : { contentType }),
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async delete(key: string | readonly string[]): Promise<void> {
|
|
388
|
+
const keys = typeof key === "string" ? [key] : [...new Set(key)];
|
|
389
|
+
for (const one of keys) await this.binding.delete(one);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async list(options?: EdgeObjectsListOptions): Promise<EdgeR2Objects> {
|
|
393
|
+
const page = await this.binding.list(options);
|
|
394
|
+
return {
|
|
395
|
+
objects: page.objects.map(
|
|
396
|
+
(entry) => new EdgeR2ObjectMetadata(entry.key, entry),
|
|
397
|
+
),
|
|
398
|
+
truncated: page.truncated,
|
|
399
|
+
...(page.cursor === undefined ? {} : { cursor: page.cursor }),
|
|
400
|
+
// R2 calls the common prefixes a delimiter collapsed `delimitedPrefixes`.
|
|
401
|
+
delimitedPrefixes: page.prefixes,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* The provider-neutral {@link ObjectStore} over the same bucket.
|
|
408
|
+
*
|
|
409
|
+
* This is the port the core's own routes speak, and it stays deliberately
|
|
410
|
+
* narrower than R2 — flat metadata, no enumeration, no separate head — so app
|
|
411
|
+
* code does not grow a dependency on a vendor object shape. Code that WANTS R2
|
|
412
|
+
* takes {@link EdgeObjectsBucket} instead; both run the same adapter, so the
|
|
413
|
+
* media path proves it.
|
|
414
|
+
*/
|
|
415
|
+
export class EdgeObjectStorage implements ObjectStore {
|
|
416
|
+
readonly #bucket: EdgeObjectsBucket;
|
|
417
|
+
|
|
418
|
+
constructor(bucket: EdgeObjectsBinding) {
|
|
419
|
+
this.#bucket = new EdgeObjectsBucket(bucket);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async put(
|
|
423
|
+
key: string,
|
|
424
|
+
value: ObjectStoreBody,
|
|
425
|
+
options?: ObjectStorePutOptions,
|
|
426
|
+
): Promise<void> {
|
|
427
|
+
await this.#bucket.put(key, value, options);
|
|
91
428
|
}
|
|
92
429
|
|
|
93
430
|
async get(key: string): Promise<ObjectStoreObject | null> {
|
|
94
|
-
const found = await this
|
|
431
|
+
const found = await this.#bucket.get(key);
|
|
95
432
|
if (!found) return null;
|
|
96
|
-
if (found.partial) {
|
|
97
|
-
// No range was asked for, so a partial body would be a truncated object
|
|
98
|
-
// served as if it were whole. Refuse rather than hand the caller bytes
|
|
99
|
-
// that do not add up to the object.
|
|
100
|
-
await found.body.cancel().catch(() => undefined);
|
|
101
|
-
throw new EdgeObjectsShapeError(
|
|
102
|
-
"edge.objects: the Host returned a partial body for an unranged get",
|
|
103
|
-
);
|
|
104
|
-
}
|
|
105
433
|
return {
|
|
106
|
-
key,
|
|
107
|
-
body: found.body
|
|
108
|
-
...(found.contentType === undefined
|
|
434
|
+
key: found.key,
|
|
435
|
+
body: found.body,
|
|
436
|
+
...(found.httpMetadata.contentType === undefined
|
|
109
437
|
? {}
|
|
110
|
-
: { contentType: found.contentType }),
|
|
438
|
+
: { contentType: found.httpMetadata.contentType }),
|
|
111
439
|
etag: found.etag,
|
|
112
440
|
byteLength: found.size,
|
|
113
441
|
};
|
|
114
442
|
}
|
|
115
443
|
|
|
116
444
|
async delete(key: string | readonly string[]): Promise<void> {
|
|
117
|
-
|
|
118
|
-
for (const one of keys) await this.bucket.delete(one);
|
|
445
|
+
await this.#bucket.delete(key);
|
|
119
446
|
}
|
|
120
447
|
}
|
|
121
448
|
|
|
449
|
+
/** Wrap an `edge.objects@1.0.0` binding as an R2-shaped bucket. */
|
|
450
|
+
export function wrapEdgeObjectsAsBucket(
|
|
451
|
+
bucket: EdgeObjectsBinding,
|
|
452
|
+
): EdgeObjectsBucket {
|
|
453
|
+
return new EdgeObjectsBucket(bucket);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Wrap an `edge.objects@1.0.0` binding as the provider-neutral port. */
|
|
122
457
|
export function wrapEdgeObjects(bucket: EdgeObjectsBinding): ObjectStore {
|
|
123
458
|
return new EdgeObjectStorage(bucket);
|
|
124
459
|
}
|
|
@@ -8,15 +8,20 @@
|
|
|
8
8
|
* are all `${APP_URL}/…`, and a wrong one is not a broken page — it is a
|
|
9
9
|
* permanent, federated wrong answer that remote servers have already cached.
|
|
10
10
|
*
|
|
11
|
-
* `APP_URL` is a plain variable, and on a
|
|
12
|
-
* A Takoform `WorkerEndpoint` allocates the Worker's public
|
|
13
|
-
* `WorkerVersion` that would have carried the variable is
|
|
14
|
-
* the deployer does not know the value at apply time and
|
|
15
|
-
* apply that could inject it. The origin exists, but only
|
|
16
|
-
* and the only place it is ever spoken is on the requests
|
|
11
|
+
* `APP_URL` is a plain variable, and on a Host-assigned endpoint it cannot
|
|
12
|
+
* always be one. A Takoform `WorkerEndpoint` allocates the Worker's public
|
|
13
|
+
* origin AFTER the `WorkerVersion` that would have carried the variable is
|
|
14
|
+
* already immutable, so the deployer does not know the value at apply time and
|
|
15
|
+
* there is no second apply that could inject it. The origin exists, but only
|
|
16
|
+
* the Host knows it, and the only place it is ever spoken is on the requests
|
|
17
|
+
* the Host routes here.
|
|
17
18
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
19
|
+
* This has nothing to do with the runtime lane. The lane names the SHAPE OF THE
|
|
20
|
+
* BINDINGS; a Takoform-hosted Worker on the production Takoserver runs on raw
|
|
21
|
+
* Cloudflare bindings — the `cloudflare` lane — and is in exactly the same
|
|
22
|
+
* position, because the Takoform module passes no `APP_URL` there either. So on
|
|
23
|
+
* EVERY lane an unset `APP_URL` is answered by OBSERVING one request and
|
|
24
|
+
* PINNING what it observed:
|
|
20
25
|
*
|
|
21
26
|
* 1. `APP_URL` is authoritative whenever it is set. It is used exactly as the
|
|
22
27
|
* operator wrote it and is never validated, cached, or persisted here —
|
|
@@ -32,27 +37,38 @@
|
|
|
32
37
|
*
|
|
33
38
|
* WHAT IS TRUSTED. The request URL as the runtime delivers it, and only that.
|
|
34
39
|
* Not `X-Forwarded-Host`, not `X-Forwarded-Proto`, not `Host` read out of the
|
|
35
|
-
* headers — nothing a client can write.
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* the
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
40
|
+
* headers — nothing a client can write. That URL is the endpoint the request
|
|
41
|
+
* genuinely arrived on, under every host this bundle runs on. Cloudflare
|
|
42
|
+
* Workers build `request.url` from the connection the edge terminated. Both
|
|
43
|
+
* wrapper hosts route by hostname and hand over the request they received on
|
|
44
|
+
* the Worker's own public endpoint: Takoserver's managed Workers-for-Platforms
|
|
45
|
+
* gateway looks up a host route for `new URL(request.url).hostname` and
|
|
46
|
+
* dispatches the SAME `Request` object, and the self-host workerd router picks
|
|
47
|
+
* a service from a table keyed by hostname and forwards unchanged. A hostname
|
|
48
|
+
* nobody published for this Worker is a 404 before any of this code runs, so
|
|
49
|
+
* the origin on the request is one the Host assigned — which is exactly the
|
|
50
|
+
* value that could not be delivered as a var.
|
|
43
51
|
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* means
|
|
47
|
-
*
|
|
48
|
-
* is
|
|
52
|
+
* WHAT THIS DOES NOT DECIDE. A Worker published straight to Cloudflare by an
|
|
53
|
+
* operator who holds several hostnames answers on all of them, and first writer
|
|
54
|
+
* wins means the first one to arrive names the instance. That operator knows
|
|
55
|
+
* their hostnames and sets `APP_URL`, which always wins and is never pinned.
|
|
56
|
+
* Inference is the answer for the deployment that CANNOT be told its origin,
|
|
57
|
+
* not a preference over being told.
|
|
49
58
|
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* an
|
|
54
|
-
*
|
|
55
|
-
*
|
|
59
|
+
* WHY HTTPS. A public fediverse origin is https; Cloudflare terminates TLS in
|
|
60
|
+
* front of a Worker on a public hostname, and Takoserver's own `WorkerEndpoint`
|
|
61
|
+
* can only ever assign an https origin. Requiring it here means an http request
|
|
62
|
+
* cannot pin an origin that would then sign deliveries and mint actor ids.
|
|
63
|
+
* Loopback http is the one exception, because `localhost` is not routable and
|
|
64
|
+
* is the origin a developer actually serves on — `wrangler dev` included.
|
|
65
|
+
*
|
|
66
|
+
* A host that terminates TLS in FRONT of the runtime and speaks plain http to
|
|
67
|
+
* it therefore establishes nothing: `request.url` is `http://…` on a routable
|
|
68
|
+
* name and the derivation refuses. That deployment must set `APP_URL`, which it
|
|
69
|
+
* can, because an operator who terminates TLS chose the hostname themselves.
|
|
70
|
+
* Refusing is the point — the alternative is trusting a forwarded-proto header
|
|
71
|
+
* that the same proxy may or may not be the only writer of.
|
|
56
72
|
*/
|
|
57
73
|
|
|
58
74
|
import type { Env, EnvVars } from "../types.ts";
|