Skip to content

Commit e66b3cd

Browse files
MajorTalclaude
andcommitted
feat(sdk): hide best-practice defaults — immutable=true, defer, lazy/async-decode
Push the agent-DX defaults all the way: with these on, the agent's typical flow is one line and gets every modern best practice for free. Defaults flipped: 1. BlobPutOptions.immutable defaults to true. The cdnUrl + sri + tag emitters all require an immutable upload, so the v1.45 default reaches for the best path. Cost: one client-side SHA-256 pass, dominated by network for typical asset sizes (< 1 MB). Storage cost: +128 bytes (one blob_url_refs row). Pass { immutable: false } to opt out for very large uploads where you specifically don't need a content-hashed URL. 2. scriptTag() defaults defer: true. Modern best practice — non-render-blocking when placed in <head>, no-op at end of <body>. async: true overrides defer (the two are mutually exclusive per HTML spec). Pass { defer: false } for the rare sync-required case. 3. imgTag() defaults loading="lazy" + decoding="async". Lazy is harmless for above-fold images (browsers handle the heuristic) and a flat win for the much more common below-fold case. Async decoding moves the decode off the main thread. Both are baseline-supported in all major browsers. linkTag intentionally NOT changed — crossorigin always emitted because we always emit integrity (SRI requires CORS mode), AND for rel="preload" matching crossorigin is what lets the browser dedupe with the eventual fetch instead of double-fetching. Tests: - New: 'defaults to immutable: true' covers the v1.45 default path + cdnUrl/sri/scriptTag work without the agent passing { immutable: true } - Updated: scriptTag/linkTag/imgTag emitter test asserts the new default attributes (defer, loading=lazy, decoding=async) and the explicit-opt-out paths (defer: false, async: true overriding defer) - Updated: existing tests that checked sha256=undefined / immutable=false explicitly pass { immutable: false } to preserve their intent - All 26 SDK tests green Docs: - SKILL.md and cli/llms-cli.txt updated to reflect immutable=true as the default and remove the 'always pass { immutable: true }' guidance (no longer needed) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 394f4c4 commit e66b3cd

5 files changed

Lines changed: 174 additions & 38 deletions

File tree

SKILL.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -106,21 +106,21 @@ Upload a blob (any size, up to 5 TiB) to project storage via direct-to-S3 presig
106106
- `content` (optional) — Inline content, ≤ 1 MB. Use only for small text blobs.
107107
- `content_type` (optional) — MIME type
108108
- `visibility` (optional, default: `"public"`) — `"public"` (bypasses auth) or `"private"` (requires apikey)
109-
- `immutable` (optional, default: `false`) — If true with `sha256`, also produces a content-addressed URL that gets `Cache-Control: immutable`.
110-
- `sha256` (optional) — Required when `immutable: true`. Client-asserted hash; gateway verifies if S3 returns one.
109+
- `immutable` (optional, **default `true` since v1.45**) — Produces a content-addressed URL that pairs with SRI and never needs cache invalidation. Pass `{ immutable: false }` only when you specifically want to skip the SHA-256 pass on a very large upload (storage cost is identical; immutable just adds the content-hashed URL).
110+
- `sha256` (optional) — Computed automatically by the SDK when `immutable: true` (the default). You don't need to set this.
111111

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.
112+
**Returns:** an `AssetRef`. The agent-DX way to use it: **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. They also bake in modern best-practice attributes — `defer` on `<script>`, `loading="lazy"` + `decoding="async"` on `<img>` — so you don't have to remember.
113113

114114
```ts
115-
const logo = await client.blobs.put(projectId, "logo.png", { bytes }, { immutable: true });
115+
const logo = await client.blobs.put(projectId, "logo.png", { bytes });
116116
html += logo.imgTag("Company logo");
117-
// → <img src="https://pr-abc.run402.com/_blob/logo-3a7fc02e.png" alt="Company logo">
117+
// → <img src="https://pr-abc.run402.com/_blob/logo-3a7fc02e.png" alt="Company logo" loading="lazy" decoding="async">
118118

119-
const app = await client.blobs.put(projectId, "app.js", { content }, { immutable: true });
119+
const app = await client.blobs.put(projectId, "app.js", { content });
120120
html += app.scriptTag({ type: "module" });
121-
// → <script src="https://pr-abc.run402.com/_blob/app-deadbeef.js" type="module" integrity="sha256-…" crossorigin></script>
121+
// → <script src="https://pr-abc.run402.com/_blob/app-deadbeef.js" type="module" defer integrity="sha256-…" crossorigin></script>
122122

123-
const styles = await client.blobs.put(projectId, "styles.css", { content }, { immutable: true });
123+
const styles = await client.blobs.put(projectId, "styles.css", { content });
124124
html += styles.linkTag();
125125
// → <link rel="stylesheet" href="https://pr-abc.run402.com/_blob/styles-aabbccdd.css" integrity="sha256-…" crossorigin>
126126
```

cli/llms-cli.txt

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -411,13 +411,13 @@ run402 cdn wait-fresh https://app.run402.com/_blob/avatar.png --sha ba78... --ti
411411
`put` response (an `AssetRef`) — the agent-DX way:
412412

413413
```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">
414+
const asset = await client.blobs.put(projectId, key, { bytes }); // v1.45 defaults to immutable: true
415+
html += asset.scriptTag(); // <script src=... defer integrity=... crossorigin></script>
416+
html += asset.linkTag(); // <link rel="stylesheet" href=... integrity=... crossorigin>
417+
html += asset.imgTag("Company logo"); // <img src=... alt="Company logo" loading="lazy" decoding="async">
418418
```
419419

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.
420+
`immutable: true` is the default since v1.45 — the SDK computes the SHA-256 client-side and the gateway returns a content-addressed URL paired with SRI. The resulting URL never needs cache invalidation, never breaks on re-deploys, never needs `cdn wait-fresh` follow-up. Pass `{ immutable: false }` explicitly only when you want to skip the SHA pass on a very large upload (in that case the tag emitters throw because there's no SHA to bind for SRI). The tag emitters also bake in modern best-practice attributes — `defer` on `<script>`, `loading="lazy"` + `decoding="async"` on `<img>` — so the agent doesn't have to remember.
421421

422422
Other AssetRef fields for advanced use:
423423
- `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.

sdk/src/namespaces/blobs.test.ts

Lines changed: 84 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,15 @@ describe("blobs.put", () => {
9191
});
9292

9393
const sdk = makeSdk(fetch);
94-
const result = await sdk.blobs.put("prj_known", "hello.txt", { content: "hello world\n" });
94+
// Pass `immutable: false` explicitly: this test verifies the basic flow
95+
// shape (init → PUT → complete) and the legacy non-immutable behavior
96+
// (no SHA pre-computation, immutable: false propagated to the gateway).
97+
// The v1.45 default is `immutable: true`; the dedicated default-and-
98+
// tag-emitter tests below cover that path.
99+
const result = await sdk.blobs.put(
100+
"prj_known", "hello.txt", { content: "hello world\n" },
101+
{ immutable: false },
102+
);
95103

96104
assert.equal(calls.length, 3);
97105
assert.equal(calls[0]!.url, "https://api.example.test/storage/v1/uploads");
@@ -112,6 +120,52 @@ describe("blobs.put", () => {
112120
assert.equal(result.url, "https://cdn.test/hello.txt");
113121
});
114122

123+
it("defaults to immutable: true (v1.45) — computes SHA + sends content-hashed flag", async () => {
124+
let initBody: Record<string, unknown> | null = null;
125+
const { fetch } = mockFetch((call) => {
126+
if (call.url.endsWith("/storage/v1/uploads")) {
127+
initBody = JSON.parse(call.body as string);
128+
return json({
129+
upload_id: "u_d",
130+
mode: "single",
131+
part_count: 1,
132+
parts: [{ part_number: 1, url: "https://s3.test/u_d/p1", byte_start: 0, byte_end: 2 }],
133+
});
134+
}
135+
if (call.url.startsWith("https://s3.test/")) {
136+
return new Response("", { status: 200, headers: { etag: '"e"' } });
137+
}
138+
if (call.url.endsWith("/complete")) {
139+
return json({
140+
key: "hello.txt",
141+
size_bytes: 3,
142+
sha256: "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
143+
visibility: "public",
144+
content_type: "text/plain",
145+
immutable_suffix: "ba7816bf",
146+
url: "https://cdn.test/hello.txt",
147+
immutable_url: "https://cdn.test/hello-ba7816bf.txt",
148+
cdn_url: "https://pr-abc.run402.com/_blob/hello.txt",
149+
cdn_immutable_url: "https://pr-abc.run402.com/_blob/hello-ba7816bf.txt",
150+
});
151+
}
152+
throw new Error("unexpected: " + call.url);
153+
});
154+
const sdk = makeSdk(fetch);
155+
// No opts — relying on v1.45 defaults.
156+
const asset = await sdk.blobs.put("prj_known", "hello.txt", { content: "abc" });
157+
assert.equal(initBody!.immutable, true);
158+
assert.equal(
159+
initBody!.sha256,
160+
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
161+
);
162+
assert.ok(asset.cdnUrl, "cdnUrl populated by default");
163+
assert.ok(asset.sri, "sri populated by default");
164+
// Tag emitters work without the agent ever passing { immutable: true }.
165+
const tag = asset.scriptTag();
166+
assert.match(tag, /integrity="sha256-/);
167+
});
168+
115169
it("attaches parts with etags in multipart mode", async () => {
116170
const { fetch, calls } = mockFetch((call) => {
117171
if (call.url.endsWith("/storage/v1/uploads") && call.method === "POST") {
@@ -137,7 +191,12 @@ describe("blobs.put", () => {
137191
throw new Error("unexpected");
138192
});
139193
const sdk = makeSdk(fetch);
140-
await sdk.blobs.put("prj_known", "multi.bin", { bytes: new Uint8Array(12) });
194+
// Pin immutable: false — this test asserts on the multipart-complete
195+
// shape, not on the sha-computation path.
196+
await sdk.blobs.put(
197+
"prj_known", "multi.bin", { bytes: new Uint8Array(12) },
198+
{ immutable: false },
199+
);
141200
const completeBody = JSON.parse(calls[3]!.body as string);
142201
assert.deepEqual(completeBody.parts, [
143202
{ part_number: 1, etag: '"e1"' },
@@ -393,36 +452,53 @@ describe("blobs.put — AssetRef widening (v1.45)", () => {
393452
const sdk = makeSdk(fetch);
394453
const asset = await sdk.blobs.put("prj_known", "app.js", { content: "abc" }, { immutable: true });
395454

396-
// Default scriptTag.
455+
// Default scriptTag — emits `defer` by default (modern best practice).
397456
const tag = asset.scriptTag();
398457
assert.match(tag, /^<script /);
399458
assert.match(tag, /src="https:\/\/pr-abc\.run402\.com\/_blob\/app-ba7816bf\.js"/);
459+
assert.match(tag, /\bdefer\b/);
400460
assert.match(tag, /integrity="sha256-[A-Za-z0-9+/]+={0,2}"/);
401461
assert.match(tag, /crossorigin/);
462+
// No async by default.
463+
assert.equal(/\basync\b/.test(tag), false);
464+
465+
// Explicit defer: false opts out.
466+
const noDefer = asset.scriptTag({ defer: false });
467+
assert.equal(/\bdefer\b/.test(noDefer), false);
402468

403-
// Module + defer.
469+
// Module + explicit defer.
404470
const moduleTag = asset.scriptTag({ type: "module", defer: true });
405471
assert.match(moduleTag, /type="module"/);
406-
assert.match(moduleTag, / defer /);
472+
assert.match(moduleTag, /\bdefer\b/);
473+
474+
// async: true overrides defer (mutually exclusive per HTML spec).
475+
const asyncTag = asset.scriptTag({ async: true });
476+
assert.match(asyncTag, /\basync\b/);
477+
assert.equal(/\bdefer\b/.test(asyncTag), false);
407478

408479
// Default linkTag (stylesheet).
409480
const link = asset.linkTag();
410481
assert.match(link, /^<link /);
411482
assert.match(link, /rel="stylesheet"/);
412483
assert.match(link, /href="https:\/\/pr-abc\.run402\.com\/_blob\/app-ba7816bf\.js"/);
413484
assert.match(link, /integrity="sha256-/);
485+
assert.match(link, /crossorigin/);
414486

415-
// Custom rel + as (preload).
487+
// Custom rel + as (preload). crossorigin still emitted (required for
488+
// SRI to be enforced AND for preload-fetch deduping).
416489
const preload = asset.linkTag({ rel: "preload", as: "font" });
417490
assert.match(preload, /rel="preload"/);
418491
assert.match(preload, /as="font"/);
492+
assert.match(preload, /crossorigin/);
419493

420-
// imgTag with alt.
494+
// imgTag — emits loading="lazy" + decoding="async" by default.
421495
const img = asset.imgTag("Company logo");
422496
assert.match(img, /^<img /);
423497
assert.match(img, /src="https:\/\/pr-abc\.run402\.com\/_blob\/app-ba7816bf\.js"/);
424498
assert.match(img, /alt="Company logo"/);
425-
// No SRI on <img>.
499+
assert.match(img, /loading="lazy"/);
500+
assert.match(img, /decoding="async"/);
501+
// No SRI on <img> per HTML spec.
426502
assert.equal(/integrity=/.test(img), false);
427503
});
428504

sdk/src/namespaces/blobs.ts

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -201,17 +201,30 @@ function buildAssetRef(
201201
cdn,
202202

203203
scriptTag(opts) {
204+
// Default `defer: true` — modern best practice. Defer prevents
205+
// render-blocking when placed in <head> and is a no-op when placed
206+
// at the end of <body> (the script runs after DOMContentLoaded
207+
// either way). Pass `{ defer: false }` to opt out for the rare
208+
// case requiring synchronous execution. `async` and `defer` are
209+
// mutually exclusive; passing async overrides defer.
204210
const { url, sri } = requireImmutable("scriptTag");
205211
const attrs: string[] = [`src="${escapeHtmlAttr(url)}"`];
206212
if (opts?.type === "module") attrs.push(`type="module"`);
207-
if (opts?.defer) attrs.push("defer");
208-
if (opts?.async) attrs.push("async");
213+
const wantsAsync = opts?.async === true;
214+
const wantsDefer = opts?.defer ?? !wantsAsync;
215+
if (wantsAsync) attrs.push("async");
216+
else if (wantsDefer) attrs.push("defer");
209217
attrs.push(`integrity="${escapeHtmlAttr(sri)}"`);
210218
attrs.push("crossorigin");
211219
return `<script ${attrs.join(" ")}></script>`;
212220
},
213221

214222
linkTag(opts) {
223+
// Always emit crossorigin — required for SRI to actually be
224+
// enforced. Without crossorigin the browser silently ignores the
225+
// integrity attribute (HTML spec). This applies to rel="preload"
226+
// too: matching crossorigin on the preload + the eventual fetch is
227+
// what lets the browser dedupe instead of double-fetching.
215228
const { url, sri } = requireImmutable("linkTag");
216229
const rel = opts?.rel ?? "stylesheet";
217230
const attrs: string[] = [`rel="${escapeHtmlAttr(rel)}"`];
@@ -223,12 +236,18 @@ function buildAssetRef(
223236
},
224237

225238
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.
239+
// Defaults: loading="lazy" + decoding="async" — modern best
240+
// practice. Lazy is harmless for above-fold images (browsers
241+
// handle the heuristic) and a flat win for the much more common
242+
// below-fold case. Async decoding moves the decode off the main
243+
// thread. Both are baseline-supported in all major browsers.
244+
// <img> doesn't accept SRI per HTML5; the URL is content-hashed
245+
// so it's still stable across re-deploys. Agents who need
246+
// byte-level integrity for images should verify Content-Digest
247+
// server-side.
229248
const { url } = requireImmutable("imgTag");
230249
const a = alt ?? "";
231-
return `<img src="${escapeHtmlAttr(url)}" alt="${escapeHtmlAttr(a)}">`;
250+
return `<img src="${escapeHtmlAttr(url)}" alt="${escapeHtmlAttr(a)}" loading="lazy" decoding="async">`;
232251
},
233252
};
234253
}
@@ -270,7 +289,13 @@ export class Blobs {
270289
}
271290

272291
const contentType = opts.contentType ?? guessContentType(key);
273-
const sha256 = opts.immutable ? await sha256Hex(bytes) : undefined;
292+
// v1.45 default: `immutable: true`. The agent-DX surface (cdnUrl, sri,
293+
// scriptTag/linkTag/imgTag) only works for content-addressed uploads,
294+
// so the default reaches for the best path. Pass `{ immutable: false }`
295+
// explicitly when you specifically want a non-content-hashed URL
296+
// (e.g. very large file where you want to skip the SHA pass).
297+
const immutable = opts.immutable ?? true;
298+
const sha256 = immutable ? await sha256Hex(bytes) : undefined;
274299

275300
// 1. Init upload — gateway returns presigned S3 URLs for each part.
276301
const init = await this.client.request<UploadInitResponse>("/storage/v1/uploads", {
@@ -284,7 +309,7 @@ export class Blobs {
284309
size_bytes: sizeBytes,
285310
content_type: contentType,
286311
visibility: opts.visibility ?? "public",
287-
immutable: opts.immutable ?? false,
312+
immutable,
288313
sha256,
289314
},
290315
context: "initializing upload",

sdk/src/namespaces/blobs.types.ts

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,20 @@ export interface BlobPutOptions {
1919
contentType?: string;
2020
/** Default: `"public"`. Public blobs get a CDN URL; private requires auth. */
2121
visibility?: BlobVisibility;
22-
/** When true, the returned URL includes a content-hash suffix — overwrites produce distinct URLs. Forces sha256 computation. */
22+
/**
23+
* Default (v1.45+): `true`. Returns an `AssetRef` with `cdnUrl` populated
24+
* and the `scriptTag()` / `linkTag()` / `imgTag()` emitters working —
25+
* this is the agent-DX flow (paste-and-go HTML with SRI baked in).
26+
*
27+
* Cost: one SHA-256 pass over the bytes on the client side. For small
28+
* assets (the typical case — images, JS, CSS, fonts, JSON < 1 MB) it's
29+
* a few ms dominated by network. Pass `false` to skip the SHA pass for
30+
* very large uploads where you specifically don't need a content-hashed
31+
* URL or SRI.
32+
*
33+
* When `false`, the returned `AssetRef` has `cdnUrl: null`, `sri: null`,
34+
* and the tag emitters throw with an "immutable: true required" hint.
35+
*/
2336
immutable?: boolean;
2437
}
2538

@@ -130,32 +143,54 @@ export interface AssetRef {
130143
* URL + Subresource Integrity + `crossorigin`. The browser will refuse
131144
* to execute the script if the bytes don't match the SHA.
132145
*
146+
* **Defaults:** `defer: true`. Modern best practice — non-render-
147+
* blocking when placed in `<head>` and a no-op at end of `<body>`.
148+
* Pass `{ defer: false }` for the rare case requiring sync execution.
149+
* `async: true` overrides defer (the two are mutually exclusive).
150+
*
133151
* @example
134-
* const asset = await client.blobs.put(p, "app.js", { content }, { immutable: true });
152+
* const asset = await client.blobs.put(p, "app.js", { content });
135153
* html += asset.scriptTag();
136-
* // → <script src="https://pr-abc.run402.com/_blob/app-3a7fc02e.js" integrity="sha256-…" crossorigin></script>
154+
* // → <script src="https://pr-abc.run402.com/_blob/app-3a7fc02e.js" defer integrity="sha256-…" crossorigin></script>
155+
*
156+
* asset.scriptTag({ type: "module" });
157+
* // → <script src="…" type="module" defer integrity="…" crossorigin></script>
137158
*/
138159
scriptTag(opts?: { type?: "module" | "text/javascript"; defer?: boolean; async?: boolean }): string;
139160

140161
/**
141162
* Returns a ready-to-paste `<link>` tag (default `rel="stylesheet"`)
142-
* with content-addressed URL + SRI + `crossorigin`.
163+
* with content-addressed URL + SRI + `crossorigin`. `crossorigin` is
164+
* always emitted — required for SRI to actually be enforced (also
165+
* required for rel="preload" to dedupe with the matching fetch).
143166
*
144167
* @example
145-
* asset.linkTag(); // stylesheet by default
168+
* asset.linkTag(); // stylesheet
146169
* asset.linkTag({ rel: "preload", as: "font" });
170+
* asset.linkTag({ rel: "modulepreload" });
147171
*/
148172
linkTag(opts?: { rel?: string; as?: string }): string;
149173

150174
/**
151175
* Returns a ready-to-paste `<img>` tag with the content-addressed URL.
152-
* `alt` is the image's accessibility text (default `""`). Browsers don't
153-
* support SRI on `<img>`, so no `integrity` attribute is emitted —
154-
* integrity is still verifiable by reading `Content-Digest` server-side.
176+
*
177+
* **Defaults:** `loading="lazy"` + `decoding="async"`. Modern best
178+
* practice — lazy loads below-fold images on demand, async decoding
179+
* moves the decode off the main thread. Both are baseline-supported
180+
* in all major browsers. Agents who specifically need an above-fold
181+
* eager image can wrap the result and override.
182+
*
183+
* Browsers don't support SRI on `<img>`, so no `integrity` attribute
184+
* is emitted. The URL is content-hashed so it's still stable across
185+
* re-deploys; for byte-level verification, read `Content-Digest`
186+
* server-side.
187+
*
188+
* `alt` is the image's accessibility text (default `""` for decorative
189+
* images). Pass a description when the image conveys information.
155190
*
156191
* @example
157192
* asset.imgTag("Company logo")
158-
* // → <img src="https://pr-abc.run402.com/_blob/logo-a1b2c3d4.png" alt="Company logo">
193+
* // → <img src="https://pr-abc.run402.com/_blob/logo-a1b2c3d4.png" alt="Company logo" loading="lazy" decoding="async">
159194
*/
160195
imgTag(alt?: string): string;
161196
}

0 commit comments

Comments
 (0)