Skip to content

Commit 600d97f

Browse files
committed
feat(astro): introduce @run402/astro 0.1.0
Astro integration package that ships <Image> as the framework-level wrapper around v1.49 image variants. Build-time-only resolution: the Vite plugin discovers <Image src=...> references at buildStart, uploads each unique source via r.assets.put with CAS dedup, populates an in-memory AssetRef registry keyed by absolute path, and rewrites template source bytes to substitute resolved paths. The component emits <picture> with the WebP variant ladder (320/800/1920), display_jpeg fallback for HEIC sources, width/height attrs for CLS prevention, and an inlined blurhash placeholder. Build cache at node_modules/.run402/assetMap.json (gitignored on first write) makes re-builds against unchanged sources free. Adds /publish-astro skill in .claude/skills + .claude/commands for the publish workflow (separate cadence from the mcp/cli/sdk lockstep).
1 parent c1c8489 commit 600d97f

28 files changed

Lines changed: 3369 additions & 2 deletions

.claude/commands/publish-astro.md

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# /publish-astro — Publish @run402/astro to npm
2+
3+
Publish the `@run402/astro` Astro integration package from `astro/` in this repo. Unlike `@run402/sdk`, `run402` CLI, and `run402-mcp` (which lockstep via `/publish`), `@run402/astro` has its own versioning cadence because it's a framework adapter, not part of the core SDK release train.
4+
5+
> **`@run402/functions` is published from the private gateway monorepo** (`kychee-com/run402-private` via `/publish-functions`). This skill is only for `@run402/astro`.
6+
7+
Stop on any failure. Do NOT skip checks.
8+
9+
## When to publish
10+
11+
- New `<Image>` component prop, integration option, or behavior change → publish
12+
- Bug fix to the resolver / scanner / uploader / picture-builder → publish patch
13+
- Compat fix for a new Astro major version → publish minor (after testing against the new major)
14+
- Documentation-only README fix → publish at your discretion (no functional change)
15+
16+
The package has **no runtime relationship with the gateway** — there is no equivalent of `@run402/functions`'s "gateway redeploy alone propagates the fix." A consumer must bump their `@run402/astro` dependency to see any change.
17+
18+
## Pre-publish checks
19+
20+
1. **On main, in sync with origin/main.** `git rev-parse --abbrev-ref HEAD` must be `main`. `git fetch origin main && git rev-list --count HEAD..origin/main` must be `0`. If not, stop and tell the user.
21+
2. **Working tree clean** (`git status`). Stop if not.
22+
3. **Unit tests pass:** `npm test --workspace=astro`. Expect 50+ tests, 0 failures.
23+
4. **Type-check clean:** `npx tsc --noEmit -p astro`. No output = clean.
24+
5. **Build the package:** `npm run build --workspace=astro`. Confirms `dist/` is current.
25+
26+
If any step fails, stop and tell the user.
27+
28+
## Version bump
29+
30+
1. Ask the user: **patch, minor, or major.**
31+
- Patch (`0.1.0 → 0.1.1`): bug fix, no surface change.
32+
- Minor (`0.1.0 → 0.2.0`): new prop, new integration option, or new optional behavior. Backwards-compatible.
33+
- Major (`0.1.0 → 1.0.0` or `1.0.0 → 2.0.0`): breaking prop change, removed option, or behavior change that requires consumer code changes. v0.x → v1.x is the "stable surface" promotion.
34+
2. Read current version from `astro/package.json`.
35+
3. Compute target: apply bump kind. Apply directly to `astro/package.json` (use Edit, not `npm version``npm version` from inside a workspace can have surprising lockfile behavior).
36+
4. `npm install --package-lock-only` from repo root to sync `package-lock.json`.
37+
38+
## Tarball smoke test
39+
40+
`npm test` runs against source, not the packed tarball. Pack it and verify the entry points resolve:
41+
42+
```
43+
SMOKE=/tmp/smoke-astro-<new_version> && rm -rf $SMOKE && mkdir $SMOKE
44+
(cd astro && npm pack --pack-destination $SMOKE)
45+
mkdir $SMOKE/astro && tar xzf $SMOKE/run402-astro-<new_version>.tgz -C $SMOKE/astro
46+
(cd $SMOKE/astro/package && npm install --omit=dev --before=9999-12-31)
47+
node -e "import('$SMOKE/astro/package/dist/index.js').then(m => console.log('OK', typeof m.run402)).catch(e => { console.error('FAIL', e.message); process.exit(1) })"
48+
```
49+
50+
Expect `OK function`. The script exits non-zero on any failure.
51+
52+
**Also verify the .astro component file ships:**
53+
54+
```
55+
ls $SMOKE/astro/package/src/Image.astro
56+
```
57+
58+
Must print the path. If the file is missing, the `files` allowlist in `package.json` is broken — do not publish.
59+
60+
**Also verify the tarball does NOT include source `.ts` files:**
61+
62+
```
63+
find $SMOKE/astro/package -name "*.ts" -not -name "*.d.ts" | head
64+
```
65+
66+
Should print nothing (only `.d.ts` types ship). Source `.ts` files in the tarball means `files` allowlist is wrong.
67+
68+
**About `--before=9999-12-31`:** if the user's global npm has a `before` date pinned (supply-chain mitigation), the scratch install needs this flag to bypass it for `/tmp` installs. Do **not** suggest removing the global config.
69+
70+
## Commit and publish
71+
72+
1. Stage and commit the version bump:
73+
```
74+
git add astro/package.json package-lock.json
75+
git commit -m "chore(astro): bump @run402/astro to v<new_version>"
76+
```
77+
2. Publish:
78+
```
79+
cd astro && npm publish --access public
80+
```
81+
The `publishConfig.access: public` field is set in `package.json`, so the explicit flag is redundant but harmless. The tarball contains `dist/`, `src/Image.astro`, and `README.md` only (per the `files` allowlist; `node_modules`, tests, and `*.test.*` are excluded).
82+
83+
## Post-publish
84+
85+
1. `git push` to push the version bump commit.
86+
2. Create a git tag:
87+
```
88+
git tag v<new_version>-astro && git push --tags
89+
```
90+
Use the `-astro` suffix because the other public-repo packages (`run402-mcp`, `run402`, `@run402/sdk`) have their own tag scheme; this clarifies which package the tag belongs to.
91+
3. Create a GitHub release (public repo):
92+
```
93+
gh release create v<new_version>-astro -R kychee-com/run402 --notes "..."
94+
```
95+
Write a human-readable summary naming user-facing changes. Don't rely on auto-generated notes.
96+
4. **Verify live on npm:**
97+
```
98+
npm view @run402/astro@<new_version> version
99+
```
100+
Should print the new version. May take up to a minute to propagate.
101+
5. **Update `documentation.md`** if the prop surface or integration options changed. The doc serves as the canonical reference for the public repo. Commit + push.
102+
6. **Update `llms-full.txt` in the private repo** (`kychee-com/run402-private` at `site/llms-full.txt`) if the Astro integration section needs to point at a new version or document a new feature. The site at https://run402.com/llms-full.txt is regenerated from there; trigger a redeploy after the update:
103+
```
104+
gh workflow run deploy-site.yml -R kychee-com/run402-private
105+
```
106+
7. Print a summary:
107+
- Published version
108+
- npm URL: `https://www.npmjs.com/package/@run402/astro`
109+
- GitHub release URL
110+
111+
## What this skill does NOT do
112+
113+
- Does NOT bump `mcp`, `cli`, or `sdk`. Those lockstep via `/publish`.
114+
- Does NOT bump `@run402/functions`. That lives in the private gateway monorepo and ships via `/publish-functions` there.
115+
- Does NOT trigger any gateway redeploy. There is no gateway-side dependency on this package.
116+
- Does NOT migrate consumer projects. After publishing, consumers (Kychon, etc.) update their `@run402/astro` dependency on their own cadence.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
name: publish-astro
3+
description: Publish @run402/astro to npm from this monorepo. Use when the user says /publish-astro or asks to publish the Astro integration package. Covers version bump, tarball smoke test, publish, tag, and post-publish verification.
4+
---
5+
6+
# /publish-astro
7+
8+
This skill wraps the repo's Claude publish-astro command.
9+
10+
When invoked, read `../../commands/publish-astro.md` and follow it exactly. Treat that file as the source of truth for the publish workflow, including its pre-flight checks, version-bump rules, tarball smoke test, publish step, and post-publish verification.
11+
12+
If `../../commands/publish-astro.md` is missing or unreadable, stop and report that the command source is missing.

astro/README.md

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
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

astro/package.json

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
{
2+
"name": "@run402/astro",
3+
"version": "0.1.0",
4+
"description": "Astro integration + <Image> component for Run402. One-line wiring for pre-encoded image variants (3-width WebP ladder + HEIC display_jpeg + blurhash + width/height) with zero runtime function cost.",
5+
"type": "module",
6+
"main": "dist/index.js",
7+
"types": "dist/index.d.ts",
8+
"exports": {
9+
".": {
10+
"types": "./dist/index.d.ts",
11+
"import": "./dist/index.js"
12+
},
13+
"./Image.astro": "./src/Image.astro"
14+
},
15+
"files": [
16+
"dist",
17+
"src/Image.astro",
18+
"!dist/*.test.*",
19+
"README.md"
20+
],
21+
"scripts": {
22+
"build": "tsc",
23+
"test": "node --experimental-test-module-mocks --test --import tsx src/resolver.test.ts src/cache.test.ts src/scanner.test.ts src/uploader.test.ts src/component.test.ts"
24+
},
25+
"engines": {
26+
"node": ">=18"
27+
},
28+
"peerDependencies": {
29+
"astro": ">=5 <7",
30+
"@run402/sdk": ">=2.3"
31+
},
32+
"peerDependenciesMeta": {
33+
"@run402/sdk": {
34+
"optional": true
35+
},
36+
"astro": {
37+
"optional": true
38+
}
39+
},
40+
"dependencies": {
41+
"blurhash": "^2.0.5"
42+
},
43+
"devDependencies": {
44+
"@types/node": "^22.0.0",
45+
"tsx": "^4.20.0",
46+
"typescript": "^5.5.0"
47+
},
48+
"repository": {
49+
"type": "git",
50+
"url": "git+https://github.com/kychee-com/run402.git",
51+
"directory": "astro"
52+
},
53+
"homepage": "https://run402.com",
54+
"bugs": "https://github.com/kychee-com/run402/issues",
55+
"author": "Kychee Technologies",
56+
"license": "MIT",
57+
"keywords": [
58+
"run402",
59+
"astro",
60+
"astro-integration",
61+
"image",
62+
"image-optimization",
63+
"webp",
64+
"heic",
65+
"blurhash",
66+
"responsive-images"
67+
],
68+
"publishConfig": {
69+
"access": "public"
70+
}
71+
}

astro/src/Image.astro

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
/**
3+
* <Image> component for @run402/astro.
4+
*
5+
* Consumes an AssetRef from the build-time registry (populated by the
6+
* `run402()` integration's Vite plugin during `buildStart`) and emits a
7+
* <picture> with the WebP variant ladder, an <img> fallback (or HEIC
8+
* display_jpeg fallback), width/height attributes for CLS prevention, and
9+
* a blurhash placeholder via inline background-image.
10+
*
11+
* USAGE
12+
*
13+
* ```astro
14+
* ---
15+
* import { Image } from '@run402/astro/Image.astro';
16+
* ---
17+
* <Image src="./images/hero.jpg" alt="Sunset over the Pacific" sizes="100vw" />
18+
* ```
19+
*
20+
* The `src` prop is a string path relative to THIS .astro file. The Vite
21+
* plugin rewrites it to an absolute filesystem path at build time so this
22+
* component can look up the registry by absolute path. Users do not see
23+
* the rewrite; their source remains the relative-path form.
24+
*/
25+
import { buildPictureHtml } from "./picture-builder.js";
26+
import { getAssetRef } from "./registry.js";
27+
import { MissingAssetRefError } from "./errors.js";
28+
import type { ImageProps } from "./types.js";
29+
30+
interface Props extends ImageProps {}
31+
32+
const props = Astro.props as Props;
33+
34+
const ref = getAssetRef(props.src);
35+
if (!ref) {
36+
throw new MissingAssetRefError(props.src, props.src);
37+
}
38+
39+
const { html, warnings } = buildPictureHtml({ ref, props });
40+
for (const w of warnings) {
41+
// Astro's logger isn't trivially accessible from a component, so warn
42+
// via stderr. The build aggregates these in the run log.
43+
// eslint-disable-next-line no-console
44+
console.warn(`[run402-astro] ${w}`);
45+
}
46+
---
47+
48+
<Fragment set:html={html} />

0 commit comments

Comments
 (0)