From 8a7465bdd4839f20d097d581b8f02441987fdcf0 Mon Sep 17 00:00:00 2001 From: Tal Weiss Date: Mon, 27 Apr 2026 21:43:38 +0200 Subject: [PATCH 1/2] feat(sdk): paste-and-go AssetRef with scriptTag/linkTag/imgTag emitters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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-.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) --- SKILL.md | 30 +++++-- cli/llms-cli.txt | 24 ++++-- sdk/src/namespaces/blobs.test.ts | 134 +++++++++++++++++++++++++++++- sdk/src/namespaces/blobs.ts | 70 +++++++++++++++- sdk/src/namespaces/blobs.types.ts | 102 ++++++++++++++++++----- 5 files changed, 323 insertions(+), 37 deletions(-) diff --git a/SKILL.md b/SKILL.md index 811bf6d3..bb4a4180 100644 --- a/SKILL.md +++ b/SKILL.md @@ -109,14 +109,32 @@ Upload a blob (any size, up to 5 TiB) to project storage via direct-to-S3 presig - `immutable` (optional, default: `false`) — If true with `sha256`, also produces a content-addressed URL that gets `Cache-Control: immutable`. - `sha256` (optional) — Required when `immutable: true`. Client-asserted hash; gateway verifies if S3 returns one. -**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. +**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-.run402.com`, `integrity` set to the SRI hash, and `crossorigin` set so browsers verify the bytes. -**Prefer `immutableUrl` in generated HTML/CSS/JS code.** Reasoning: -- 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 ` + +const styles = await client.blobs.put(projectId, "styles.css", { content }, { immutable: true }); +html += styles.linkTag(); +// → +``` + +**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-.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. + +**Other AssetRef fields** for advanced use: +- `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). +- `cdnMutableUrl` — mutable form of the auto-subdomain URL. Eventual-consistency caveats apply; prefer `cdnUrl` for generated code. +- `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 ` +html += asset.linkTag(); // +html += asset.imgTag("Company logo"); // Company logo +``` + +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. + +Other AssetRef fields for advanced use: +- `cdnUrl` — the content-addressed URL the emitters use directly. `https://pr-.run402.com/_blob/-<8hex>.`. Served via the v1.33 CDN; guaranteed-reachable. +- `cdnMutableUrl` — mutable URL on the auto-subdomain. Eventual-consistency caveats; prefer `cdnUrl` in code. +- `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 ``; + }, + + linkTag(opts) { + const { url, sri } = requireImmutable("linkTag"); + const rel = opts?.rel ?? "stylesheet"; + const attrs: string[] = [`rel="${escapeHtmlAttr(rel)}"`]; + attrs.push(`href="${escapeHtmlAttr(url)}"`); + if (opts?.as) attrs.push(`as="${escapeHtmlAttr(opts.as)}"`); + attrs.push(`integrity="${escapeHtmlAttr(sri)}"`); + attrs.push("crossorigin"); + return ``; + }, + + imgTag(alt) { + // doesn't take SRI per HTML5 spec — agents who need integrity + // for images should fetch + verify Content-Digest server-side. We + // still require immutable so the URL is stable across re-deploys. + const { url } = requireImmutable("imgTag"); + const a = alt ?? ""; + return `${escapeHtmlAttr(a)}`; + }, }; } diff --git a/sdk/src/namespaces/blobs.types.ts b/sdk/src/namespaces/blobs.types.ts index 24515ad3..c967cde8 100644 --- a/sdk/src/namespaces/blobs.types.ts +++ b/sdk/src/namespaces/blobs.types.ts @@ -50,21 +50,32 @@ export interface BlobCdnEnvelope { } /** - * Stable URL reference returned by `client.blobs.put`. Includes both the - * legacy snake_case fields (back-compat with pre-v1.45 SDK) and the v1.45+ - * agent-DX fields: + * Stable URL reference returned by `client.blobs.put`. * - * - `immutableUrl` — content-addressed URL; correct from upload time, no - * wait. Prefer this in generated HTML/CSS/JS code. - * - `etag`, `sri`, `contentDigest` — strong integrity headers derived from - * the SHA-256, suitable for ` + */ + scriptTag(opts?: { type?: "module" | "text/javascript"; defer?: boolean; async?: boolean }): string; + + /** + * Returns a ready-to-paste `` tag (default `rel="stylesheet"`) + * with content-addressed URL + SRI + `crossorigin`. + * + * @example + * asset.linkTag(); // stylesheet by default + * asset.linkTag({ rel: "preload", as: "font" }); + */ + linkTag(opts?: { rel?: string; as?: string }): string; + + /** + * Returns a ready-to-paste `` tag with the content-addressed URL. + * `alt` is the image's accessibility text (default `""`). Browsers don't + * support SRI on ``, so no `integrity` attribute is emitted — + * integrity is still verifiable by reading `Content-Digest` server-side. + * + * @example + * asset.imgTag("Company logo") + * // → Company logo + */ + imgTag(alt?: string): string; } /** From b5827a6be923ab843c93e48cc54cb074a5bb2eb4 Mon Sep 17 00:00:00 2001 From: Tal Weiss Date: Mon, 27 Apr 2026 21:56:08 +0200 Subject: [PATCH 2/2] =?UTF-8?q?feat(sdk):=20hide=20best-practice=20default?= =?UTF-8?q?s=20=E2=80=94=20immutable=3Dtrue,=20defer,=20lazy/async-decode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 , no-op at end of . 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) --- SKILL.md | 16 +++--- cli/llms-cli.txt | 10 ++-- sdk/src/namespaces/blobs.test.ts | 92 ++++++++++++++++++++++++++++--- sdk/src/namespaces/blobs.ts | 41 +++++++++++--- sdk/src/namespaces/blobs.types.ts | 53 +++++++++++++++--- 5 files changed, 174 insertions(+), 38 deletions(-) diff --git a/SKILL.md b/SKILL.md index bb4a4180..2e9813b9 100644 --- a/SKILL.md +++ b/SKILL.md @@ -106,21 +106,21 @@ Upload a blob (any size, up to 5 TiB) to project storage via direct-to-S3 presig - `content` (optional) — Inline content, ≤ 1 MB. Use only for small text blobs. - `content_type` (optional) — MIME type - `visibility` (optional, default: `"public"`) — `"public"` (bypasses auth) or `"private"` (requires apikey) -- `immutable` (optional, default: `false`) — If true with `sha256`, also produces a content-addressed URL that gets `Cache-Control: immutable`. -- `sha256` (optional) — Required when `immutable: true`. Client-asserted hash; gateway verifies if S3 returns one. +- `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). +- `sha256` (optional) — Computed automatically by the SDK when `immutable: true` (the default). You don't need to set this. -**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-.run402.com`, `integrity` set to the SRI hash, and `crossorigin` set so browsers verify the bytes. +**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-.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 ` +// → -const styles = await client.blobs.put(projectId, "styles.css", { content }, { immutable: true }); +const styles = await client.blobs.put(projectId, "styles.css", { content }); html += styles.linkTag(); // → ``` diff --git a/cli/llms-cli.txt b/cli/llms-cli.txt index 24608220..d82a19c1 100644 --- a/cli/llms-cli.txt +++ b/cli/llms-cli.txt @@ -411,13 +411,13 @@ run402 cdn wait-fresh https://app.run402.com/_blob/avatar.png --sha ba78... --ti `put` response (an `AssetRef`) — the agent-DX way: ```js -const asset = await client.blobs.put(projectId, key, { bytes }, { immutable: true }); -html += asset.scriptTag(); // -html += asset.linkTag(); // -html += asset.imgTag("Company logo"); // Company logo +const asset = await client.blobs.put(projectId, key, { bytes }); // v1.45 defaults to immutable: true +html += asset.scriptTag(); // +html += asset.linkTag(); // +html += asset.imgTag("Company logo"); // Company logo ``` -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. +`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 ``; }, linkTag(opts) { + // Always emit crossorigin — required for SRI to actually be + // enforced. Without crossorigin the browser silently ignores the + // integrity attribute (HTML spec). This applies to rel="preload" + // too: matching crossorigin on the preload + the eventual fetch is + // what lets the browser dedupe instead of double-fetching. const { url, sri } = requireImmutable("linkTag"); const rel = opts?.rel ?? "stylesheet"; const attrs: string[] = [`rel="${escapeHtmlAttr(rel)}"`]; @@ -223,12 +236,18 @@ function buildAssetRef( }, imgTag(alt) { - // doesn't take SRI per HTML5 spec — agents who need integrity - // for images should fetch + verify Content-Digest server-side. We - // still require immutable so the URL is stable across re-deploys. + // Defaults: loading="lazy" + decoding="async" — modern best + // practice. 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. + // doesn't accept SRI per HTML5; the URL is content-hashed + // so it's still stable across re-deploys. Agents who need + // byte-level integrity for images should verify Content-Digest + // server-side. const { url } = requireImmutable("imgTag"); const a = alt ?? ""; - return `${escapeHtmlAttr(a)}`; + return `${escapeHtmlAttr(a)}`; }, }; } @@ -270,7 +289,13 @@ export class Blobs { } const contentType = opts.contentType ?? guessContentType(key); - const sha256 = opts.immutable ? await sha256Hex(bytes) : undefined; + // v1.45 default: `immutable: true`. The agent-DX surface (cdnUrl, sri, + // scriptTag/linkTag/imgTag) only works for content-addressed uploads, + // so the default reaches for the best path. Pass `{ immutable: false }` + // explicitly when you specifically want a non-content-hashed URL + // (e.g. very large file where you want to skip the SHA pass). + const immutable = opts.immutable ?? true; + const sha256 = immutable ? await sha256Hex(bytes) : undefined; // 1. Init upload — gateway returns presigned S3 URLs for each part. const init = await this.client.request("/storage/v1/uploads", { @@ -284,7 +309,7 @@ export class Blobs { size_bytes: sizeBytes, content_type: contentType, visibility: opts.visibility ?? "public", - immutable: opts.immutable ?? false, + immutable, sha256, }, context: "initializing upload", diff --git a/sdk/src/namespaces/blobs.types.ts b/sdk/src/namespaces/blobs.types.ts index c967cde8..29aa3e4b 100644 --- a/sdk/src/namespaces/blobs.types.ts +++ b/sdk/src/namespaces/blobs.types.ts @@ -19,7 +19,20 @@ export interface BlobPutOptions { contentType?: string; /** Default: `"public"`. Public blobs get a CDN URL; private requires auth. */ visibility?: BlobVisibility; - /** When true, the returned URL includes a content-hash suffix — overwrites produce distinct URLs. Forces sha256 computation. */ + /** + * Default (v1.45+): `true`. Returns an `AssetRef` with `cdnUrl` populated + * and the `scriptTag()` / `linkTag()` / `imgTag()` emitters working — + * this is the agent-DX flow (paste-and-go HTML with SRI baked in). + * + * Cost: one SHA-256 pass over the bytes on the client side. For small + * assets (the typical case — images, JS, CSS, fonts, JSON < 1 MB) it's + * a few ms dominated by network. Pass `false` to skip the SHA pass for + * very large uploads where you specifically don't need a content-hashed + * URL or SRI. + * + * When `false`, the returned `AssetRef` has `cdnUrl: null`, `sri: null`, + * and the tag emitters throw with an "immutable: true required" hint. + */ immutable?: boolean; } @@ -130,32 +143,54 @@ export interface AssetRef { * URL + Subresource Integrity + `crossorigin`. The browser will refuse * to execute the script if the bytes don't match the SHA. * + * **Defaults:** `defer: true`. Modern best practice — non-render- + * blocking when placed in `` and a no-op at end of ``. + * Pass `{ defer: false }` for the rare case requiring sync execution. + * `async: true` overrides defer (the two are mutually exclusive). + * * @example - * const asset = await client.blobs.put(p, "app.js", { content }, { immutable: true }); + * const asset = await client.blobs.put(p, "app.js", { content }); * html += asset.scriptTag(); - * // → + * // → + * + * asset.scriptTag({ type: "module" }); + * // → */ scriptTag(opts?: { type?: "module" | "text/javascript"; defer?: boolean; async?: boolean }): string; /** * Returns a ready-to-paste `` tag (default `rel="stylesheet"`) - * with content-addressed URL + SRI + `crossorigin`. + * with content-addressed URL + SRI + `crossorigin`. `crossorigin` is + * always emitted — required for SRI to actually be enforced (also + * required for rel="preload" to dedupe with the matching fetch). * * @example - * asset.linkTag(); // stylesheet by default + * asset.linkTag(); // stylesheet * asset.linkTag({ rel: "preload", as: "font" }); + * asset.linkTag({ rel: "modulepreload" }); */ linkTag(opts?: { rel?: string; as?: string }): string; /** * Returns a ready-to-paste `` tag with the content-addressed URL. - * `alt` is the image's accessibility text (default `""`). Browsers don't - * support SRI on ``, so no `integrity` attribute is emitted — - * integrity is still verifiable by reading `Content-Digest` server-side. + * + * **Defaults:** `loading="lazy"` + `decoding="async"`. Modern best + * practice — lazy loads below-fold images on demand, async decoding + * moves the decode off the main thread. Both are baseline-supported + * in all major browsers. Agents who specifically need an above-fold + * eager image can wrap the result and override. + * + * Browsers don't support SRI on ``, so no `integrity` attribute + * is emitted. The URL is content-hashed so it's still stable across + * re-deploys; for byte-level verification, read `Content-Digest` + * server-side. + * + * `alt` is the image's accessibility text (default `""` for decorative + * images). Pass a description when the image conveys information. * * @example * asset.imgTag("Company logo") - * // → Company logo + * // → Company logo */ imgTag(alt?: string): string; }