Skip to content

Commit 8a7465b

Browse files
MajorTalclaude
andcommitted
feat(sdk): paste-and-go AssetRef with scriptTag/linkTag/imgTag emitters
v1.45 best-DX: agents call blobs.put with immutable:true and paste asset.scriptTag() / asset.linkTag() / asset.imgTag() into generated HTML. Each emitter returns a ready-to-use tag with src/href set to a content-hashed URL on pr-<public_id>.run402.com (the host guaranteed to work through the v1.33 CDN), integrity set to the SRI hash, and crossorigin set so browsers verify the bytes. AssetRef widening: - cdnUrl: the immutable-form auto-subdomain URL — what scriptTag/linkTag/imgTag bind to. Null on non-immutable or private uploads. - cdnMutableUrl: mutable-form auto-subdomain URL. Eventual-consistency caveats apply; prefer cdnUrl in code. - url / immutable_url / immutableUrl (preferred-host forms) — kept for backward compat and direct-API consumers (e.g. blobs.get). Currently NOT served through the v1.33 CDN behavior on claimed subdomains / custom domains; followup tracked separately. Tag emitter ergonomics: - scriptTag({ type?, defer?, async? }): bakes integrity + crossorigin - linkTag({ rel = 'stylesheet', as? }): bakes integrity + crossorigin - imgTag(alt = ''): no SRI (HTML5 spec), but URL is content-hashed so still stable across re-deploys - HTML attribute escaping for url + alt - Throw with actionable hint when called on non-immutable uploads Tests: - 4 new test cases (immutable widening, tag emitter shapes, HTML escaping, non-immutable error) - Existing 22 tests still green; total now 25 Coordinated with run402-private: the gateway emits the new cdn_url + cdn_immutable_url fields on the upload-completion response. Older gateway versions (without the new fields) still work — the SDK leaves cdnUrl/cdnMutableUrl null and the tag emitters throw with the immutable-only hint, which surfaces on the first call rather than during upload. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e812399 commit 8a7465b

5 files changed

Lines changed: 323 additions & 37 deletions

File tree

SKILL.md

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -109,14 +109,32 @@ Upload a blob (any size, up to 5 TiB) to project storage via direct-to-S3 presig
109109
- `immutable` (optional, default: `false`) — If true with `sha256`, also produces a content-addressed URL that gets `Cache-Control: immutable`.
110110
- `sha256` (optional) — Required when `immutable: true`. Client-asserted hash; gateway verifies if S3 returns one.
111111

112-
**Returns:** an `AssetRef`: `{ key, size_bytes, sha256, visibility, url, immutable_url, size, contentSha256, contentType, immutableUrl, etag, sri, contentDigest, cacheKind, cdn: { version, invalidationId, invalidationStatus, ready, hint } }`. The legacy snake_case fields (`size_bytes`, `sha256`, `immutable_url`) are kept for back-compat; the camelCase fields are the v1.45 agent-DX surface.
112+
**Returns:** an `AssetRef`. The agent-DX way to use it: **upload with `immutable: true`, then call `result.scriptTag()`, `result.linkTag()`, or `result.imgTag()` and paste the output directly into your generated HTML.** The tag emitters return ready-to-use HTML with `src` / `href` set to a content-hashed URL on `pr-<public_id>.run402.com`, `integrity` set to the SRI hash, and `crossorigin` set so browsers verify the bytes.
113113

114-
**Prefer `immutableUrl` in generated HTML/CSS/JS code.** Reasoning:
115-
- Read-after-write correctness: the immutable URL is bound to the SHA at upload time and was never previously cached. The mutable `url` is "eventually fresh" — invalidation is asynchronous, so a `<script>` tag pointing at it may load the OLD version for seconds-to-minutes after a re-upload.
116-
- Built-in integrity: pair `immutableUrl` with `integrity={sri}` on `<script>` and `<link>` tags so browsers verify the hash before executing.
117-
- No follow-up calls needed: an agent emitting `<script src={immutableUrl} integrity={sri}>` doesn't need to call `wait_for_cdn_freshness` afterwards. Use the mutable `url` only when the URL must remain stable across re-uploads.
114+
```ts
115+
const logo = await client.blobs.put(projectId, "logo.png", { bytes }, { immutable: true });
116+
html += logo.imgTag("Company logo");
117+
// → <img src="https://pr-abc.run402.com/_blob/logo-3a7fc02e.png" alt="Company logo">
118118

119-
If you must use the mutable `url` (e.g. you're updating an `<img>` referenced by an external system that won't accept a new URL), call `wait_for_cdn_freshness` before publishing the change. **Mutable URLs only**; never call wait_for_cdn_freshness on `immutableUrl`.
119+
const app = await client.blobs.put(projectId, "app.js", { content }, { immutable: true });
120+
html += app.scriptTag({ type: "module" });
121+
// → <script src="https://pr-abc.run402.com/_blob/app-deadbeef.js" type="module" integrity="sha256-…" crossorigin></script>
122+
123+
const styles = await client.blobs.put(projectId, "styles.css", { content }, { immutable: true });
124+
html += styles.linkTag();
125+
// → <link rel="stylesheet" href="https://pr-abc.run402.com/_blob/styles-aabbccdd.css" integrity="sha256-…" crossorigin>
126+
```
127+
128+
**Why this is the recommended path.** The URL the emitters use (`cdnUrl`) is content-addressed (no cache invalidation, no `wait_for_cdn_freshness`), served from the auto-subdomain `pr-<public_id>.run402.com` (the host that's guaranteed to work through the v1.33 CDN), and stable across re-deploys of the same content. SRI guarantees the browser refuses execution on byte mismatch. There are no decisions for the agent to make.
129+
130+
**Other AssetRef fields** for advanced use:
131+
- `cdnUrl` — the content-addressed auto-subdomain URL the tag emitters use. Use this directly if you're not generating HTML (e.g. CSS `url()` references, JSON data URLs).
132+
- `cdnMutableUrl` — mutable form of the auto-subdomain URL. Eventual-consistency caveats apply; prefer `cdnUrl` for generated code.
133+
- `url` / `immutableUrl` (preferred-host forms) — the URL on the project's pretty host (claimed subdomain or custom domain when configured). Currently NOT served through the CDN — keep using these for direct-API consumers (e.g. `client.blobs.get`), not in `<script>`/`<img>` embeds.
134+
- `etag`, `sri`, `contentDigest` — the integrity values the tag emitters bake in. Useful if you're constructing tags by hand for an unsupported context.
135+
- `cdn: { version, invalidationId, invalidationStatus, ready, hint }` — CloudFront invalidation envelope. For immutable uploads `cdn.ready === true` and no further work is required.
136+
137+
The legacy `size_bytes`, `sha256`, `immutable_url` fields stay populated for back-compat with pre-v1.45 callers.
120138

121139
Supersedes `upload_file` (deprecated).
122140

cli/llms-cli.txt

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -408,16 +408,28 @@ run402 blob diagnose https://app.run402.com/_blob/avatar.png --project abc123
408408
run402 cdn wait-fresh https://app.run402.com/_blob/avatar.png --sha ba78... --timeout 120
409409
```
410410

411-
`put` response (an `AssetRef`) includes both legacy snake_case and v1.45 camelCase fields:
412-
- `url` / (no camelCase alias) — stable mutable URL: `https://pr-<public_id>.run402.com/_blob/<key>`, also any claimed subdomain or mapped custom domain. Cached at CloudFront edge; invalidation is asynchronous on overwrite.
413-
- `immutable_url` / `immutableUrl` (only when `--immutable`) — content-hashed URL: `https://<host>/_blob/<key-without-ext>-<8hex>.<ext>`. Bound to a SHA at upload time; never previously cached. **Prefer this in generated HTML/CSS/JS code** — it doesn't need waiting and pairs with `sri` for browser SRI verification.
414-
- `etag` — strong `"sha256-<hex>"` ETag (when `--immutable`).
415-
- `sri` — `sha256-<base64>` for `<script integrity={sri}>` and `<link integrity={sri}>`.
411+
`put` response (an `AssetRef`) — the agent-DX way:
412+
413+
```js
414+
const asset = await client.blobs.put(projectId, key, { bytes }, { immutable: true });
415+
html += asset.scriptTag(); // <script src=... integrity=... crossorigin></script>
416+
html += asset.linkTag(); // <link rel="stylesheet" href=... integrity=... crossorigin>
417+
html += asset.imgTag("Company logo"); // <img src=... alt="Company logo">
418+
```
419+
420+
Always upload with `immutable: true` when you plan to embed in HTML/CSS/JS. The tag emitters require it (they bind to a SHA), and the resulting URL is content-addressed → never needs cache invalidation, never breaks on re-deploys, never needs `cdn wait-fresh` follow-up.
421+
422+
Other AssetRef fields for advanced use:
423+
- `cdnUrl` — the content-addressed URL the emitters use directly. `https://pr-<public_id>.run402.com/_blob/<key-without-ext>-<8hex>.<ext>`. Served via the v1.33 CDN; guaranteed-reachable.
424+
- `cdnMutableUrl` — mutable URL on the auto-subdomain. Eventual-consistency caveats; prefer `cdnUrl` in code.
425+
- `url` / `immutableUrl` (preferred-host forms) — URL on the project's pretty host (claimed subdomain / custom domain when configured). Currently NOT served through the CDN — keep using for direct-API consumers (e.g. `run402 blob get`), not in `<script>`/`<img>` embeds.
426+
- `etag` — strong `"sha256-<hex>"` ETag (when `immutable`).
427+
- `sri` — `sha256-<base64>` for `<script integrity={sri}>` if you must construct tags by hand.
416428
- `contentDigest` — RFC 9530 `sha-256=:<base64>:` for HTTP integrity.
417429
- `cacheKind` — `"immutable" | "mutable" | "private"`.
418430
- `cdn.{version,invalidationId,invalidationStatus,ready,hint}` — CloudFront invalidation envelope; `cdn.ready === true` for immutable uploads.
419431

420-
**Agent guidance.** When emitting HTML/CSS/JS that links a just-uploaded asset, use `immutableUrl` + `integrity={sri}`. Read-after-write is correct and you don't need a follow-up `cdn wait-fresh` poll. Use the mutable `url` only when the URL must remain stable across re-uploads; in that case, `run402 cdn wait-fresh <url> --sha <new-sha>` blocks until the CDN serves the new SHA.
432+
**Agent loop pattern (mutable URL only):** if you must use a stable mutable URL across re-uploads, `run402 cdn wait-fresh <mutable-url> --sha <new-sha>` blocks until the CDN serves the new SHA. **Don't call wait-fresh on immutable URLs** — they're correct from the moment of upload.
421433

422434
Resume: state is persisted to `~/.run402/uploads/<upload_id>.json`. Ctrl-C mid-upload and re-run the same `blob put` command — it picks up where it left off. Pass `--no-resume` to start fresh.
423435

sdk/src/namespaces/blobs.test.ts

Lines changed: 133 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,8 @@ describe("blobs.put — AssetRef widening (v1.45)", () => {
327327
immutable_suffix: SHA.slice(0, 8),
328328
url: "https://app.run402.com/_blob/x.txt",
329329
immutable_url: "https://app.run402.com/_blob/x-ba7816bf.txt",
330+
cdn_url: "https://pr-abc.run402.com/_blob/x.txt",
331+
cdn_immutable_url: "https://pr-abc.run402.com/_blob/x-ba7816bf.txt",
330332
});
331333
}
332334
throw new Error("unexpected: " + call.url);
@@ -344,6 +346,9 @@ describe("blobs.put — AssetRef widening (v1.45)", () => {
344346
assert.equal(result.contentSha256, SHA);
345347
assert.equal(result.immutableUrl, "https://app.run402.com/_blob/x-ba7816bf.txt");
346348
assert.equal(result.contentType, "text/plain");
349+
// v1.45 cdn-reachable URLs (auto-subdomain, guaranteed-working).
350+
assert.equal(result.cdnUrl, "https://pr-abc.run402.com/_blob/x-ba7816bf.txt");
351+
assert.equal(result.cdnMutableUrl, "https://pr-abc.run402.com/_blob/x.txt");
347352
// Integrity fields derived from the SHA.
348353
assert.equal(result.etag, `"sha256-${SHA}"`);
349354
assert.match(result.sri ?? "", /^sha256-[A-Za-z0-9+/]+={0,2}$/);
@@ -352,7 +357,134 @@ describe("blobs.put — AssetRef widening (v1.45)", () => {
352357
assert.equal(result.cacheKind, "immutable");
353358
assert.equal(result.cdn.version, "blob-gateway-v2");
354359
assert.equal(result.cdn.ready, true);
355-
assert.match(result.cdn.hint ?? "", /immutableUrl is ready immediately/);
360+
assert.match(result.cdn.hint ?? "", /Use cdnUrl/);
361+
});
362+
363+
it("scriptTag/linkTag/imgTag emit ready-to-paste tags with SRI + crossorigin", async () => {
364+
const SHA = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
365+
const { fetch } = mockFetch((call) => {
366+
if (call.url.endsWith("/storage/v1/uploads")) {
367+
return json({
368+
upload_id: "u_t",
369+
mode: "single",
370+
part_count: 1,
371+
parts: [{ part_number: 1, url: "https://s3.test/u_t/p1", byte_start: 0, byte_end: 2 }],
372+
});
373+
}
374+
if (call.url.startsWith("https://s3.test/")) {
375+
return new Response("", { status: 200, headers: { etag: '"e"' } });
376+
}
377+
if (call.url.endsWith("/complete")) {
378+
return json({
379+
key: "app.js",
380+
size_bytes: 3,
381+
sha256: SHA,
382+
visibility: "public",
383+
content_type: "text/javascript",
384+
immutable_suffix: SHA.slice(0, 8),
385+
url: "https://app.run402.com/_blob/app.js",
386+
immutable_url: "https://app.run402.com/_blob/app-ba7816bf.js",
387+
cdn_url: "https://pr-abc.run402.com/_blob/app.js",
388+
cdn_immutable_url: "https://pr-abc.run402.com/_blob/app-ba7816bf.js",
389+
});
390+
}
391+
throw new Error("unexpected: " + call.url);
392+
});
393+
const sdk = makeSdk(fetch);
394+
const asset = await sdk.blobs.put("prj_known", "app.js", { content: "abc" }, { immutable: true });
395+
396+
// Default scriptTag.
397+
const tag = asset.scriptTag();
398+
assert.match(tag, /^<script /);
399+
assert.match(tag, /src="https:\/\/pr-abc\.run402\.com\/_blob\/app-ba7816bf\.js"/);
400+
assert.match(tag, /integrity="sha256-[A-Za-z0-9+/]+={0,2}"/);
401+
assert.match(tag, /crossorigin/);
402+
403+
// Module + defer.
404+
const moduleTag = asset.scriptTag({ type: "module", defer: true });
405+
assert.match(moduleTag, /type="module"/);
406+
assert.match(moduleTag, / defer /);
407+
408+
// Default linkTag (stylesheet).
409+
const link = asset.linkTag();
410+
assert.match(link, /^<link /);
411+
assert.match(link, /rel="stylesheet"/);
412+
assert.match(link, /href="https:\/\/pr-abc\.run402\.com\/_blob\/app-ba7816bf\.js"/);
413+
assert.match(link, /integrity="sha256-/);
414+
415+
// Custom rel + as (preload).
416+
const preload = asset.linkTag({ rel: "preload", as: "font" });
417+
assert.match(preload, /rel="preload"/);
418+
assert.match(preload, /as="font"/);
419+
420+
// imgTag with alt.
421+
const img = asset.imgTag("Company logo");
422+
assert.match(img, /^<img /);
423+
assert.match(img, /src="https:\/\/pr-abc\.run402\.com\/_blob\/app-ba7816bf\.js"/);
424+
assert.match(img, /alt="Company logo"/);
425+
// No SRI on <img>.
426+
assert.equal(/integrity=/.test(img), false);
427+
});
428+
429+
it("tag emitters escape HTML special chars in alt + url + sri", async () => {
430+
// sha256 of "abc"
431+
const SHA = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
432+
// URL with characters that need attribute escaping.
433+
const HOSTILE_URL = "https://pr-abc.run402.com/_blob/a&b\"c<d>.png";
434+
const { fetch } = mockFetch((call) => {
435+
if (call.url.endsWith("/storage/v1/uploads")) {
436+
return json({
437+
upload_id: "u_e", mode: "single", part_count: 1,
438+
parts: [{ part_number: 1, url: "https://s3.test/u_e/p1", byte_start: 0, byte_end: 2 }],
439+
});
440+
}
441+
if (call.url.startsWith("https://s3.test/")) {
442+
return new Response("", { status: 200, headers: { etag: '"e"' } });
443+
}
444+
if (call.url.endsWith("/complete")) {
445+
return json({
446+
key: 'a&b"c<d>.png',
447+
size_bytes: 3, sha256: SHA, visibility: "public",
448+
content_type: "image/png", immutable_suffix: SHA.slice(0, 8),
449+
url: HOSTILE_URL, immutable_url: HOSTILE_URL,
450+
cdn_url: HOSTILE_URL, cdn_immutable_url: HOSTILE_URL,
451+
});
452+
}
453+
throw new Error("unexpected: " + call.url);
454+
});
455+
const sdk = makeSdk(fetch);
456+
const asset = await sdk.blobs.put("prj_known", 'a&b"c<d>.png', { content: "abc" }, { immutable: true });
457+
const img = asset.imgTag('Bad <alt> "quoted"');
458+
assert.match(img, /alt="Bad &lt;alt&gt; &quot;quoted&quot;"/);
459+
assert.match(img, /a&amp;b&quot;c&lt;d&gt;\.png/);
460+
});
461+
462+
it("tag emitters throw on non-immutable uploads with an actionable hint", async () => {
463+
const { fetch } = mockFetch((call) => {
464+
if (call.url.endsWith("/storage/v1/uploads")) {
465+
return json({
466+
upload_id: "u_n", mode: "single", part_count: 1,
467+
parts: [{ part_number: 1, url: "https://s3.test/u_n/p1", byte_start: 0, byte_end: 2 }],
468+
});
469+
}
470+
if (call.url.startsWith("https://s3.test/")) {
471+
return new Response("", { status: 200, headers: { etag: '"e"' } });
472+
}
473+
if (call.url.endsWith("/complete")) {
474+
return json({
475+
key: "x.png", size_bytes: 3, sha256: null, visibility: "public",
476+
content_type: "image/png", immutable_suffix: null,
477+
url: "https://app.run402.com/_blob/x.png", immutable_url: null,
478+
cdn_url: "https://pr-abc.run402.com/_blob/x.png", cdn_immutable_url: null,
479+
});
480+
}
481+
throw new Error("unexpected: " + call.url);
482+
});
483+
const sdk = makeSdk(fetch);
484+
const asset = await sdk.blobs.put("prj_known", "x.png", { content: "abc" });
485+
assert.throws(() => asset.scriptTag(), /immutable: true/);
486+
assert.throws(() => asset.linkTag(), /immutable: true/);
487+
assert.throws(() => asset.imgTag(), /immutable: true/);
356488
});
357489

358490
it("leaves integrity fields null on non-immutable upload (sha256 not computed)", async () => {

sdk/src/namespaces/blobs.ts

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,13 +82,27 @@ interface UploadCompleteResponse {
8282
etag?: string;
8383
url: string | null;
8484
immutable_url: string | null;
85+
/** v1.45+ agent-DX URLs from the gateway. Always on the auto-subdomain
86+
* (`pr-<public_id>.run402.com`) which is guaranteed to work through the
87+
* v1.33 CDN path. May be null on private uploads or older gateway
88+
* versions that don't emit them. */
89+
cdn_url?: string | null;
90+
cdn_immutable_url?: string | null;
8591
/** Optional: future gateway versions emit a `cdn` envelope on completion
8692
* with the CloudFront invalidation ID + status for mutable overwrites
8793
* (and `ready: true` for immutable uploads). When absent (current
8894
* gateway), the SDK fills in safe defaults from local information. */
8995
cdn?: Partial<BlobCdnEnvelope>;
9096
}
9197

98+
function escapeHtmlAttr(s: string): string {
99+
return s
100+
.replace(/&/g, "&amp;")
101+
.replace(/"/g, "&quot;")
102+
.replace(/</g, "&lt;")
103+
.replace(/>/g, "&gt;");
104+
}
105+
92106
/**
93107
* Convert hex (e.g. `"abcd…"`) to base64 in environments that have either
94108
* Buffer (Node) or `btoa` (browsers). The helper is here because Browser SDK
@@ -132,6 +146,12 @@ function buildAssetRef(
132146
const sri = sha ? `sha256-${hexToBase64(sha)}` : null;
133147
const contentDigest = sha ? `sha-256=:${hexToBase64(sha)}:` : null;
134148

149+
// v1.45 agent-DX URLs — guaranteed CDN-reachable on the auto-subdomain.
150+
// Older gateway versions don't emit them; null in that case (callers fall
151+
// back to the preferred-host `url` / `immutableUrl`).
152+
const cdnUrl = resp.cdn_immutable_url ?? null;
153+
const cdnMutableUrl = resp.cdn_url ?? null;
154+
135155
// The cdn envelope: prefer what the gateway returns; fall back to
136156
// best-effort defaults so older gateway versions don't break the SDK
137157
// surface. immutable URLs are always-ready by definition.
@@ -144,10 +164,23 @@ function buildAssetRef(
144164
hint:
145165
cdnFromGw.hint ??
146166
(immutable
147-
? "immutableUrl is ready immediately."
148-
: "For mutable URLs, propagation is asynchronous. Prefer immutableUrl in generated HTML/CSS/JS, or call wait_for_cdn_freshness."),
167+
? "Use cdnUrl + scriptTag()/linkTag()/imgTag() — paste-and-go."
168+
: "For mutable URLs, propagation is asynchronous. Prefer cdnUrl (immutable, content-hashed) for generated HTML/CSS/JS, or call wait_for_cdn_freshness."),
149169
};
150170

171+
// Emitter helpers. Throw with an actionable hint when the SHA is null
172+
// (non-immutable upload) — the agent should re-upload with `immutable:
173+
// true` to get content-hashed URLs that pair with SRI.
174+
function requireImmutable(name: string): { url: string; sri: string } {
175+
if (!cdnUrl || !sri) {
176+
throw new Error(
177+
`${name}() requires an immutable upload (pass { immutable: true } to blobs.put). ` +
178+
`Without immutable, there is no SHA to bind for SRI and the URL would change on re-upload.`,
179+
);
180+
}
181+
return { url: cdnUrl, sri };
182+
}
183+
151184
return {
152185
key: resp.key,
153186
size_bytes: resp.size_bytes,
@@ -159,11 +192,44 @@ function buildAssetRef(
159192
contentSha256: sha,
160193
contentType,
161194
immutableUrl: resp.immutable_url,
195+
cdnUrl,
196+
cdnMutableUrl,
162197
etag,
163198
sri,
164199
contentDigest,
165200
cacheKind,
166201
cdn,
202+
203+
scriptTag(opts) {
204+
const { url, sri } = requireImmutable("scriptTag");
205+
const attrs: string[] = [`src="${escapeHtmlAttr(url)}"`];
206+
if (opts?.type === "module") attrs.push(`type="module"`);
207+
if (opts?.defer) attrs.push("defer");
208+
if (opts?.async) attrs.push("async");
209+
attrs.push(`integrity="${escapeHtmlAttr(sri)}"`);
210+
attrs.push("crossorigin");
211+
return `<script ${attrs.join(" ")}></script>`;
212+
},
213+
214+
linkTag(opts) {
215+
const { url, sri } = requireImmutable("linkTag");
216+
const rel = opts?.rel ?? "stylesheet";
217+
const attrs: string[] = [`rel="${escapeHtmlAttr(rel)}"`];
218+
attrs.push(`href="${escapeHtmlAttr(url)}"`);
219+
if (opts?.as) attrs.push(`as="${escapeHtmlAttr(opts.as)}"`);
220+
attrs.push(`integrity="${escapeHtmlAttr(sri)}"`);
221+
attrs.push("crossorigin");
222+
return `<link ${attrs.join(" ")}>`;
223+
},
224+
225+
imgTag(alt) {
226+
// <img> doesn't take SRI per HTML5 spec — agents who need integrity
227+
// for images should fetch + verify Content-Digest server-side. We
228+
// still require immutable so the URL is stable across re-deploys.
229+
const { url } = requireImmutable("imgTag");
230+
const a = alt ?? "";
231+
return `<img src="${escapeHtmlAttr(url)}" alt="${escapeHtmlAttr(a)}">`;
232+
},
167233
};
168234
}
169235

0 commit comments

Comments
 (0)