@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.
Files changed (3) hide show
  1. package/README.md +31 -0
  2. package/package.json +1 -1
  3. 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/server",
3
- "version": "0.2.15",
3
+ "version": "0.2.16",
4
4
  "description": "Server runtime primitives for Lambda — event adapters, routing, SSR, image processing",
5
5
  "license": "AGPL-3.0-only",
6
6
  "publishConfig": {
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,