@netlify/agent-runner-cli 2.7.0 → 2.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin-local.js +160 -160
- package/dist/bin.js +199 -199
- package/dist/index.js +157 -157
- package/dist/skills/netlify-access-control/SKILL.md +136 -0
- package/dist/skills/netlify-access-control/references/two-layer-pattern.md +69 -0
- package/dist/skills/netlify-forms/SKILL.md +139 -283
- package/dist/skills/netlify-image-cdn/SKILL.md +116 -175
- package/dist/skills/netlify-image-cdn/references/user-uploads.md +156 -0
- package/package.json +2 -1
- package/scripts/build-skills.js +52 -0
- package/scripts/scenarios/install-skills.mjs +16 -6
|
@@ -1,244 +1,185 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: netlify-image-cdn
|
|
3
|
-
description:
|
|
3
|
+
description: Transforms images on demand via Netlify Image CDN's /.netlify/images endpoint with query parameters for resizing/cropping/format/quality. Use when adding image optimization or responsive images, converting formats (WebP/AVIF/PNG), generating thumbnails or blur placeholders, serving remote/third-party images through the CDN, allowlisting remote domains in netlify.toml, setting up image redirects or cache headers, building user-uploaded image pipelines, or debugging a 404 on /.netlify/images. Also covers framework image handling for Angular/Astro/Gatsby/Next.js/Nuxt.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Netlify Image CDN
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
content negotiation to serve the most efficient format and caches transformed images for fast delivery.
|
|
8
|
+
Transform images by requesting the endpoint with a `url` query parameter. This is the current and only documented surface — there is no legacy form.
|
|
10
9
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
All Netlify projects have a `/.netlify/images` route without any additional enablement. Use it with query parameters to
|
|
14
|
-
transform any image:
|
|
15
|
-
|
|
16
|
-
```html
|
|
17
|
-
<!-- Resize to 400px wide, convert to WebP -->
|
|
18
|
-
<img src="/.netlify/images?url=/photo.jpg&w=400&fm=webp" alt="Optimized photo" />
|
|
19
|
-
|
|
20
|
-
<!-- Cover crop to 200x200 square -->
|
|
21
|
-
<img src="/.netlify/images?url=/photo.jpg&w=200&h=200&fit=cover" alt="Cropped photo" />
|
|
22
|
-
|
|
23
|
-
<!-- Transform a remote image -->
|
|
24
|
-
<img src="/.netlify/images?url=https://example.com/image.jpg&w=800&q=80" alt="Remote image" />
|
|
10
|
+
```
|
|
11
|
+
GET /.netlify/images?url=<source>[&w=][&h=][&fit=][&position=][&fm=][&q=]
|
|
25
12
|
```
|
|
26
13
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|-----------|----------|------|-------------|
|
|
31
|
-
| `url` | Yes | `string` | Source image path (relative) or URL (remote, requires config) |
|
|
32
|
-
| `w` | No | `integer` | Width in pixels |
|
|
33
|
-
| `h` | No | `integer` | Height in pixels |
|
|
34
|
-
| `fit` | No | `string` | Resize mode: `contain` (default), `cover`, `fill` |
|
|
35
|
-
| `position` | No | `string` | Crop anchor for `fit=cover`: `center` (default), `top`, `bottom`, `left`, `right` |
|
|
36
|
-
| `fm` | No | `string` | Output format: `avif`, `jpg`, `png`, `webp`, `gif`, `blurhash` |
|
|
37
|
-
| `q` | No | `integer` | Quality 1-100 for lossy formats (default: 75) |
|
|
38
|
-
|
|
39
|
-
The `url` value must be URI-encoded when it contains special characters.
|
|
40
|
-
|
|
41
|
-
## Fit Modes
|
|
42
|
-
|
|
43
|
-
| `fit=` | Aspect Ratio Maintained | Crops | Always Returns Exact Dimensions |
|
|
44
|
-
|--------|------------------------|-------|-------------------------------|
|
|
45
|
-
| `contain` (default) | Yes | No | No, one dimension may be smaller |
|
|
46
|
-
| `cover` | Yes | Yes | Yes, crops excess after proportional resize |
|
|
47
|
-
| `fill` | No | No | Yes, stretches/squishes to exact dimensions |
|
|
48
|
-
|
|
49
|
-
### `fit=contain` (default)
|
|
50
|
-
|
|
51
|
-
Resizes proportionally to fit within the specified dimensions. If only one dimension is given, the other is calculated
|
|
52
|
-
to maintain aspect ratio.
|
|
53
|
-
|
|
54
|
-
```html
|
|
55
|
-
<img src="/.netlify/images?url=/photo.jpg&w=600&h=400&fit=contain" />
|
|
14
|
+
```bash
|
|
15
|
+
# resize a deployed image to 50px wide
|
|
16
|
+
curl -vs 'https://mysitename.netlify.app/.netlify/images?url=/owl.jpeg&w=50'
|
|
56
17
|
```
|
|
57
18
|
|
|
58
|
-
|
|
19
|
+
`url` is required; all other parameters are optional.
|
|
59
20
|
|
|
60
|
-
|
|
61
|
-
to control which area is retained.
|
|
21
|
+
## Query parameters
|
|
62
22
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
23
|
+
| Parameter | Purpose | Values | Default |
|
|
24
|
+
|-----------|---------|--------|---------|
|
|
25
|
+
| `url` | Source asset (required) | Relative path or remote URL | — |
|
|
26
|
+
| `w` | Width in pixels | Integer | — |
|
|
27
|
+
| `h` | Height in pixels | Integer | — |
|
|
28
|
+
| `fit` | Resize behavior | `contain`, `cover`, `fill` | `contain` |
|
|
29
|
+
| `position` | Crop anchor when `fit=cover` | `top`, `bottom`, `left`, `right`, `center` | `center` |
|
|
30
|
+
| `fm` | Output format | `avif`, `jpg`, `png`, `webp`, `gif`, `blurhash` | content-negotiated |
|
|
31
|
+
| `q` | Quality for lossy output | Integer `1`–`100` | `75` |
|
|
32
|
+
|
|
33
|
+
## Common transformations
|
|
66
34
|
|
|
67
|
-
|
|
35
|
+
```bash
|
|
36
|
+
# resize + crop to a 50px square, retaining the left side
|
|
37
|
+
curl -vs 'https://mysitename.netlify.app/.netlify/images?url=/owl.jpeg&fit=cover&w=50&h=50&position=left'
|
|
68
38
|
|
|
69
|
-
|
|
39
|
+
# convert JPEG to PNG (response carries content-type: image/png)
|
|
40
|
+
curl -vs 'https://mysitename.netlify.app/.netlify/images?url=/owl.jpeg&fm=png'
|
|
70
41
|
|
|
71
|
-
|
|
72
|
-
|
|
42
|
+
# convert JPEG to AVIF at medium quality
|
|
43
|
+
curl -vs 'https://mysitename.netlify.app/.netlify/images?url=/owl.jpeg&fm=avif&q=50'
|
|
73
44
|
```
|
|
74
45
|
|
|
75
|
-
|
|
46
|
+
### fit behavior
|
|
76
47
|
|
|
77
|
-
|
|
48
|
+
- **`contain` (default):** maintains aspect ratio; one dimension may come back smaller than requested. Supply one dimension and the other is computed.
|
|
49
|
+
- **`cover`:** fills exactly, cropping excess. **Requires BOTH `w` and `h`** — omitting either is invalid. Use `position` to choose what's retained.
|
|
50
|
+
- **`fill`:** fills exactly, stretching/squishing if aspect ratios differ.
|
|
78
51
|
|
|
79
|
-
|
|
80
|
-
2. Otherwise use `avif` if accepted
|
|
81
|
-
3. Otherwise serve the original format
|
|
52
|
+
### Format notes
|
|
82
53
|
|
|
83
|
-
|
|
54
|
+
- `q` applies only when output is `avif`, `jpg`, `gif`, or `webp`.
|
|
55
|
+
- `webp` and `gif` can be static or animated.
|
|
56
|
+
- If `fm` is omitted, format is content-negotiated from the `Accept` header: `webp` if accepted, else `avif` if accepted, else the original format. A source-only request (no other params) still converts to `webp`/`avif` but keeps size and shape.
|
|
84
57
|
|
|
85
|
-
## Remote
|
|
58
|
+
## Remote source images
|
|
86
59
|
|
|
87
|
-
|
|
60
|
+
Remote sources must be allowlisted in `netlify.toml` before transformation, or the request fails.
|
|
88
61
|
|
|
89
62
|
```toml
|
|
90
63
|
[images]
|
|
91
|
-
remote_images = [
|
|
92
|
-
"https://my-images\\.com/.*",
|
|
93
|
-
"https://animals\\.more-images\\.com/[bcr]at/.*"
|
|
94
|
-
]
|
|
64
|
+
remote_images = ['https://my-images\.com/.*', 'https://animals.more-images.com/[bcr]at/.*']
|
|
95
65
|
```
|
|
96
66
|
|
|
97
|
-
|
|
67
|
+
Percent-encode remote source URLs before placing them in the `url` parameter with `encodeURIComponent` — a URL containing `?` or `&` breaks otherwise.
|
|
98
68
|
|
|
99
|
-
```
|
|
100
|
-
|
|
69
|
+
```js
|
|
70
|
+
const src = `/.netlify/images?url=${encodeURIComponent('https://my-images.com/owl.jpeg?v=2')}&w=400`;
|
|
101
71
|
```
|
|
102
72
|
|
|
103
|
-
|
|
73
|
+
Constraints:
|
|
74
|
+
- Remote sources must be **publicly accessible**.
|
|
75
|
+
- Credential-bearing headers (`Authorization`, `Cookie`) are **NOT forwarded** when fetching a remote source. For authenticated sources, use URLs that carry their own authorization (e.g. S3 presigned URLs) and make sure your `remote_images` patterns match those full URLs.
|
|
104
76
|
|
|
105
|
-
|
|
77
|
+
### remote_images regex escaping
|
|
106
78
|
|
|
107
|
-
|
|
108
|
-
URL. When doing so, the parameters can remain parameters to pass in or can be statically defined.
|
|
79
|
+
The only meaningful escape is the literal dot (`\.`). Forward slashes are NOT metacharacters — never write `https:\/\/`. In `netlify.toml`, use single-quoted literal strings (`'https://example\.com/.*'`) or double the backslash in double-quoted strings (`"https://example\\.com/.*"`). A bare `\.` inside double quotes is invalid TOML.
|
|
109
80
|
|
|
110
|
-
|
|
81
|
+
## Response codes
|
|
111
82
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
```
|
|
83
|
+
- Invalid transformation parameter values → `404`.
|
|
84
|
+
- Valid new transformation → `200` with content and matching `content-type`.
|
|
85
|
+
- Previously transformed (cached) image → `304`.
|
|
86
|
+
|
|
87
|
+
## Reusing parameters across images
|
|
118
88
|
|
|
119
|
-
|
|
89
|
+
Map a friendly path to the endpoint with a redirect/rewrite.
|
|
120
90
|
|
|
91
|
+
`_redirects`:
|
|
121
92
|
```
|
|
122
93
|
/transform-small/* /.netlify/images?url=/:splat&w=50&h=50 200
|
|
123
94
|
```
|
|
124
95
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
96
|
+
`netlify.toml`:
|
|
97
|
+
```toml
|
|
98
|
+
[[redirects]]
|
|
99
|
+
from = "/transform-small/*"
|
|
100
|
+
to = "/.netlify/images?url=/:splat&w=50&h=50"
|
|
101
|
+
status = 200
|
|
130
102
|
```
|
|
131
103
|
|
|
132
|
-
|
|
133
|
-
loops and errors.
|
|
104
|
+
Then `GET /transform-small/owl.jpeg` returns the transformed image. **Cross-site redirects for transformations are NOT recommended** — they can degrade site performance.
|
|
134
105
|
|
|
135
|
-
##
|
|
106
|
+
## Caching headers
|
|
136
107
|
|
|
137
|
-
Apply custom headers to source images on
|
|
138
|
-
when explicitly asked.
|
|
139
|
-
|
|
140
|
-
**Using `netlify.toml`:**
|
|
108
|
+
Apply custom headers to source images on the site's own domain; they carry through to the transformed output.
|
|
141
109
|
|
|
110
|
+
`netlify.toml`:
|
|
142
111
|
```toml
|
|
143
112
|
[[headers]]
|
|
144
|
-
for = "/source-images/*"
|
|
145
|
-
[headers.values]
|
|
146
|
-
|
|
113
|
+
for = "/source-images/*"
|
|
114
|
+
[headers.values]
|
|
115
|
+
Cache-Control = "public, max-age=604800, must-revalidate"
|
|
147
116
|
```
|
|
148
117
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
```
|
|
152
|
-
/source-images/*
|
|
153
|
-
Cache-Control: public, max-age=604800, must-revalidate
|
|
154
|
-
```
|
|
155
|
-
|
|
156
|
-
**Limitations:**
|
|
157
|
-
- Custom headers only work for images on the same domain
|
|
158
|
-
- Cannot apply custom headers to remote source images
|
|
159
|
-
- Netlify respects cache headers that external domains send with their images
|
|
160
|
-
- `Cache-Control` headers on source images apply to browsers/CDNs, not the Netlify cache itself
|
|
161
|
-
|
|
162
|
-
## Framework Integration
|
|
163
|
-
|
|
164
|
-
Many frameworks automatically use Netlify Image CDN when deployed to Netlify:
|
|
165
|
-
|
|
166
|
-
| Framework | Setup | Remote Image Allowlist |
|
|
167
|
-
|-----------|-------|----------------------|
|
|
168
|
-
| **Angular** | Automatic via `NgOptimizedImage` | `[images] remote_images` in `netlify.toml` |
|
|
169
|
-
| **Astro** | Automatic via `<Image />` component | `image.domains` or `image.remotePatterns` in `astro.config.mjs` |
|
|
170
|
-
| **Gatsby 5.13+** | Set env var `NETLIFY_IMAGE_CDN=true` | `[images] remote_images` in `netlify.toml` |
|
|
171
|
-
| **Next.js 13.5+** | Automatic with adapter v5 | `remotePatterns` in `next.config.js` |
|
|
172
|
-
| **Nuxt** | Automatic via `nuxt/image` module | `image.domains` in `nuxt.config.ts` |
|
|
173
|
-
|
|
174
|
-
## Caching & Deploy Behavior
|
|
175
|
-
|
|
176
|
-
- Transformed images are uniquely cached at the edge per transformation parameters
|
|
177
|
-
- Future requests for the same transformation serve the cached version
|
|
178
|
-
- After a new deploy, cached images are invalidated if the source changed
|
|
179
|
-
- Source images are also cached to speed up future transformations
|
|
180
|
-
- Use asset fingerprinting (content hashes in filenames) for fine-grained cache control
|
|
181
|
-
|
|
182
|
-
## Response Codes
|
|
183
|
-
|
|
184
|
-
| Condition | Response |
|
|
185
|
-
|-----------|----------|
|
|
186
|
-
| Valid query, new transformation | `200` with transformed image |
|
|
187
|
-
| Request for previously cached transformation | `304` Not Modified |
|
|
188
|
-
| Invalid parameter values or missing source | `404` Not Found |
|
|
189
|
-
|
|
190
|
-
## Common Errors & Solutions
|
|
191
|
-
|
|
192
|
-
### "404 on image transformation"
|
|
118
|
+
- Custom headers can be applied to source images on the site's domain only — NOT to remote source images (Netlify does respect cache headers the external domain sends).
|
|
119
|
+
- `Cache-Control` on source images applies only to browsers and CDNs in front of Netlify, NOT the Netlify Cache itself.
|
|
193
120
|
|
|
194
|
-
|
|
121
|
+
## Blur placeholders (fm=blurhash)
|
|
195
122
|
|
|
196
|
-
**
|
|
123
|
+
`fm=blurhash` returns a BlurHash **text string**, not image bytes. Pointing an `<img src>` (or CSS background) at it renders nothing. Fetch the string ahead of time, decode it client-side with a BlurHash library (https://blurha.sh), and load the real image as a separate request without `fm=blurhash`.
|
|
197
124
|
|
|
198
|
-
|
|
199
|
-
2. Check parameter values are valid (e.g., `q` must be 1-100, `fit` must be `contain`, `cover`, or `fill`)
|
|
200
|
-
3. For remote images, ensure the domain is in `remote_images` config
|
|
125
|
+
## Local development
|
|
201
126
|
|
|
202
|
-
|
|
127
|
+
The `/.netlify/images` endpoint, `[images]` allowlisting, and image redirects only exist under `netlify dev` (Netlify CLI). A local **404 on `/.netlify/images` almost always means a framework dev server (`vite`, `next dev`, `astro dev`) is running instead of `netlify dev`** — the URL itself is usually fine. Start the local environment with `netlify dev`.
|
|
203
128
|
|
|
204
|
-
|
|
129
|
+
## User-uploaded image pipelines
|
|
205
130
|
|
|
206
|
-
|
|
131
|
+
For pipelines composing Functions + Blobs + Image CDN (handling user-uploaded images), see `references/user-uploads.md`.
|
|
207
132
|
|
|
208
|
-
|
|
209
|
-
2. Ensure `w` and `h` are appropriate for the display size
|
|
210
|
-
3. Use `fm=png` for images that need lossless quality
|
|
133
|
+
## Framework image handling
|
|
211
134
|
|
|
212
|
-
|
|
135
|
+
Many frameworks route their built-in image optimization through Netlify Image CDN — use the framework's standard image component/syntax and only configure the remote allowlist. For unlisted frameworks, call `/.netlify/images` directly.
|
|
213
136
|
|
|
214
|
-
|
|
137
|
+
| Framework | Prerequisites | Remote allowlist location |
|
|
138
|
+
|-----------|---------------|---------------------------|
|
|
139
|
+
| Angular | None; `NgOptimizedImage` uses it automatically | `[images] remote_images` in `netlify.toml` |
|
|
140
|
+
| Astro | None; `<Image />` uses it automatically | `image.domains` or `image.remotePatterns` in `astro.config.mjs` |
|
|
141
|
+
| Gatsby (both 5.13+ and 5.11 or earlier) | Set env `NETLIFY_IMAGE_CDN=true`; use Contentful/Drupal/WordPress source plugins | `[images] remote_images` in `netlify.toml` |
|
|
142
|
+
| Next.js | Next.js 13.5+ and Next.js adapter v5 | `remotePatterns` in `next.config.js` |
|
|
143
|
+
| Nuxt | None; `nuxt/image` module uses it automatically | `image.domains` in `nuxt.config.ts` |
|
|
215
144
|
|
|
216
|
-
|
|
145
|
+
Setup guides: [Angular](https://docs.netlify.com/build/frameworks/framework-setup-guides/angular#netlify-image-cdn), [Astro](https://docs.netlify.com/build/frameworks/framework-setup-guides/astro#netlify-image-cdn), [Gatsby](https://docs.netlify.com/build/frameworks/framework-setup-guides/gatsby/#netlify-image-cdn), [Next.js](https://docs.netlify.com/build/frameworks/framework-setup-guides/nextjs/overview), [Nuxt](https://docs.netlify.com/build/frameworks/framework-setup-guides/nuxt#netlify-image-cdn).
|
|
217
146
|
|
|
218
|
-
|
|
219
|
-
```toml
|
|
220
|
-
[images]
|
|
221
|
-
remote_images = ["https://example\\.com/.*"]
|
|
222
|
-
```
|
|
223
|
-
2. Escape dots in regex patterns with double backslash
|
|
224
|
-
3. Redeploy after updating config
|
|
147
|
+
## Additional constraints
|
|
225
148
|
|
|
226
|
-
|
|
149
|
+
- Deploy behavior: transforms respect [atomic deploys](https://docs.netlify.com/build/caching/caching-overview#automatic-invalidation-with-atomic-deploys); changing a source image in a new deploy re-runs transforms on subsequent requests.
|
|
150
|
+
- [Split Testing](https://docs.netlify.com/manage/monitoring/split-testing/) is NOT supported — image results may be inconsistent across split test branches.
|
|
151
|
+
- Netlify Image CDN is NOT part of Netlify's HIPAA-compliant hosting offering.
|
|
227
152
|
|
|
228
|
-
|
|
153
|
+
Interactive parameter playground: https://image-cdn-playground.netlify.app/
|
|
229
154
|
|
|
230
|
-
|
|
155
|
+
<!-- system: agent-context/image-cdn/system.md — human-owned, merged by ctx-gen; edit system.md, not this section -->
|
|
156
|
+
# Netlify house rules (image-cdn)
|
|
231
157
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
3. Wait a few minutes for edge cache propagation
|
|
158
|
+
These are org conventions, not docs facts — merged into the rendered skill by
|
|
159
|
+
ctx-gen and never generated. Owned by the skills maintainer.
|
|
235
160
|
|
|
236
|
-
|
|
161
|
+
1. For user-uploaded image pipelines (Functions + Blobs + Image CDN
|
|
162
|
+
composed), see `references/user-uploads.md` in this skill — an authored
|
|
163
|
+
guide with no single docs source.
|
|
164
|
+
2. Percent-encode remote source URLs before placing them in the `url`
|
|
165
|
+
parameter (`encodeURIComponent`) — URLs containing `?` or `&` break
|
|
166
|
+
otherwise.
|
|
167
|
+
3. `fm=blurhash` returns a BlurHash TEXT string, not image bytes. Pointing an
|
|
168
|
+
`<img src>` (or CSS background) at it renders nothing — fetch the string
|
|
169
|
+
ahead of time, decode it client-side with a BlurHash library, and load the
|
|
170
|
+
real image as a separate request without `fm=blurhash`.
|
|
171
|
+
4. A local 404 on `/.netlify/images` almost always means a framework dev
|
|
172
|
+
server (`vite`, `next dev`, `astro dev`) is running instead of
|
|
173
|
+
`netlify dev` — the endpoint, `[images]` allowlisting, and image redirects
|
|
174
|
+
only exist under `netlify dev`. The URL itself is usually fine.
|
|
175
|
+
5. In `remote_images` patterns, the meaningful regex escape is the dot;
|
|
176
|
+
forward slashes are not metacharacters — do not write `https:\/\/`.
|
|
177
|
+
In `netlify.toml`, use a single-quoted literal string
|
|
178
|
+
(`'https://example\.com/.*'`) or double the backslash in a
|
|
179
|
+
double-quoted string (`"https://example\\.com/.*"`) — a bare `\.`
|
|
180
|
+
inside double quotes is invalid TOML.
|
|
237
181
|
|
|
238
|
-
**Cause:** Redirect rule creates a loop with the image transformation endpoint.
|
|
239
182
|
|
|
240
|
-
|
|
183
|
+
## Additional requirements
|
|
241
184
|
|
|
242
|
-
|
|
243
|
-
2. Avoid redirecting `/.netlify/images` to another `/.netlify/images` URL
|
|
244
|
-
3. Use status `200` (rewrite) not `301`/`302` (redirect) when possible
|
|
185
|
+
Only add custom headers to source images when the user explicitly asks.
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# User-Uploaded Images Pipeline
|
|
2
|
+
|
|
3
|
+
Compose Netlify Functions (upload handler) + Netlify Blobs (storage) + Image CDN (serving/transforming) to build a complete user-uploaded image pipeline.
|
|
4
|
+
|
|
5
|
+
## Architecture
|
|
6
|
+
|
|
7
|
+
1. **Upload** — A Netlify Function receives multipart form data, validates, and stores in Blobs
|
|
8
|
+
2. **Storage** — Netlify Blobs stores the binary image with metadata
|
|
9
|
+
3. **Serve** — A Netlify Function retrieves the blob and serves it at `/uploads/:key`
|
|
10
|
+
4. **Transform** — A redirect maps `/img/:key` to `/.netlify/images?url=/uploads/:key` for CDN optimization
|
|
11
|
+
|
|
12
|
+
## Dependencies
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install @netlify/blobs
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Upload Handler
|
|
19
|
+
|
|
20
|
+
```typescript
|
|
21
|
+
// netlify/functions/upload.ts
|
|
22
|
+
import type { Context, Config } from "@netlify/functions";
|
|
23
|
+
import { getStore } from "@netlify/blobs";
|
|
24
|
+
import { randomUUID } from "crypto";
|
|
25
|
+
|
|
26
|
+
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"];
|
|
27
|
+
const MAX_SIZE = 4 * 1024 * 1024; // 4 MB
|
|
28
|
+
|
|
29
|
+
export default async (req: Request, context: Context) => {
|
|
30
|
+
if (req.method !== "POST") {
|
|
31
|
+
return new Response("Method not allowed", { status: 405 });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const formData = await req.formData();
|
|
35
|
+
const image = formData.get("image") as File;
|
|
36
|
+
|
|
37
|
+
if (!image) return Response.json({ error: "No image provided" }, { status: 400 });
|
|
38
|
+
if (!ALLOWED_TYPES.includes(image.type)) return Response.json({ error: "Invalid type" }, { status: 400 });
|
|
39
|
+
if (image.size > MAX_SIZE) return Response.json({ error: "File too large" }, { status: 400 });
|
|
40
|
+
|
|
41
|
+
const extension = image.name.split(".").pop() || "jpg";
|
|
42
|
+
const key = `${randomUUID()}.${extension}`;
|
|
43
|
+
const store = getStore({ name: "images", consistency: "strong" });
|
|
44
|
+
|
|
45
|
+
await store.set(key, image, {
|
|
46
|
+
metadata: {
|
|
47
|
+
contentType: image.type,
|
|
48
|
+
originalFilename: image.name,
|
|
49
|
+
uploadedAt: new Date().toISOString(),
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
return Response.json({ success: true, key, url: `/img/${key}` });
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const config: Config = { path: "/api/upload", method: "POST" };
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Serve Handler
|
|
60
|
+
|
|
61
|
+
```typescript
|
|
62
|
+
// netlify/functions/serve-image.ts
|
|
63
|
+
import type { Context, Config } from "@netlify/functions";
|
|
64
|
+
import { getStore } from "@netlify/blobs";
|
|
65
|
+
|
|
66
|
+
export default async (req: Request, context: Context) => {
|
|
67
|
+
const key = context.params.key;
|
|
68
|
+
const store = getStore({ name: "images", consistency: "strong" });
|
|
69
|
+
|
|
70
|
+
const result = await store.getWithMetadata(key, { type: "stream" });
|
|
71
|
+
if (!result) return new Response("Not found", { status: 404 });
|
|
72
|
+
|
|
73
|
+
return new Response(result.data, {
|
|
74
|
+
headers: {
|
|
75
|
+
"Content-Type": result.metadata?.contentType || "image/jpeg",
|
|
76
|
+
"Cache-Control": "public, max-age=31536000, immutable",
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const config: Config = { path: "/uploads/:key" };
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## CDN Redirect
|
|
85
|
+
|
|
86
|
+
```toml
|
|
87
|
+
# netlify.toml
|
|
88
|
+
|
|
89
|
+
# Basic optimized URL
|
|
90
|
+
[[redirects]]
|
|
91
|
+
from = "/img/:key"
|
|
92
|
+
to = "/.netlify/images?url=/uploads/:key"
|
|
93
|
+
status = 200
|
|
94
|
+
|
|
95
|
+
# Thumbnail preset
|
|
96
|
+
[[redirects]]
|
|
97
|
+
from = "/img/thumb/:key"
|
|
98
|
+
to = "/.netlify/images?url=/uploads/:key&w=150&h=150&fit=cover"
|
|
99
|
+
status = 200
|
|
100
|
+
|
|
101
|
+
# Hero preset
|
|
102
|
+
[[redirects]]
|
|
103
|
+
from = "/img/hero/:key"
|
|
104
|
+
to = "/.netlify/images?url=/uploads/:key&w=1200&h=675&fit=cover"
|
|
105
|
+
status = 200
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Client-Side Upload (React Example)
|
|
109
|
+
|
|
110
|
+
```tsx
|
|
111
|
+
function ImageUpload({ onUpload }: { onUpload: (url: string) => void }) {
|
|
112
|
+
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
113
|
+
const file = e.target.files?.[0];
|
|
114
|
+
if (!file) return;
|
|
115
|
+
|
|
116
|
+
const formData = new FormData();
|
|
117
|
+
formData.append("image", file);
|
|
118
|
+
|
|
119
|
+
const res = await fetch("/api/upload", { method: "POST", body: formData });
|
|
120
|
+
const { url } = await res.json();
|
|
121
|
+
onUpload(url);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
return <input type="file" accept="image/*" onChange={handleChange} />;
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Astro Upload (API Route)
|
|
129
|
+
|
|
130
|
+
```typescript
|
|
131
|
+
// src/pages/api/upload.ts
|
|
132
|
+
import type { APIRoute } from "astro";
|
|
133
|
+
import { getStore } from "@netlify/blobs";
|
|
134
|
+
import { randomUUID } from "crypto";
|
|
135
|
+
|
|
136
|
+
export const POST: APIRoute = async ({ request, redirect }) => {
|
|
137
|
+
const formData = await request.formData();
|
|
138
|
+
const image = formData.get("image") as File;
|
|
139
|
+
if (!image) return new Response("No image", { status: 400 });
|
|
140
|
+
|
|
141
|
+
const key = `${randomUUID()}.${image.name.split(".").pop() || "jpg"}`;
|
|
142
|
+
const store = getStore({ name: "images", consistency: "strong" });
|
|
143
|
+
await store.set(key, image, {
|
|
144
|
+
metadata: { contentType: image.type, originalFilename: image.name },
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
return redirect(`/gallery?uploaded=${key}`);
|
|
148
|
+
};
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Key Points
|
|
152
|
+
|
|
153
|
+
- Always validate file type and size on the server (client validation can be bypassed)
|
|
154
|
+
- Use `strong` consistency on Blobs for immediate reads after writes
|
|
155
|
+
- The serve handler's `Cache-Control: immutable` means the CDN caches the raw image permanently — Image CDN transformations layer on top
|
|
156
|
+
- Without `fm` parameter, Netlify auto-serves AVIF or WebP based on browser support
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@netlify/agent-runner-cli",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.8.0",
|
|
5
5
|
"description": "CLI tool for running Netlify agents",
|
|
6
6
|
"main": "./dist/index.js",
|
|
7
7
|
"types": "./dist/index.d.ts",
|
|
@@ -68,6 +68,7 @@
|
|
|
68
68
|
"@eslint/js": "^10.0.1",
|
|
69
69
|
"@netlify/axis": "^1.21.0",
|
|
70
70
|
"@netlify/eslint-config-node": "^7.0.1",
|
|
71
|
+
"@netlify/skills": "1.7.0",
|
|
71
72
|
"@types/node": "^24.5.0",
|
|
72
73
|
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
|
73
74
|
"@typescript-eslint/parser": "^8.0.0",
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { appendFile, cp, mkdir, readdir, readFile, rm } from 'node:fs/promises'
|
|
2
|
+
import { createRequire } from 'node:module'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import process from 'node:process'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
|
|
7
|
+
const localSkillsDir = new URL('../src/skills/', import.meta.url)
|
|
8
|
+
const sharedPackagePath = createRequire(import.meta.url).resolve('@netlify/skills/package.json')
|
|
9
|
+
const sharedSkillsDir = path.join(path.dirname(sharedPackagePath), 'skills')
|
|
10
|
+
export const bundledSkillsDir = fileURLToPath(new URL('../dist/skills/', import.meta.url))
|
|
11
|
+
const selectedSharedSkills = [
|
|
12
|
+
{ name: 'netlify-access-control' },
|
|
13
|
+
{
|
|
14
|
+
name: 'netlify-forms',
|
|
15
|
+
instructions: new URL('../src/skill-instructions/netlify-forms.md', import.meta.url),
|
|
16
|
+
assets: new URL('../src/skill-assets/netlify-forms/', import.meta.url),
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
name: 'netlify-image-cdn',
|
|
20
|
+
instructions: new URL('../src/skill-instructions/netlify-image-cdn.md', import.meta.url),
|
|
21
|
+
},
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
export const buildSkills = async (targetDir = bundledSkillsDir) => {
|
|
25
|
+
await rm(targetDir, { recursive: true, force: true })
|
|
26
|
+
await cp(localSkillsDir, targetDir, { recursive: true })
|
|
27
|
+
|
|
28
|
+
for (const { name, instructions, assets } of selectedSharedSkills) {
|
|
29
|
+
await cp(path.join(sharedSkillsDir, name), path.join(targetDir, name), {
|
|
30
|
+
recursive: true,
|
|
31
|
+
force: false,
|
|
32
|
+
errorOnExist: true,
|
|
33
|
+
})
|
|
34
|
+
if (instructions) {
|
|
35
|
+
await appendFile(path.join(targetDir, name, 'SKILL.md'), `\n\n${await readFile(instructions, 'utf8')}`)
|
|
36
|
+
}
|
|
37
|
+
if (assets) {
|
|
38
|
+
const assetsDir = fileURLToPath(assets)
|
|
39
|
+
for (const entry of await readdir(assetsDir, { recursive: true, withFileTypes: true })) {
|
|
40
|
+
if (!entry.isFile()) continue
|
|
41
|
+
const source = path.join(entry.parentPath, entry.name)
|
|
42
|
+
const destination = path.join(targetDir, name, path.relative(assetsDir, source))
|
|
43
|
+
await mkdir(path.dirname(destination), { recursive: true })
|
|
44
|
+
await cp(source, destination, { force: false, errorOnExist: true })
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
51
|
+
await buildSkills()
|
|
52
|
+
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
1
|
/**
|
|
3
2
|
* Installs the orchestrator's bundled skills into the agent's skill directory
|
|
4
3
|
* by calling `setupAgentSkills` from `src/context.js` — the same function the
|
|
@@ -15,10 +14,15 @@
|
|
|
15
14
|
* Omit `--agent` to install for all known agents in one pass.
|
|
16
15
|
*/
|
|
17
16
|
import process from 'node:process'
|
|
17
|
+
import fs from 'node:fs/promises'
|
|
18
|
+
import os from 'node:os'
|
|
19
|
+
import path from 'node:path'
|
|
18
20
|
import { parseArgs } from 'node:util'
|
|
19
21
|
|
|
20
22
|
import { createJiti } from 'jiti'
|
|
21
23
|
|
|
24
|
+
import { buildSkills } from '../build-skills.js'
|
|
25
|
+
|
|
22
26
|
const jiti = createJiti(import.meta.url, { interopDefault: true })
|
|
23
27
|
const { setupAgentSkills, resetSkillsCache } = await jiti.import('../../src/context.js')
|
|
24
28
|
|
|
@@ -43,11 +47,17 @@ const main = async () => {
|
|
|
43
47
|
|
|
44
48
|
const agents = values.agent ? [values.agent] : ALL_AGENTS
|
|
45
49
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
50
|
+
const sourceDir = await fs.mkdtemp(path.join(os.tmpdir(), 'scenario-skills-'))
|
|
51
|
+
try {
|
|
52
|
+
await buildSkills(sourceDir)
|
|
53
|
+
for (const agent of agents) {
|
|
54
|
+
const runner = RUNNER_BY_AGENT[agent] ?? agent
|
|
55
|
+
resetSkillsCache()
|
|
56
|
+
const installed = await setupAgentSkills(runner, { sourceDir, targetDir: values['target-dir'] })
|
|
57
|
+
process.stderr.write(`[install-skills] ${runner}: ${installed.length} skill(s)\n`)
|
|
58
|
+
}
|
|
59
|
+
} finally {
|
|
60
|
+
await fs.rm(sourceDir, { recursive: true, force: true })
|
|
51
61
|
}
|
|
52
62
|
}
|
|
53
63
|
|