@everystack/server 0.2.14 → 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 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.14",
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,
package/src/plugin.ts CHANGED
@@ -111,6 +111,14 @@ export interface PluginLambdaHandlerOptions {
111
111
  actions?: Record<string, ActionHandler>;
112
112
  /** Override default Cache-Control headers */
113
113
  cache?: ServerCacheConfig;
114
+ /**
115
+ * Serve HTTP. Default true. Set false for an operator Lambda (server/ops.ts):
116
+ * routes are not built and HTTP-shaped events are rejected with 403 — the
117
+ * function answers `_action` invokes only. IAM (lambda:InvokeFunction) is the
118
+ * auth layer. Use with createAdminDb() so the privileged credential lives
119
+ * here, not in the HTTP-facing API function.
120
+ */
121
+ http?: boolean;
114
122
  }
115
123
 
116
124
  // --- Plugin Lambda Handler Implementation ---
@@ -118,9 +126,10 @@ export interface PluginLambdaHandlerOptions {
118
126
  export function createPluginLambdaHandler(
119
127
  options: PluginLambdaHandlerOptions
120
128
  ): (event: APIGatewayProxyEventV2 | Record<string, unknown>) => Promise<APIGatewayProxyStructuredResultV2 | unknown> {
129
+ const httpEnabled = options.http !== false;
121
130
  let cached: {
122
131
  ctx: PluginContext;
123
- router: (path: string, method: string) => Handler;
132
+ router?: (path: string, method: string) => Handler;
124
133
  actions: Record<string, ActionHandler>;
125
134
  logSink?: LogSink;
126
135
  } | null = null;
@@ -136,11 +145,6 @@ export function createPluginLambdaHandler(
136
145
  contributions.push(await plugin(ctx));
137
146
  }
138
147
 
139
- // Collect routes (plugin order = priority)
140
- const pluginRoutes = contributions.flatMap(c => c.routes ?? []);
141
- const appRoutes = options.routes?.(ctx) ?? [];
142
- const allRoutes = [...pluginRoutes, ...appRoutes];
143
-
144
148
  // Collect actions (app-level takes precedence on collision)
145
149
  const pluginActions: Record<string, ActionHandler> = {};
146
150
  for (const contribution of contributions) {
@@ -150,6 +154,20 @@ export function createPluginLambdaHandler(
150
154
  }
151
155
  const allActions = { ...pluginActions, ...(options.actions ?? {}) };
152
156
 
157
+ // Operator mode (http: false) — actions only. Skip routing entirely;
158
+ // HTTP-shaped events are rejected below. Routes contributed by plugins
159
+ // are intentionally ignored.
160
+ if (!httpEnabled) {
161
+ cached = { ctx, actions: allActions };
162
+ log('info', 'Plugin handlers initialized (actions only)', { plugins: options.plugins.length });
163
+ return cached;
164
+ }
165
+
166
+ // Collect routes (plugin order = priority)
167
+ const pluginRoutes = contributions.flatMap(c => c.routes ?? []);
168
+ const appRoutes = options.routes?.(ctx) ?? [];
169
+ const allRoutes = [...pluginRoutes, ...appRoutes];
170
+
153
171
  // Collect log sink (first one wins)
154
172
  const logSink = contributions.find(c => c.logSink)?.logSink;
155
173
 
@@ -182,6 +200,20 @@ export function createPluginLambdaHandler(
182
200
  }
183
201
  }
184
202
 
203
+ // Operator mode — reject anything that isn't an `_action` invoke. A caller
204
+ // with IAM invoke rights can hand the function any event shape; refuse to
205
+ // route HTTP on the privileged connection.
206
+ if (!httpEnabled) {
207
+ if ('requestContext' in event || 'rawPath' in event) {
208
+ return {
209
+ statusCode: 403,
210
+ headers: { 'Content-Type': 'application/json', 'cache-control': 'no-store' },
211
+ body: JSON.stringify({ error: 'Forbidden' }),
212
+ };
213
+ }
214
+ return { error: 'Missing _action' };
215
+ }
216
+
185
217
  // HTTP event — Function URL or API Gateway
186
218
  const httpEvent = event as APIGatewayProxyEventV2;
187
219
  const requestId =
@@ -194,7 +226,7 @@ export function createPluginLambdaHandler(
194
226
  const request = eventToRequest(httpEvent);
195
227
  const path = httpEvent.rawPath;
196
228
  const method = httpEvent.requestContext.http.method;
197
- const handlerFn = router(path, method);
229
+ const handlerFn = router!(path, method);
198
230
  const response = await handlerFn(request);
199
231
  const result = await responseToResult(response);
200
232
 
@@ -285,57 +317,19 @@ export interface OpsLambdaHandlerOptions {
285
317
  /**
286
318
  * Dedicated operator entrypoint — handles `_action` invokes only.
287
319
  *
320
+ * Thin alias for `createPluginLambdaHandler({ ...options, http: false })`.
288
321
  * Deploy as a separate Lambda (server/ops.ts) and link ADMIN_DATABASE_URL to
289
322
  * it instead of the API function. IAM (lambda:InvokeFunction) is the auth
290
- * layer, same as the API _action path. HTTP events are rejected: this
291
- * function must never be wired to a Function URL or the CDN.
323
+ * layer; HTTP-shaped events are rejected.
324
+ *
325
+ * @deprecated Prefer `createPluginLambdaHandler({ http: false })` directly.
292
326
  */
293
327
  export function createOpsLambdaHandler(
294
328
  options: OpsLambdaHandlerOptions
295
329
  ): (event: Record<string, unknown>) => Promise<unknown> {
296
- let cached: { ctx: PluginContext; actions: Record<string, ActionHandler> } | null = null;
297
-
298
- async function ensureInitialized() {
299
- if (cached) return cached;
300
- const ctx = await options.context();
301
- const actions: Record<string, ActionHandler> = {};
302
- for (const plugin of options.plugins) {
303
- const contribution = await plugin(ctx);
304
- if (contribution.actions) Object.assign(actions, contribution.actions);
305
- }
306
- Object.assign(actions, options.actions ?? {});
307
- cached = { ctx, actions };
308
- log('info', 'Ops handlers initialized', { actions: Object.keys(actions).length });
309
- return cached;
310
- }
311
-
312
- return async (event: Record<string, unknown>): Promise<unknown> => {
313
- if (!('_action' in event) || typeof event._action !== 'string') {
314
- // Not an operator invoke. Reject HTTP-shaped events explicitly.
315
- if ('requestContext' in event || 'rawPath' in event) {
316
- return {
317
- statusCode: 403,
318
- headers: { 'Content-Type': 'application/json', 'cache-control': 'no-store' },
319
- body: JSON.stringify({ error: 'Forbidden' }),
320
- };
321
- }
322
- return { error: 'Missing _action' };
323
- }
324
-
325
- const { ctx, actions } = await ensureInitialized();
326
- const actionName = event._action as string;
327
- const actionHandler = actions[actionName];
328
- if (!actionHandler) {
329
- return { error: `Unknown action: ${actionName}` };
330
- }
331
- log('info', 'Ops action invoked', { action: actionName });
332
- try {
333
- return await actionHandler(event._payload, ctx);
334
- } catch (error) {
335
- log('error', 'Ops action error', { action: actionName, error: String(error) });
336
- return { error: String(error) };
337
- }
338
- };
330
+ return createPluginLambdaHandler({ ...options, http: false }) as (
331
+ event: Record<string, unknown>
332
+ ) => Promise<unknown>;
339
333
  }
340
334
 
341
335
  // --- SSR Fallback Plugin ---