@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 +31 -0
- package/package.json +1 -1
- package/src/image.ts +66 -0
- package/src/plugin.ts +46 -52
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,
|
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
|
|
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
|
|
291
|
-
*
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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 ---
|