@takosjp/yurucommu-core 4.1.4 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takosjp/yurucommu-core",
3
- "version": "4.1.4",
3
+ "version": "4.1.5",
4
4
  "license": "AGPL-3.0-only",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takosjp/yurucommu-api",
3
- "version": "4.1.4",
3
+ "version": "4.1.5",
4
4
  "description": "Typed client SDK and public API contract for yurucommu-server clients.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",
@@ -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
- const ifNoneMatch = c.req.header("If-None-Match");
228
- if (ifNoneMatch && etag && ifNoneMatch === etag) {
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
 
@@ -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
- object.etag,
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.etag,
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 etag = object.etag;
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": `${cacheScope}, max-age=${maxAge}`,
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": `${cacheScope}, max-age=${maxAge}`,
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
  }
@@ -8,10 +8,10 @@
8
8
  * the Interface: an app written against R2 is supposed to port over unchanged.
9
9
  * The RESULT objects were not: the facade's `get()` answers with a plain record
10
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
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
15
  * legal R2, threw `o.text is not a function` on the portable lane.
16
16
  *
17
17
  * The wire contract is Takoserver's (ADR 0005) and does not move. This module
@@ -23,13 +23,13 @@
23
23
  *
24
24
  * PROVIDED, with R2's names and R2's semantics: `key`, `size`, `etag`,
25
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.
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
33
  *
34
34
  * BEST EFFORT: `uploaded`. The Host's `head` and `list` carry
35
35
  * `uploadedAtMillis`, so it is a `Date` there. Its `get` and `put` do NOT —
@@ -44,10 +44,12 @@
44
44
  *
45
45
  * `etag` is the Host's etag VERBATIM and is opaque: the self-host wrapper sends
46
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.
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.
51
53
  *
52
54
  * ## The narrow spots of the facade itself, which parity does not widen
53
55
  *
@@ -90,7 +92,7 @@ import type {
90
92
  EdgeObjectMetadata,
91
93
  EdgeObjectsBinding,
92
94
  } from "./edge-facades.ts";
93
- import { readStream } from "./shared.ts";
95
+ import { httpEtagOf, readStream } from "./shared.ts";
94
96
 
95
97
  /** A request or response the facade cannot express. */
96
98
  export class EdgeObjectsShapeError extends TypeError {
@@ -144,6 +146,7 @@ export interface EdgeR2ObjectBody extends EdgeR2Object {
144
146
  readonly body: ReadableStream<Uint8Array>;
145
147
  readonly bodyUsed: boolean;
146
148
  arrayBuffer(): Promise<ArrayBuffer>;
149
+ bytes(): Promise<Uint8Array>;
147
150
  text(): Promise<string>;
148
151
  json<T = unknown>(): Promise<T>;
149
152
  blob(): Promise<Blob>;
@@ -169,14 +172,6 @@ export interface EdgeObjectsListOptions {
169
172
  readonly limit?: number;
170
173
  }
171
174
 
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
175
  function writeContentType(
181
176
  contentType: string | undefined,
182
177
  headers: Headers,
@@ -279,6 +274,13 @@ class EdgeR2ObjectWithBody
279
274
  return this.#response.arrayBuffer();
280
275
  }
281
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
+
282
284
  text(): Promise<string> {
283
285
  return this.#response.text();
284
286
  }
@@ -437,6 +439,7 @@ export class EdgeObjectStorage implements ObjectStore {
437
439
  ? {}
438
440
  : { contentType: found.httpMetadata.contentType }),
439
441
  etag: found.etag,
442
+ httpEtag: found.httpEtag,
440
443
  byteLength: found.size,
441
444
  };
442
445
  }
@@ -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
- ...(etag === undefined ? {} : { etag }),
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