@takosjp/yurucommu-core 4.1.3 → 4.1.5
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 +1 -1
- package/packages/api/package.json +1 -1
- package/src/backend/lib/conditional-request.ts +87 -0
- package/src/backend/middleware/cache.ts +6 -2
- package/src/backend/public.ts +9 -0
- package/src/backend/routes/apps.ts +5 -2
- package/src/backend/routes/media.ts +30 -3
- package/src/backend/runtime/cloudflare.ts +7 -0
- package/src/backend/runtime/edge-objects.ts +370 -32
- package/src/backend/runtime/managed-runtime.ts +11 -0
- package/src/backend/runtime/s3-fetch.ts +5 -1
- package/src/backend/runtime/shared.ts +22 -0
- package/src/backend/runtime/types.ts +12 -0
package/package.json
CHANGED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `If-None-Match` evaluation (RFC 9110 §8.8.3, §13.1.2).
|
|
3
|
+
*
|
|
4
|
+
* Two rules from the RFC are easy to get wrong and are both pinned here.
|
|
5
|
+
*
|
|
6
|
+
* The FIELD IS A LIST, and a comma is not a separator it can be split on.
|
|
7
|
+
* `etagc` admits every VCHAR except DQUOTE, so `","` is a legal opaque-tag and
|
|
8
|
+
* `header.split(",")` would tear one in half. The scan below reads a tag at a
|
|
9
|
+
* time — an optional `W/`, an opening DQUOTE, then everything up to the next
|
|
10
|
+
* DQUOTE, which is unambiguous precisely because the tag body cannot contain
|
|
11
|
+
* one.
|
|
12
|
+
*
|
|
13
|
+
* The COMPARISON IS WEAK. §13.1.2 evaluates `If-None-Match` with the weak
|
|
14
|
+
* comparison function, so the weakness marker is ignored on both sides and only
|
|
15
|
+
* the quoted opaque-tags are compared, character for character. (The strong
|
|
16
|
+
* function is for `If-Match` and `If-Range`, where a weak validator may not be
|
|
17
|
+
* used to reassemble a representation.)
|
|
18
|
+
*
|
|
19
|
+
* A field value the grammar does not admit yields no tags at all and therefore
|
|
20
|
+
* no match: §13.1 says to ignore a condition that cannot be evaluated, which
|
|
21
|
+
* for a cache validator means serving the full representation.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Would the client's cached copy still be valid — i.e. should the caller answer
|
|
26
|
+
* `304 Not Modified` instead of the representation?
|
|
27
|
+
*
|
|
28
|
+
* `httpEtag` must be the SAME value the response emits in `ETag`: the quoted,
|
|
29
|
+
* header-safe spelling. A bare backend etag never matches anything a client
|
|
30
|
+
* echoes back, which is the whole reason this takes the header form.
|
|
31
|
+
*
|
|
32
|
+
* Call this only once the requested representation is known to exist and the
|
|
33
|
+
* requester is known to be allowed to read it. `*` matches whenever there is a
|
|
34
|
+
* current representation, and answering 304 to someone who may not read the
|
|
35
|
+
* object would leak its existence.
|
|
36
|
+
*/
|
|
37
|
+
export function ifNoneMatchIsFresh(
|
|
38
|
+
header: string | undefined | null,
|
|
39
|
+
httpEtag: string | undefined,
|
|
40
|
+
): boolean {
|
|
41
|
+
if (!header || !httpEtag) return false;
|
|
42
|
+
const field = header.trim();
|
|
43
|
+
// `If-None-Match = "*" / 1#entity-tag`: the wildcard is the WHOLE field, not
|
|
44
|
+
// a member of the list, and it matches because a representation exists.
|
|
45
|
+
if (field === "*") return true;
|
|
46
|
+
const target = opaqueTagOf(httpEtag);
|
|
47
|
+
if (target === null) return false;
|
|
48
|
+
return entityTags(field).includes(target);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** One entity-tag reduced to its quoted opaque-tag, or `null` if malformed. */
|
|
52
|
+
function opaqueTagOf(tag: string): string | null {
|
|
53
|
+
const bare = tag.startsWith("W/") ? tag.slice(2) : tag;
|
|
54
|
+
if (bare.length < 2 || !bare.startsWith('"') || !bare.endsWith('"')) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
return bare;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Every entity-tag in a list field value, or none at all if it is malformed. */
|
|
61
|
+
function entityTags(field: string): string[] {
|
|
62
|
+
const tags: string[] = [];
|
|
63
|
+
let index = 0;
|
|
64
|
+
while (index < field.length) {
|
|
65
|
+
const char = field[index];
|
|
66
|
+
if (char === "," || char === " " || char === "\t") {
|
|
67
|
+
index += 1;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
// The weakness marker is consumed and discarded: the weak comparison
|
|
71
|
+
// function does not distinguish `W/"x"` from `"x"`.
|
|
72
|
+
if (field.startsWith("W/", index)) index += 2;
|
|
73
|
+
if (field[index] !== '"') return [];
|
|
74
|
+
const end = field.indexOf('"', index + 1);
|
|
75
|
+
if (end === -1) return [];
|
|
76
|
+
tags.push(field.slice(index, end + 1));
|
|
77
|
+
index = end + 1;
|
|
78
|
+
while (index < field.length) {
|
|
79
|
+
const next = field[index];
|
|
80
|
+
if (next !== " " && next !== "\t") break;
|
|
81
|
+
index += 1;
|
|
82
|
+
}
|
|
83
|
+
// Nothing but a comma may follow an entity-tag.
|
|
84
|
+
if (index < field.length && field[index] !== ",") return [];
|
|
85
|
+
}
|
|
86
|
+
return tags;
|
|
87
|
+
}
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import type { Context, MiddlewareHandler, Next } from "hono";
|
|
16
16
|
import type { Env, Variables } from "../types.ts";
|
|
17
17
|
import { logger } from "../lib/logger.ts";
|
|
18
|
+
import { ifNoneMatchIsFresh } from "../lib/conditional-request.ts";
|
|
18
19
|
import { bytesToHex } from "../lib/hex.ts";
|
|
19
20
|
|
|
20
21
|
const log = logger.child({ component: "middleware.cache" });
|
|
@@ -224,8 +225,11 @@ function isConditionalHit(
|
|
|
224
225
|
etag: string | null,
|
|
225
226
|
lastModified: string | null,
|
|
226
227
|
): boolean {
|
|
227
|
-
|
|
228
|
-
|
|
228
|
+
// One `If-None-Match` reading for the whole backend. String equality was not
|
|
229
|
+
// one: the field is a LIST, `W/"x"` is a match for `"x"` under §13.1.2's weak
|
|
230
|
+
// comparison, and `*` matches any existing representation. `generateETag`
|
|
231
|
+
// already emits the quoted form these are compared against.
|
|
232
|
+
if (ifNoneMatchIsFresh(c.req.header("If-None-Match"), etag ?? undefined)) {
|
|
229
233
|
return true;
|
|
230
234
|
}
|
|
231
235
|
|
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,
|
|
@@ -282,7 +282,10 @@ appsServeRoutes.get("/:clientId/:appName/*", async (c) => {
|
|
|
282
282
|
filePath.includes("/assets/")
|
|
283
283
|
? "public, max-age=31536000, immutable"
|
|
284
284
|
: "public, max-age=3600",
|
|
285
|
-
|
|
285
|
+
// The quoted entity-tag, never the port's verbatim `etag`: that one is a
|
|
286
|
+
// bare hex digest on the portable lane and is not a valid `ETag` field
|
|
287
|
+
// value (RFC 9110 §8.8.3).
|
|
288
|
+
object.httpEtag,
|
|
286
289
|
);
|
|
287
290
|
return new Response(object.body, { headers });
|
|
288
291
|
}
|
|
@@ -295,7 +298,7 @@ appsServeRoutes.get("/:clientId/:appName/*", async (c) => {
|
|
|
295
298
|
const headers = createHostedHeaders(
|
|
296
299
|
"text/html; charset=utf-8",
|
|
297
300
|
"no-cache",
|
|
298
|
-
indexObject.
|
|
301
|
+
indexObject.httpEtag,
|
|
299
302
|
);
|
|
300
303
|
return new Response(indexObject.body, { headers });
|
|
301
304
|
}
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from "../../db/index.ts";
|
|
12
12
|
import { generateId } from "../federation-helpers.ts";
|
|
13
13
|
import { canViewerReadObject } from "../lib/community-visibility.ts";
|
|
14
|
+
import { ifNoneMatchIsFresh } from "../lib/conditional-request.ts";
|
|
14
15
|
import { canViewerReadObjectFull } from "../lib/post-visibility.ts";
|
|
15
16
|
import { stripImageMetadata } from "../lib/strip-image-metadata.ts";
|
|
16
17
|
import { logger } from "../lib/logger.ts";
|
|
@@ -588,18 +589,44 @@ async function serveMediaByR2Key(c: MediaContext, r2Key: string) {
|
|
|
588
589
|
const maxAge = contentType.startsWith("video/")
|
|
589
590
|
? CACHE_MAX_AGE_VIDEO
|
|
590
591
|
: CACHE_MAX_AGE_IMAGE;
|
|
591
|
-
const
|
|
592
|
+
const cacheControl = `${cacheScope}, max-age=${maxAge}`;
|
|
593
|
+
// The HEADER form, not `object.etag`. An `ETag` field value is an
|
|
594
|
+
// entity-tag and an entity-tag's opaque-tag is always quoted (RFC 9110
|
|
595
|
+
// §8.8.3); the port's `etag` is the backend's verbatim spelling, which on
|
|
596
|
+
// the portable lane is a BARE hex digest. Serving that produced a field no
|
|
597
|
+
// cache could match and no client could echo back — media was effectively
|
|
598
|
+
// uncacheable on self-host. `httpEtag` is the quoted form on every lane.
|
|
599
|
+
const etag = object.httpEtag;
|
|
600
|
+
|
|
601
|
+
// §13.1.2 evaluates `If-None-Match` with the WEAK comparison function, so
|
|
602
|
+
// `W/"x"` from the client matches the `"x"` we sent; only the opaque-tags
|
|
603
|
+
// are compared. The validator on both sides of that comparison is the one
|
|
604
|
+
// we emitted, which is why it reads `httpEtag` and not `etag`. Reached only
|
|
605
|
+
// after the authorization gate above: `*` matches any representation that
|
|
606
|
+
// exists, so answering 304 earlier would disclose that a private object is
|
|
607
|
+
// there.
|
|
608
|
+
if (etag && ifNoneMatchIsFresh(c.req.header("If-None-Match"), etag)) {
|
|
609
|
+
// The port has no `head`, so the bytes were already fetched; a 304 must
|
|
610
|
+
// not carry them (§15.4.5), and dropping the stream unread would leak it.
|
|
611
|
+
await object.body?.cancel().catch(() => undefined);
|
|
612
|
+
// §15.4.5 also asks a 304 to carry the header fields a 200 would have
|
|
613
|
+
// sent that guide cache behaviour — here, the validator and the policy.
|
|
614
|
+
return c.body(null, 304, {
|
|
615
|
+
"Cache-Control": cacheControl,
|
|
616
|
+
ETag: etag,
|
|
617
|
+
});
|
|
618
|
+
}
|
|
592
619
|
|
|
593
620
|
if (!object.body) {
|
|
594
621
|
return c.body(null, 200, {
|
|
595
622
|
"Content-Type": contentType,
|
|
596
|
-
"Cache-Control":
|
|
623
|
+
"Cache-Control": cacheControl,
|
|
597
624
|
...(etag ? { ETag: etag } : {}),
|
|
598
625
|
});
|
|
599
626
|
}
|
|
600
627
|
return c.body(object.body, 200, {
|
|
601
628
|
"Content-Type": contentType,
|
|
602
|
-
"Cache-Control":
|
|
629
|
+
"Cache-Control": cacheControl,
|
|
603
630
|
...(etag ? { ETag: etag } : {}),
|
|
604
631
|
});
|
|
605
632
|
} catch (error) {
|
|
@@ -56,7 +56,14 @@ class CloudflareStorage implements ObjectStore {
|
|
|
56
56
|
key,
|
|
57
57
|
body: obj.body as unknown as ReadableStream,
|
|
58
58
|
contentType: obj.httpMetadata?.contentType,
|
|
59
|
+
// R2 spells the same validator twice: `etag` bare, `httpEtag` quoted.
|
|
60
|
+
// This adapter has always handed the quoted one over as the port's
|
|
61
|
+
// opaque `etag`, and that stays — narrowing a published field to R2's
|
|
62
|
+
// bare spelling would silently change what every existing reader sees.
|
|
63
|
+
// `httpEtag` names the header-safe form explicitly, which on this lane is
|
|
64
|
+
// the same string.
|
|
59
65
|
etag: obj.httpEtag,
|
|
66
|
+
httpEtag: obj.httpEtag,
|
|
60
67
|
byteLength: obj.size,
|
|
61
68
|
};
|
|
62
69
|
}
|
|
@@ -1,14 +1,62 @@
|
|
|
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()`, `bytes()`,
|
|
12
|
+
* `blob()`, `key`, `httpEtag`, `uploaded`, `httpMetadata` and
|
|
13
|
+
* `writeHttpMetadata()`. So the facade WAS distinguishable from R2 — by exactly
|
|
14
|
+
* the members an app is 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()`, `bytes()`, `text()`,
|
|
27
|
+
* `json()`, `blob()`. The five body helpers and `bodyUsed` are a real
|
|
28
|
+
* `Response`'s, so a second read REJECTS with a `TypeError` exactly as R2's do
|
|
29
|
+
* rather than replaying a cached value, and reading `body` directly also marks
|
|
30
|
+
* the object used. `blob()` answers with the bytes and no `type`; the stored
|
|
31
|
+
* content type is read from `httpMetadata` / `writeHttpMetadata()`, which is
|
|
32
|
+
* where R2 keeps 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: it is the
|
|
48
|
+
* Host's own identity for those bytes, and R2 does not rewrite its `etag`
|
|
49
|
+
* either. `httpEtag` is derived — the same value, quoted when it was not
|
|
50
|
+
* already — and THAT is the one a response emits, because a bare digest is not
|
|
51
|
+
* an entity-tag (RFC 9110 §8.8.3) and no cache can match one. The
|
|
52
|
+
* provider-neutral {@link ObjectStore} answer carries both for the same reason.
|
|
53
|
+
*
|
|
54
|
+
* ## The narrow spots of the facade itself, which parity does not widen
|
|
6
55
|
*
|
|
7
56
|
* - NO CUSTOM METADATA. Only `contentType` survives a round trip, which is
|
|
8
57
|
* also all the provider-neutral {@link ObjectStorePutOptions} carries.
|
|
9
|
-
* - FIXED ARITIES. The Host counts `arguments.length`, so
|
|
10
|
-
*
|
|
11
|
-
* absent.
|
|
58
|
+
* - FIXED ARITIES. The Host counts `arguments.length`, so every call passes
|
|
59
|
+
* its full argument list even when the options slot is absent.
|
|
12
60
|
* - A STREAMING `put` NEEDS `contentLength`. ADR 0005 is explicit that a Host
|
|
13
61
|
* enforces the declared count while streaming and never buffers a body to
|
|
14
62
|
* discover its size. Every body shape but a bare `ReadableStream` already
|
|
@@ -16,10 +64,12 @@
|
|
|
16
64
|
* `ArrayBuffer`, a string — so the length is declared and the bytes stream
|
|
17
65
|
* through. A stream that arrives without a knowable length is buffered
|
|
18
66
|
* 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
|
-
*
|
|
67
|
+
* - `delete` TAKES ONE KEY. The bucket's array form becomes a sequence of
|
|
68
|
+
* calls, which is not atomic — the same as R2's, which also has no
|
|
69
|
+
* transaction.
|
|
70
|
+
* - AN UNRANGED `get` MUST NOT BE PARTIAL. A truncated body served as a whole
|
|
71
|
+
* object is a silent corruption, so the bytes are dropped and the call
|
|
72
|
+
* throws.
|
|
23
73
|
*
|
|
24
74
|
* AVAILABILITY: BOTH wrapper backends project `edge.objects`. The managed
|
|
25
75
|
* Cloudflare backend does it over provider-private R2
|
|
@@ -37,8 +87,12 @@ import type {
|
|
|
37
87
|
ObjectStoreObject,
|
|
38
88
|
ObjectStorePutOptions,
|
|
39
89
|
} from "./types.ts";
|
|
40
|
-
import type {
|
|
41
|
-
|
|
90
|
+
import type {
|
|
91
|
+
EdgeObjectBody,
|
|
92
|
+
EdgeObjectMetadata,
|
|
93
|
+
EdgeObjectsBinding,
|
|
94
|
+
} from "./edge-facades.ts";
|
|
95
|
+
import { httpEtagOf, readStream } from "./shared.ts";
|
|
42
96
|
|
|
43
97
|
/** A request or response the facade cannot express. */
|
|
44
98
|
export class EdgeObjectsShapeError extends TypeError {
|
|
@@ -48,6 +102,198 @@ export class EdgeObjectsShapeError extends TypeError {
|
|
|
48
102
|
}
|
|
49
103
|
}
|
|
50
104
|
|
|
105
|
+
/**
|
|
106
|
+
* R2's `R2HTTPMetadata`, restricted to the one field `edge.objects` carries.
|
|
107
|
+
*
|
|
108
|
+
* The other five R2 fields (`contentLanguage`, `contentDisposition`,
|
|
109
|
+
* `contentEncoding`, `cacheControl`, `cacheExpiry`) are absent on every answer
|
|
110
|
+
* because the Interface never accepted them on `put`.
|
|
111
|
+
*/
|
|
112
|
+
export interface EdgeObjectHttpMetadata {
|
|
113
|
+
readonly contentType?: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A byte range the Host actually served, in R2's `R2Range` spelling. */
|
|
117
|
+
export interface EdgeObjectRange {
|
|
118
|
+
readonly offset: number;
|
|
119
|
+
readonly length: number;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* R2's `R2Object` over `edge.objects@1.0.0`: what `head`, `put` and a `list`
|
|
124
|
+
* entry answer with.
|
|
125
|
+
*/
|
|
126
|
+
export interface EdgeR2Object {
|
|
127
|
+
readonly key: string;
|
|
128
|
+
readonly size: number;
|
|
129
|
+
/** The Host's etag verbatim. Opaque; quoting differs by backend. */
|
|
130
|
+
readonly etag: string;
|
|
131
|
+
/** The same etag in R2's header-safe quoted spelling. */
|
|
132
|
+
readonly httpEtag: string;
|
|
133
|
+
/** A `Date` on `head` and `list`; `undefined` on `get` and `put`. */
|
|
134
|
+
readonly uploaded: Date | undefined;
|
|
135
|
+
readonly httpMetadata: EdgeObjectHttpMetadata;
|
|
136
|
+
/** Always `undefined`: `edge.objects` has no custom metadata (ADR 0005). */
|
|
137
|
+
readonly customMetadata: undefined;
|
|
138
|
+
/** Present only on a ranged `get`. */
|
|
139
|
+
readonly range?: EdgeObjectRange;
|
|
140
|
+
/** Writes the metadata this object carries onto response headers. */
|
|
141
|
+
writeHttpMetadata(headers: Headers): void;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** R2's `R2ObjectBody`: an {@link EdgeR2Object} whose bytes came with it. */
|
|
145
|
+
export interface EdgeR2ObjectBody extends EdgeR2Object {
|
|
146
|
+
readonly body: ReadableStream<Uint8Array>;
|
|
147
|
+
readonly bodyUsed: boolean;
|
|
148
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
149
|
+
bytes(): Promise<Uint8Array>;
|
|
150
|
+
text(): Promise<string>;
|
|
151
|
+
json<T = unknown>(): Promise<T>;
|
|
152
|
+
blob(): Promise<Blob>;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** R2's `R2Objects`: one page of {@link EdgeObjectsBucket.list}. */
|
|
156
|
+
export interface EdgeR2Objects {
|
|
157
|
+
readonly objects: readonly EdgeR2Object[];
|
|
158
|
+
readonly truncated: boolean;
|
|
159
|
+
readonly cursor?: string;
|
|
160
|
+
/** R2's name for the common prefixes a `delimiter` collapsed. */
|
|
161
|
+
readonly delimitedPrefixes: readonly string[];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface EdgeObjectsGetOptions {
|
|
165
|
+
readonly range?: { readonly offset: number; readonly length?: number };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export interface EdgeObjectsListOptions {
|
|
169
|
+
readonly prefix?: string;
|
|
170
|
+
readonly delimiter?: string;
|
|
171
|
+
readonly cursor?: string;
|
|
172
|
+
readonly limit?: number;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function writeContentType(
|
|
176
|
+
contentType: string | undefined,
|
|
177
|
+
headers: Headers,
|
|
178
|
+
): void {
|
|
179
|
+
// R2 writes only the fields its `httpMetadata` actually holds, so an object
|
|
180
|
+
// stored without a content type leaves the caller's headers alone.
|
|
181
|
+
if (contentType !== undefined) headers.set("content-type", contentType);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* R2's `R2Object`. Metadata only: `head`, `put` and every `list` entry.
|
|
186
|
+
*/
|
|
187
|
+
class EdgeR2ObjectMetadata implements EdgeR2Object {
|
|
188
|
+
readonly key: string;
|
|
189
|
+
readonly size: number;
|
|
190
|
+
readonly etag: string;
|
|
191
|
+
readonly httpEtag: string;
|
|
192
|
+
readonly uploaded: Date | undefined;
|
|
193
|
+
readonly httpMetadata: EdgeObjectHttpMetadata;
|
|
194
|
+
readonly customMetadata: undefined = undefined;
|
|
195
|
+
readonly range?: EdgeObjectRange;
|
|
196
|
+
|
|
197
|
+
constructor(
|
|
198
|
+
key: string,
|
|
199
|
+
metadata: {
|
|
200
|
+
readonly etag: string;
|
|
201
|
+
readonly size: number;
|
|
202
|
+
readonly contentType?: string;
|
|
203
|
+
readonly uploadedAtMillis?: number;
|
|
204
|
+
},
|
|
205
|
+
range?: EdgeObjectRange,
|
|
206
|
+
) {
|
|
207
|
+
this.key = key;
|
|
208
|
+
this.size = metadata.size;
|
|
209
|
+
this.etag = metadata.etag;
|
|
210
|
+
this.httpEtag = httpEtagOf(metadata.etag);
|
|
211
|
+
this.uploaded =
|
|
212
|
+
metadata.uploadedAtMillis === undefined
|
|
213
|
+
? undefined
|
|
214
|
+
: new Date(metadata.uploadedAtMillis);
|
|
215
|
+
this.httpMetadata =
|
|
216
|
+
metadata.contentType === undefined
|
|
217
|
+
? {}
|
|
218
|
+
: { contentType: metadata.contentType };
|
|
219
|
+
if (range !== undefined) this.range = range;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
writeHttpMetadata(headers: Headers): void {
|
|
223
|
+
writeContentType(this.httpMetadata.contentType, headers);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* R2's `R2ObjectBody`.
|
|
229
|
+
*
|
|
230
|
+
* The bytes are held in a `Response`, which is where the body semantics come
|
|
231
|
+
* from rather than being re-implemented: `bodyUsed` flips the moment the stream
|
|
232
|
+
* is disturbed — including by a caller that read `body` itself — and a second
|
|
233
|
+
* `text()` / `json()` / `arrayBuffer()` / `blob()` REJECTS with a `TypeError`
|
|
234
|
+
* instead of replaying the first read. That is R2's own behaviour (workerd's
|
|
235
|
+
* `R2ObjectBody` refuses a disturbed body the same way the `Body` mixin does),
|
|
236
|
+
* so a caller cannot tell the two apart by consuming twice.
|
|
237
|
+
*/
|
|
238
|
+
class EdgeR2ObjectWithBody
|
|
239
|
+
extends EdgeR2ObjectMetadata
|
|
240
|
+
implements EdgeR2ObjectBody
|
|
241
|
+
{
|
|
242
|
+
readonly #response: Response;
|
|
243
|
+
|
|
244
|
+
constructor(
|
|
245
|
+
key: string,
|
|
246
|
+
metadata: {
|
|
247
|
+
readonly etag: string;
|
|
248
|
+
readonly size: number;
|
|
249
|
+
readonly contentType?: string;
|
|
250
|
+
readonly uploadedAtMillis?: number;
|
|
251
|
+
},
|
|
252
|
+
body: ReadableStream<Uint8Array>,
|
|
253
|
+
range?: EdgeObjectRange,
|
|
254
|
+
) {
|
|
255
|
+
super(key, metadata, range);
|
|
256
|
+
// Bytes only, with no content type attached: a `Response` normalises the
|
|
257
|
+
// header it is given (appending `;charset=utf-8` to a text type, for one),
|
|
258
|
+
// and that normalisation would show up on `blob().type` as a value the Host
|
|
259
|
+
// never stored. The stored content type is read where R2 puts it —
|
|
260
|
+
// `httpMetadata` and `writeHttpMetadata()`.
|
|
261
|
+
this.#response = new Response(body as unknown as BodyInit);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
get body(): ReadableStream<Uint8Array> {
|
|
265
|
+
// A `Response` built from a stream always has one.
|
|
266
|
+
return this.#response.body as ReadableStream<Uint8Array>;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
get bodyUsed(): boolean {
|
|
270
|
+
return this.#response.bodyUsed;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
arrayBuffer(): Promise<ArrayBuffer> {
|
|
274
|
+
return this.#response.arrayBuffer();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async bytes(): Promise<Uint8Array> {
|
|
278
|
+
// Built on `arrayBuffer()` rather than `Response.bytes()`, which this repo
|
|
279
|
+
// cannot name while type-checking against the DOM lib. It is the same read,
|
|
280
|
+
// so the once-only semantics are the same: a second call rejects.
|
|
281
|
+
return new Uint8Array(await this.#response.arrayBuffer());
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
text(): Promise<string> {
|
|
285
|
+
return this.#response.text();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
json<T = unknown>(): Promise<T> {
|
|
289
|
+
return this.#response.json() as Promise<T>;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
blob(): Promise<Blob> {
|
|
293
|
+
return this.#response.blob();
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
51
297
|
/**
|
|
52
298
|
* The byte length of a body the Host can be told up front, or `undefined` for
|
|
53
299
|
* a bare stream whose size only the producer knows.
|
|
@@ -61,14 +307,57 @@ function knownBodyLength(value: ObjectStoreBody): number | undefined {
|
|
|
61
307
|
return undefined;
|
|
62
308
|
}
|
|
63
309
|
|
|
64
|
-
|
|
65
|
-
|
|
310
|
+
/**
|
|
311
|
+
* `edge.objects@1.0.0` as a bucket whose answers are R2's.
|
|
312
|
+
*
|
|
313
|
+
* The calls are the facade's (its option names, its ceilings, its error
|
|
314
|
+
* vocabulary); the results are R2-shaped, so app code written against
|
|
315
|
+
* `R2Bucket` reads them unchanged. See the parity rule at the top of this file
|
|
316
|
+
* for what is provided, what is best effort, and what the wire cannot supply.
|
|
317
|
+
*/
|
|
318
|
+
export class EdgeObjectsBucket {
|
|
319
|
+
constructor(private readonly binding: EdgeObjectsBinding) {}
|
|
320
|
+
|
|
321
|
+
async head(key: string): Promise<EdgeR2Object | null> {
|
|
322
|
+
const found: EdgeObjectMetadata | null = await this.binding.head(key);
|
|
323
|
+
if (!found) return null;
|
|
324
|
+
return new EdgeR2ObjectMetadata(key, found);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async get(
|
|
328
|
+
key: string,
|
|
329
|
+
options?: EdgeObjectsGetOptions,
|
|
330
|
+
): Promise<EdgeR2ObjectBody | null> {
|
|
331
|
+
const range = options?.range;
|
|
332
|
+
// Fixed arity: the Host counts `arguments.length`, so the options slot is
|
|
333
|
+
// always passed, even when it is empty.
|
|
334
|
+
const found: EdgeObjectBody | null = await this.binding.get(
|
|
335
|
+
key,
|
|
336
|
+
range === undefined ? undefined : { range },
|
|
337
|
+
);
|
|
338
|
+
if (!found) return null;
|
|
339
|
+
if (found.partial && range === undefined) {
|
|
340
|
+
// No range was asked for, so a partial body would be a truncated object
|
|
341
|
+
// served as if it were whole. Refuse rather than hand the caller bytes
|
|
342
|
+
// that do not add up to the object.
|
|
343
|
+
await found.body.cancel().catch(() => undefined);
|
|
344
|
+
throw new EdgeObjectsShapeError(
|
|
345
|
+
"edge.objects: the Host returned a partial body for an unranged get",
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
return new EdgeR2ObjectWithBody(
|
|
349
|
+
key,
|
|
350
|
+
found,
|
|
351
|
+
found.body as ReadableStream<Uint8Array>,
|
|
352
|
+
found.range,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
66
355
|
|
|
67
356
|
async put(
|
|
68
357
|
key: string,
|
|
69
358
|
value: ObjectStoreBody,
|
|
70
359
|
options?: ObjectStorePutOptions,
|
|
71
|
-
): Promise<
|
|
360
|
+
): Promise<EdgeR2Object> {
|
|
72
361
|
const contentType = options?.contentType;
|
|
73
362
|
let contentLength = knownBodyLength(value);
|
|
74
363
|
// The facade's body slot has no `Blob`. A Blob's stream carries the same
|
|
@@ -84,41 +373,90 @@ export class EdgeObjectStorage implements ObjectStore {
|
|
|
84
373
|
body = buffered;
|
|
85
374
|
contentLength = buffered.byteLength;
|
|
86
375
|
}
|
|
87
|
-
await this.
|
|
376
|
+
const stored = await this.binding.put(key, body, {
|
|
88
377
|
contentLength,
|
|
89
378
|
...(contentType === undefined ? {} : { contentType }),
|
|
90
379
|
});
|
|
380
|
+
// The Host's `put` answers with `{etag, size}` and nothing else, so the
|
|
381
|
+
// returned object's `uploaded` is absent — see the parity rule above.
|
|
382
|
+
return new EdgeR2ObjectMetadata(key, {
|
|
383
|
+
etag: stored.etag,
|
|
384
|
+
size: stored.size,
|
|
385
|
+
...(contentType === undefined ? {} : { contentType }),
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async delete(key: string | readonly string[]): Promise<void> {
|
|
390
|
+
const keys = typeof key === "string" ? [key] : [...new Set(key)];
|
|
391
|
+
for (const one of keys) await this.binding.delete(one);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async list(options?: EdgeObjectsListOptions): Promise<EdgeR2Objects> {
|
|
395
|
+
const page = await this.binding.list(options);
|
|
396
|
+
return {
|
|
397
|
+
objects: page.objects.map(
|
|
398
|
+
(entry) => new EdgeR2ObjectMetadata(entry.key, entry),
|
|
399
|
+
),
|
|
400
|
+
truncated: page.truncated,
|
|
401
|
+
...(page.cursor === undefined ? {} : { cursor: page.cursor }),
|
|
402
|
+
// R2 calls the common prefixes a delimiter collapsed `delimitedPrefixes`.
|
|
403
|
+
delimitedPrefixes: page.prefixes,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* The provider-neutral {@link ObjectStore} over the same bucket.
|
|
410
|
+
*
|
|
411
|
+
* This is the port the core's own routes speak, and it stays deliberately
|
|
412
|
+
* narrower than R2 — flat metadata, no enumeration, no separate head — so app
|
|
413
|
+
* code does not grow a dependency on a vendor object shape. Code that WANTS R2
|
|
414
|
+
* takes {@link EdgeObjectsBucket} instead; both run the same adapter, so the
|
|
415
|
+
* media path proves it.
|
|
416
|
+
*/
|
|
417
|
+
export class EdgeObjectStorage implements ObjectStore {
|
|
418
|
+
readonly #bucket: EdgeObjectsBucket;
|
|
419
|
+
|
|
420
|
+
constructor(bucket: EdgeObjectsBinding) {
|
|
421
|
+
this.#bucket = new EdgeObjectsBucket(bucket);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async put(
|
|
425
|
+
key: string,
|
|
426
|
+
value: ObjectStoreBody,
|
|
427
|
+
options?: ObjectStorePutOptions,
|
|
428
|
+
): Promise<void> {
|
|
429
|
+
await this.#bucket.put(key, value, options);
|
|
91
430
|
}
|
|
92
431
|
|
|
93
432
|
async get(key: string): Promise<ObjectStoreObject | null> {
|
|
94
|
-
const found = await this
|
|
433
|
+
const found = await this.#bucket.get(key);
|
|
95
434
|
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
435
|
return {
|
|
106
|
-
key,
|
|
107
|
-
body: found.body
|
|
108
|
-
...(found.contentType === undefined
|
|
436
|
+
key: found.key,
|
|
437
|
+
body: found.body,
|
|
438
|
+
...(found.httpMetadata.contentType === undefined
|
|
109
439
|
? {}
|
|
110
|
-
: { contentType: found.contentType }),
|
|
440
|
+
: { contentType: found.httpMetadata.contentType }),
|
|
111
441
|
etag: found.etag,
|
|
442
|
+
httpEtag: found.httpEtag,
|
|
112
443
|
byteLength: found.size,
|
|
113
444
|
};
|
|
114
445
|
}
|
|
115
446
|
|
|
116
447
|
async delete(key: string | readonly string[]): Promise<void> {
|
|
117
|
-
|
|
118
|
-
for (const one of keys) await this.bucket.delete(one);
|
|
448
|
+
await this.#bucket.delete(key);
|
|
119
449
|
}
|
|
120
450
|
}
|
|
121
451
|
|
|
452
|
+
/** Wrap an `edge.objects@1.0.0` binding as an R2-shaped bucket. */
|
|
453
|
+
export function wrapEdgeObjectsAsBucket(
|
|
454
|
+
bucket: EdgeObjectsBinding,
|
|
455
|
+
): EdgeObjectsBucket {
|
|
456
|
+
return new EdgeObjectsBucket(bucket);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Wrap an `edge.objects@1.0.0` binding as the provider-neutral port. */
|
|
122
460
|
export function wrapEdgeObjects(bucket: EdgeObjectsBinding): ObjectStore {
|
|
123
461
|
return new EdgeObjectStorage(bucket);
|
|
124
462
|
}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
type ManagedRuntimeConnectionMaterialization,
|
|
14
14
|
} from "@takosjp/takosumi-contract/managed-runtime-connections";
|
|
15
15
|
|
|
16
|
+
import { httpEtagOf } from "./shared.ts";
|
|
16
17
|
import type {
|
|
17
18
|
IKeyValueStore,
|
|
18
19
|
ObjectStore,
|
|
@@ -401,6 +402,16 @@ class ManagedRuntimeStorageObject implements ObjectStoreObject {
|
|
|
401
402
|
return this.response.headers.get("etag") ?? undefined;
|
|
402
403
|
}
|
|
403
404
|
|
|
405
|
+
/**
|
|
406
|
+
* The gateway forwards R2's quoted `httpEtag`, so this is normally the same
|
|
407
|
+
* string as `etag`. It is derived rather than assumed: the port's contract is
|
|
408
|
+
* that `httpEtag` is always an entity-tag a header may carry.
|
|
409
|
+
*/
|
|
410
|
+
get httpEtag(): string | undefined {
|
|
411
|
+
const etag = this.etag;
|
|
412
|
+
return etag === undefined ? undefined : httpEtagOf(etag);
|
|
413
|
+
}
|
|
414
|
+
|
|
404
415
|
get byteLength(): number | undefined {
|
|
405
416
|
return parseContentLength(this.response.headers.get("content-length"));
|
|
406
417
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { httpEtagOf } from "./shared.ts";
|
|
1
2
|
import type {
|
|
2
3
|
ObjectStore,
|
|
3
4
|
ObjectStoreBody,
|
|
@@ -135,7 +136,10 @@ class S3FetchObjectStore implements ObjectStore {
|
|
|
135
136
|
? null
|
|
136
137
|
: boundedBody(response.body, this.maxObjectBytes, "get"),
|
|
137
138
|
...(contentType === undefined ? {} : { contentType }),
|
|
138
|
-
|
|
139
|
+
// S3 answers with a quoted entity-tag, so the derivation is a no-op here
|
|
140
|
+
// — but the port promises `httpEtag` on every backend, and deriving it
|
|
141
|
+
// is what makes that true without trusting one server's spelling.
|
|
142
|
+
...(etag === undefined ? {} : { etag, httpEtag: httpEtagOf(etag) }),
|
|
139
143
|
...(byteLength === undefined ? {} : { byteLength }),
|
|
140
144
|
};
|
|
141
145
|
}
|
|
@@ -39,6 +39,28 @@ export function nowSeconds(): number {
|
|
|
39
39
|
return Date.now() / 1000;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* One backend etag in the spelling an `ETag` header may actually carry.
|
|
44
|
+
*
|
|
45
|
+
* RFC 9110 §8.8.3 defines an entity-tag as an optional `W/` marker followed by
|
|
46
|
+
* a QUOTED opaque-tag, so a bare digest is not a valid field value and no cache
|
|
47
|
+
* can match one. Backends do not agree on the spelling they hand over: R2 keeps
|
|
48
|
+
* `etag` bare and `httpEtag` quoted, the `edge.objects` self-host wrapper sends
|
|
49
|
+
* the raw hex digest, and S3 and the managed gateway forward an already-quoted
|
|
50
|
+
* one. Every object seam therefore carries the derived, header-safe form beside
|
|
51
|
+
* the verbatim one, and it is the derived form that reaches a response.
|
|
52
|
+
*
|
|
53
|
+
* A value that is already an entity-tag — quoted, weak or strong — is returned
|
|
54
|
+
* unchanged; anything else is quoted.
|
|
55
|
+
*/
|
|
56
|
+
export function httpEtagOf(etag: string): string {
|
|
57
|
+
const bare = etag.startsWith("W/") ? etag.slice(2) : etag;
|
|
58
|
+
if (bare.length >= 2 && bare.startsWith('"') && bare.endsWith('"')) {
|
|
59
|
+
return etag;
|
|
60
|
+
}
|
|
61
|
+
return `"${etag}"`;
|
|
62
|
+
}
|
|
63
|
+
|
|
42
64
|
export function hasNulByte(value: string): boolean {
|
|
43
65
|
return value.includes("\0");
|
|
44
66
|
}
|
|
@@ -81,7 +81,19 @@ export interface ObjectStoreObject {
|
|
|
81
81
|
key: string;
|
|
82
82
|
body: ReadableStream<Uint8Array> | null;
|
|
83
83
|
contentType?: string;
|
|
84
|
+
/**
|
|
85
|
+
* The backend's etag VERBATIM, and opaque. Backends disagree on the spelling:
|
|
86
|
+
* some hand over a bare digest, others an already-quoted tag. So this is a
|
|
87
|
+
* value to compare and to store, never one to put in a header.
|
|
88
|
+
*/
|
|
84
89
|
etag?: string;
|
|
90
|
+
/**
|
|
91
|
+
* The same etag as an entity-tag: quoted, and safe to emit (RFC 9110 §8.8.3).
|
|
92
|
+
* Present exactly when `etag` is. This is the value a response carries in
|
|
93
|
+
* `ETag` and the value a client echoes back in `If-None-Match`, so a
|
|
94
|
+
* conditional request is evaluated against this one, not against `etag`.
|
|
95
|
+
*/
|
|
96
|
+
httpEtag?: string;
|
|
85
97
|
byteLength?: number;
|
|
86
98
|
}
|
|
87
99
|
|