|
| 1 | +# @run402/astro |
| 2 | + |
| 3 | +One-line Astro integration for Run402 image variants. Drop `<Image>` into your templates and get the v1.49 WebP variant ladder, HEIC `display_jpeg`, blurhash placeholder, and CDN-served immutable URLs - zero runtime function cost. |
| 4 | + |
| 5 | +## Why |
| 6 | + |
| 7 | +Run402 v1.49 pre-encodes 3 WebP variants (320w / 800w / 1920w) + a display-friendly JPEG for HEIC sources + a blurhash placeholder for every image uploaded via the assets slice. Variants serve from CloudFront like any other static URL. This package wires that pipeline into Astro's build: walk your `<Image>` references, upload each unique source, render `<picture>` markup that consumes the variants. |
| 8 | + |
| 9 | +Compared to Next.js's `<Image>` model: Vercel transforms images lazily via Lambda on cache miss. Run402's variants are encoded once at upload time and served as static immutable assets - **no per-request transform cost**. |
| 10 | + |
| 11 | +## Install |
| 12 | + |
| 13 | +```sh |
| 14 | +npm install @run402/astro @run402/sdk |
| 15 | +``` |
| 16 | + |
| 17 | +Astro 5 or 6 (peer dependency, optional declaration so install never blocks). |
| 18 | + |
| 19 | +## Configure |
| 20 | + |
| 21 | +```js |
| 22 | +// astro.config.mjs |
| 23 | +import { defineConfig } from 'astro/config'; |
| 24 | +import { run402 } from '@run402/astro'; |
| 25 | + |
| 26 | +export default defineConfig({ |
| 27 | + integrations: [run402()], |
| 28 | +}); |
| 29 | +``` |
| 30 | + |
| 31 | +Set `RUN402_PROJECT_ID` in your environment (or pass `run402({ projectId: 'prj_...' })`). |
| 32 | + |
| 33 | +In CI, the integration auto-detects GitHub OIDC credentials when the workflow has `id-token: write` permission and a Run402 CI binding for the project. |
| 34 | + |
| 35 | +## Use |
| 36 | + |
| 37 | +```astro |
| 38 | +--- |
| 39 | +import { Image } from '@run402/astro/Image.astro'; |
| 40 | +--- |
| 41 | +<Image src="./images/hero.jpg" alt="Sunset over the Pacific" sizes="100vw" priority /> |
| 42 | +
|
| 43 | +<Image src="./images/team-photo.heic" alt="Team retreat 2026" sizes="(min-width: 768px) 50vw, 100vw" /> |
| 44 | +``` |
| 45 | + |
| 46 | +`src` is resolved relative to the importing `.astro` file. TypeScript path aliases (`@/*`) also work if you have them in `tsconfig.json`. |
| 47 | + |
| 48 | +## Generated HTML |
| 49 | + |
| 50 | +For an image source with v1.49 variants (≥ 320 pixels on both axes), the component emits: |
| 51 | + |
| 52 | +```html |
| 53 | +<picture> |
| 54 | + <source type="image/webp" |
| 55 | + srcset="https://cdn.run402.com/.../hero-thumb.webp 320w, |
| 56 | + https://cdn.run402.com/.../hero-medium.webp 800w, |
| 57 | + https://cdn.run402.com/.../hero-large.webp 1920w" |
| 58 | + sizes="100vw" /> |
| 59 | + <img src="https://cdn.run402.com/.../hero.jpg" |
| 60 | + alt="Sunset over the Pacific" |
| 61 | + width="1600" |
| 62 | + height="1200" |
| 63 | + loading="eager" |
| 64 | + fetchpriority="high" |
| 65 | + style="background-image:url(data:image/png;base64,...);" /> |
| 66 | +</picture> |
| 67 | +``` |
| 68 | + |
| 69 | +Width/height attributes prevent cumulative layout shift. The inlined blurhash data URI provides a low-quality image placeholder while the real bytes load. |
| 70 | + |
| 71 | +For HEIC sources, the `<img>` fallback uses the generated `display_jpeg` variant (so non-HEIC-capable browsers - everything before Safari 14 - still render). The original HEIC bytes are preserved in CAS but never served via `<img>`. |
| 72 | + |
| 73 | +For sources smaller than 320 pixels on either axis (logos, icons), the component falls back to a single `<img>` with a build warning. |
| 74 | + |
| 75 | +## Props |
| 76 | + |
| 77 | +| Prop | Type | Default | Notes | |
| 78 | +|---|---|---|---| |
| 79 | +| `src` | `string` | required | Path relative to the importing file. Leading slashes are rejected. | |
| 80 | +| `alt` | `string` | required | Alt text. Escaped for HTML. | |
| 81 | +| `sizes` | `string` | `"100vw"` | Passed through to the `<source>` element. | |
| 82 | +| `priority` | `boolean` | `false` | Above-the-fold opt-in: emits `loading="eager"` + `fetchpriority="high"`. | |
| 83 | +| `loading` | `"lazy" \| "eager"` | `"lazy"` | Ignored when `priority` is set. | |
| 84 | +| `width` | `number` | source width | Override width; height auto-recomputed preserving aspect ratio. | |
| 85 | +| `height` | `number` | source height | Override height; width auto-recomputed preserving aspect ratio. | |
| 86 | +| `class` | `string` | — | Passthrough to `<img>`. | |
| 87 | +| `placeholder` | `"blurhash" \| "color" \| "none"` | `"blurhash"` | LQIP strategy. | |
| 88 | + |
| 89 | +## Integration options |
| 90 | + |
| 91 | +```js |
| 92 | +run402({ |
| 93 | + projectId: 'prj_...', // overrides RUN402_PROJECT_ID env var |
| 94 | + assetPrefix: 'astro/', // key prefix for uploaded blobs |
| 95 | + dryRun: false, // when true, log references but don't upload |
| 96 | + verbose: false, // print per-image upload events to stderr |
| 97 | +}) |
| 98 | +``` |
| 99 | + |
| 100 | +## Build cache |
| 101 | + |
| 102 | +On first build, every unique source is uploaded. Subsequent builds against unchanged sources are essentially free - the cache at `node_modules/.run402/assetMap.json` is keyed by source SHA-256. The cache directory is gitignored on first write (entry appended to project-root `.gitignore`). |
| 103 | + |
| 104 | +Re-deploys with unchanged bytes: |
| 105 | +- CAS dedup at the gateway means S3 stores one copy of each unique sha |
| 106 | +- The encoder is a no-op for `(project, sha, v1)` tuples already present |
| 107 | +- `bytes_reused` reflects the cached set; `bytes_uploaded` reflects new work only |
| 108 | + |
| 109 | +## Dry run |
| 110 | + |
| 111 | +```sh |
| 112 | +ASTRO_INTEGRATIONS_LOG=true astro build |
| 113 | +``` |
| 114 | + |
| 115 | +Or programmatically: |
| 116 | + |
| 117 | +```js |
| 118 | +run402({ dryRun: true }) |
| 119 | +``` |
| 120 | + |
| 121 | +Walks the project, lists every `<Image>` reference with its sha256 prefix and file size, estimates upload duration based on the v1.49 encoder semaphore (2 concurrent, ~10s per encode), and exits without uploading. |
| 122 | + |
| 123 | +## Error handling |
| 124 | + |
| 125 | +The integration fails the build (rather than silently falling back) when: |
| 126 | + |
| 127 | +- `<Image src="/absolute">` - leading-slash paths refer to `public/` and bypass the variant pipeline |
| 128 | +- Source file does not exist |
| 129 | +- Extension is not one of `.jpg / .jpeg / .png / .webp / .avif / .heic / .heif` |
| 130 | +- Gateway returns `IMAGE_DECODE_FAILED`, `IMAGE_INPUT_TOO_LARGE`, `IMAGE_ENCODE_TIMEOUT`, `QUOTA_EXCEEDED` |
| 131 | +- Encoder queue stays full across 3 retries (`TOO_MANY_ENCODES_QUEUED`) |
| 132 | + |
| 133 | +Each error names the offending file path so the build log points you at the right line. |
| 134 | + |
| 135 | +## What this package does NOT do (v0.1) |
| 136 | + |
| 137 | +- **Dynamic `src` expressions.** Only string literals are extracted. `<Image src={myImage}>` emits a build warning and skips that reference. v0.1 is for build-time-known image references; runtime-dynamic images (CMS-driven) keep using `r.assets.put` server-side. |
| 138 | +- **Arbitrary widths.** The variant ladder is the v1.49 fixed set (320 / 800 / 1920). No `?w=437` lazy transforms. |
| 139 | +- **Edge content negotiation.** No CloudFront-side variant routing. The `<picture>` element does the negotiation client-side via standard HTML semantics. |
| 140 | + |
| 141 | +## Known limitations |
| 142 | + |
| 143 | +- Astro auto-copies `public/` into `dist/`. The integration filters out any `public/`-located image that's referenced via `<Image>`, but a `public/`-located image NOT referenced via `<Image>` still ships in `dist/` (and via `deployment_files`). If you want all images to go through variants, keep them under `src/images/` not `public/images/`. |
| 144 | +- New images added during `astro dev` require a dev server restart. Subsequent builds pick them up automatically. |
| 145 | + |
| 146 | +## License |
| 147 | + |
| 148 | +MIT |
0 commit comments