@everystack/server 0.2.15 → 0.2.16
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/README.md +31 -0
- package/package.json +1 -1
- package/src/image.ts +66 -0
package/README.md
CHANGED
|
@@ -163,6 +163,7 @@ interface ImageHandlerConfig {
|
|
|
163
163
|
enabled: boolean;
|
|
164
164
|
prefix?: string; // S3 key prefix (default: 'cache/')
|
|
165
165
|
};
|
|
166
|
+
observability?: boolean; // Emit cache-state + timing headers (default: true)
|
|
166
167
|
}
|
|
167
168
|
```
|
|
168
169
|
|
|
@@ -173,6 +174,36 @@ URL format: `/media/{key}?w=400&h=300&fit=cover&fm=webp&q=80&dpr=2`
|
|
|
173
174
|
- All images get EXIF auto-rotation and format conversion (default: webp q80)
|
|
174
175
|
- With `s3Cache` enabled: checks S3 for a cached render before processing, writes result to S3 after first render. Subsequent requests from any CloudFront region skip Sharp entirely.
|
|
175
176
|
|
|
177
|
+
#### Observability headers
|
|
178
|
+
|
|
179
|
+
When `observability` is enabled (default), image responses carry additive,
|
|
180
|
+
non-sensitive headers that double as production render-time monitoring. They do
|
|
181
|
+
not change `Cache-Control`, `Vary`, status, or body.
|
|
182
|
+
|
|
183
|
+
| Header | Set on | Meaning |
|
|
184
|
+
| ---------------- | ----------------- | ------------------------------------------------ |
|
|
185
|
+
| `x-image-cache` | both image paths | `hit` = served from S3 render cache (no Sharp); `render` = freshly rendered |
|
|
186
|
+
| `x-render-ms` | render path only | ms spent in Sharp |
|
|
187
|
+
| `x-image-ms` | both image paths | total handler wall time (entry → response) |
|
|
188
|
+
| `x-fetch-ms` | both image paths | ms to GET the original/cached object from S3 |
|
|
189
|
+
| `Server-Timing` | both image paths | devtools mirror: `cache;desc=…, s3;dur=…, render;dur=…, total;dur=…` |
|
|
190
|
+
|
|
191
|
+
**Telling the three pipeline states apart** — CloudFront edge hit vs. S3
|
|
192
|
+
render-cache hit vs. fresh render. Read CloudFront's `x-cache` **first**:
|
|
193
|
+
|
|
194
|
+
| `x-cache` (CloudFront) | `x-image-cache` (Lambda) | meaning |
|
|
195
|
+
| ---------------------- | ------------------------ | ------------------------------------ |
|
|
196
|
+
| `Hit from cloudfront` | (ignore — stale) | EDGE HIT, Lambda not invoked |
|
|
197
|
+
| `Miss from cloudfront` | `hit` | S3 RENDER-CACHE HIT, no Sharp |
|
|
198
|
+
| `Miss from cloudfront` | `render` | FRESH RENDER (`x-render-ms` = Sharp time) |
|
|
199
|
+
|
|
200
|
+
> **Caveat:** CloudFront caches the response *including* these headers and
|
|
201
|
+
> replays them on edge hits, so on an edge hit `x-image-cache` reflects the
|
|
202
|
+
> *original* render, not this request. The `x-image-*` headers are authoritative
|
|
203
|
+
> only when `x-cache` is a `Miss`. Always read `x-cache` first.
|
|
204
|
+
|
|
205
|
+
Set `observability: false` to suppress all of the above.
|
|
206
|
+
|
|
176
207
|
### `deleteImageCache(bucket, key, options?)`
|
|
177
208
|
|
|
178
209
|
Deletes all cached renders for an image key. Call when the original is deleted or replaced.
|
package/package.json
CHANGED
package/src/image.ts
CHANGED
|
@@ -45,6 +45,29 @@ export interface ImageHandlerConfig {
|
|
|
45
45
|
* via S3 lifecycle rules (configure on the bucket, not here).
|
|
46
46
|
*/
|
|
47
47
|
s3Cache?: S3CacheConfig;
|
|
48
|
+
/**
|
|
49
|
+
* Emit additive cache-state + timing headers on image responses
|
|
50
|
+
* (`x-image-cache`, `x-render-ms`, `x-image-ms`, `x-fetch-ms`,
|
|
51
|
+
* `Server-Timing`). Default: true.
|
|
52
|
+
*
|
|
53
|
+
* These headers are non-sensitive (cache state + timings) and double
|
|
54
|
+
* as production render-time monitoring, so default-on is intended.
|
|
55
|
+
*
|
|
56
|
+
* CONSUMER TRUTH TABLE — three pipeline states, read x-cache FIRST:
|
|
57
|
+
*
|
|
58
|
+
* x-cache (CloudFront) | x-image-cache (Lambda) | meaning
|
|
59
|
+
* ---------------------|------------------------|----------------------------
|
|
60
|
+
* Hit from cloudfront | (ignore — stale) | EDGE HIT, Lambda not invoked
|
|
61
|
+
* Miss from cloudfront | hit | S3 RENDER-CACHE HIT, no Sharp
|
|
62
|
+
* Miss from cloudfront | render | FRESH RENDER (x-render-ms = Sharp time)
|
|
63
|
+
*
|
|
64
|
+
* CRITICAL CAVEAT: CloudFront caches the response INCLUDING these
|
|
65
|
+
* headers and replays them on edge hits, so on an edge hit
|
|
66
|
+
* `x-image-cache` reflects the ORIGINAL render, not this request.
|
|
67
|
+
* Consumers MUST read CloudFront's `x-cache` first — `x-image-*` are
|
|
68
|
+
* authoritative only when `x-cache` is a Miss.
|
|
69
|
+
*/
|
|
70
|
+
observability?: boolean;
|
|
48
71
|
}
|
|
49
72
|
|
|
50
73
|
export interface TransformParams {
|
|
@@ -196,8 +219,36 @@ export function createImageHandler(
|
|
|
196
219
|
): (event: APIGatewayProxyEventV2) => Promise<APIGatewayProxyStructuredResultV2> {
|
|
197
220
|
const { bucket, pathPrefix = '/media/', validateKey } = config;
|
|
198
221
|
const region = config.region || process.env.AWS_REGION;
|
|
222
|
+
const observability = config.observability !== false;
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Build additive cache-state + timing headers. Returns {} when
|
|
226
|
+
* observability is disabled so responses are byte-identical to before.
|
|
227
|
+
*/
|
|
228
|
+
function timingHeaders(opts: {
|
|
229
|
+
cache?: 'hit' | 'render';
|
|
230
|
+
fetchMs?: number;
|
|
231
|
+
renderMs?: number;
|
|
232
|
+
totalMs: number;
|
|
233
|
+
}): Record<string, string> {
|
|
234
|
+
if (!observability) return {};
|
|
235
|
+
const headers: Record<string, string> = { 'x-image-ms': String(opts.totalMs) };
|
|
236
|
+
if (opts.cache) headers['x-image-cache'] = opts.cache;
|
|
237
|
+
if (opts.fetchMs !== undefined) headers['x-fetch-ms'] = String(opts.fetchMs);
|
|
238
|
+
if (opts.renderMs !== undefined) headers['x-render-ms'] = String(opts.renderMs);
|
|
239
|
+
|
|
240
|
+
// Server-Timing mirror — nice for browser devtools.
|
|
241
|
+
const timings: string[] = [];
|
|
242
|
+
if (opts.cache) timings.push(`cache;desc=${opts.cache}`);
|
|
243
|
+
if (opts.fetchMs !== undefined) timings.push(`s3;dur=${opts.fetchMs}`);
|
|
244
|
+
if (opts.renderMs !== undefined) timings.push(`render;dur=${opts.renderMs}`);
|
|
245
|
+
timings.push(`total;dur=${opts.totalMs}`);
|
|
246
|
+
headers['Server-Timing'] = timings.join(', ');
|
|
247
|
+
return headers;
|
|
248
|
+
}
|
|
199
249
|
|
|
200
250
|
return async (event: APIGatewayProxyEventV2): Promise<APIGatewayProxyStructuredResultV2> => {
|
|
251
|
+
const t0 = Date.now();
|
|
201
252
|
try {
|
|
202
253
|
// Extract key from path: /media/uploads/abc.jpg → uploads/abc.jpg
|
|
203
254
|
const path = event.rawPath;
|
|
@@ -258,16 +309,20 @@ export function createImageHandler(
|
|
|
258
309
|
if (useCache) {
|
|
259
310
|
s3CacheKey = computeCacheKey(key, params, cachePrefix, queryParams._v);
|
|
260
311
|
try {
|
|
312
|
+
const fetchStart = Date.now();
|
|
261
313
|
const cached = await client.send(
|
|
262
314
|
new GetObjectCommand({ Bucket: bucket, Key: s3CacheKey })
|
|
263
315
|
);
|
|
264
316
|
if (cached.Body) {
|
|
265
317
|
const bytes = await cached.Body.transformToByteArray();
|
|
318
|
+
const fetchMs = Date.now() - fetchStart;
|
|
266
319
|
return {
|
|
267
320
|
statusCode: 200,
|
|
268
321
|
headers: {
|
|
269
322
|
'Content-Type': cached.ContentType || FORMAT_CONTENT_TYPES[params.fm || 'webp'],
|
|
270
323
|
...cacheHeaders,
|
|
324
|
+
// S3 render-cache hit — no Sharp ran. See truth table on ImageHandlerConfig.observability.
|
|
325
|
+
...timingHeaders({ cache: 'hit', fetchMs, totalMs: Date.now() - t0 }),
|
|
271
326
|
},
|
|
272
327
|
body: Buffer.from(bytes).toString('base64'),
|
|
273
328
|
isBase64Encoded: true,
|
|
@@ -282,7 +337,9 @@ export function createImageHandler(
|
|
|
282
337
|
// --- Fetch original from S3 for processing ---
|
|
283
338
|
let originalData: Buffer;
|
|
284
339
|
let originalContentType: string | undefined;
|
|
340
|
+
let fetchMs: number | undefined;
|
|
285
341
|
try {
|
|
342
|
+
const fetchStart = Date.now();
|
|
286
343
|
const result = await client.send(
|
|
287
344
|
new GetObjectCommand({ Bucket: bucket, Key: key })
|
|
288
345
|
);
|
|
@@ -296,6 +353,7 @@ export function createImageHandler(
|
|
|
296
353
|
originalContentType = result.ContentType || undefined;
|
|
297
354
|
const bytes = await result.Body.transformToByteArray();
|
|
298
355
|
originalData = Buffer.from(bytes);
|
|
356
|
+
fetchMs = Date.now() - fetchStart;
|
|
299
357
|
} catch (error: any) {
|
|
300
358
|
if (isNotFound(error)) {
|
|
301
359
|
return {
|
|
@@ -338,6 +396,7 @@ export function createImageHandler(
|
|
|
338
396
|
headers: {
|
|
339
397
|
'Content-Type': originalContentType || 'application/octet-stream',
|
|
340
398
|
...cacheHeaders,
|
|
399
|
+
...timingHeaders({ fetchMs, totalMs: Date.now() - t0 }),
|
|
341
400
|
},
|
|
342
401
|
body: originalData.toString('base64'),
|
|
343
402
|
isBase64Encoded: true,
|
|
@@ -348,7 +407,9 @@ export function createImageHandler(
|
|
|
348
407
|
const hasTransforms = params.w || params.h || params.fit || params.fm || params.q;
|
|
349
408
|
|
|
350
409
|
// Process with Sharp — catch decode failures (e.g. HEIC without libheif)
|
|
410
|
+
// Time spent here is the Sharp render cost reported as x-render-ms.
|
|
351
411
|
let processed: { data: Buffer; contentType: string } | undefined;
|
|
412
|
+
const renderStart = Date.now();
|
|
352
413
|
try {
|
|
353
414
|
processed = hasTransforms
|
|
354
415
|
? await processImage(originalData, params)
|
|
@@ -389,6 +450,7 @@ export function createImageHandler(
|
|
|
389
450
|
headers: {
|
|
390
451
|
'Content-Type': originalContentType || 'application/octet-stream',
|
|
391
452
|
...cacheHeaders,
|
|
453
|
+
...timingHeaders({ fetchMs, totalMs: Date.now() - t0 }),
|
|
392
454
|
},
|
|
393
455
|
body: originalData.toString('base64'),
|
|
394
456
|
isBase64Encoded: true,
|
|
@@ -396,6 +458,8 @@ export function createImageHandler(
|
|
|
396
458
|
}
|
|
397
459
|
}
|
|
398
460
|
|
|
461
|
+
const renderMs = Date.now() - renderStart;
|
|
462
|
+
|
|
399
463
|
// --- Write rendered image to S3 cache (fire-and-forget) ---
|
|
400
464
|
if (useCache && s3CacheKey) {
|
|
401
465
|
client
|
|
@@ -418,6 +482,8 @@ export function createImageHandler(
|
|
|
418
482
|
headers: {
|
|
419
483
|
'Content-Type': processed.contentType,
|
|
420
484
|
...cacheHeaders,
|
|
485
|
+
// Fresh render — Sharp ran. x-render-ms is the Sharp cost.
|
|
486
|
+
...timingHeaders({ cache: 'render', fetchMs, renderMs, totalMs: Date.now() - t0 }),
|
|
421
487
|
},
|
|
422
488
|
body: processed.data.toString('base64'),
|
|
423
489
|
isBase64Encoded: true,
|