@everystack/server 0.2.15 → 0.2.17

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.15",
3
+ "version": "0.2.17",
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
@@ -15,6 +15,7 @@ import type {
15
15
  APIGatewayProxyStructuredResultV2,
16
16
  } from 'aws-lambda';
17
17
  import { log } from './log';
18
+ import { buildTimingHeaders } from './timing';
18
19
 
19
20
  export interface S3CacheConfig {
20
21
  /** Enable S3 origin cache for processed images. Default: false. */
@@ -45,6 +46,29 @@ export interface ImageHandlerConfig {
45
46
  * via S3 lifecycle rules (configure on the bucket, not here).
46
47
  */
47
48
  s3Cache?: S3CacheConfig;
49
+ /**
50
+ * Emit additive cache-state + timing headers on image responses
51
+ * (`x-image-cache`, `x-render-ms`, `x-image-ms`, `x-fetch-ms`,
52
+ * `Server-Timing`). Default: true.
53
+ *
54
+ * These headers are non-sensitive (cache state + timings) and double
55
+ * as production render-time monitoring, so default-on is intended.
56
+ *
57
+ * CONSUMER TRUTH TABLE — three pipeline states, read x-cache FIRST:
58
+ *
59
+ * x-cache (CloudFront) | x-image-cache (Lambda) | meaning
60
+ * ---------------------|------------------------|----------------------------
61
+ * Hit from cloudfront | (ignore — stale) | EDGE HIT, Lambda not invoked
62
+ * Miss from cloudfront | hit | S3 RENDER-CACHE HIT, no Sharp
63
+ * Miss from cloudfront | render | FRESH RENDER (x-render-ms = Sharp time)
64
+ *
65
+ * CRITICAL CAVEAT: CloudFront caches the response INCLUDING these
66
+ * headers and replays them on edge hits, so on an edge hit
67
+ * `x-image-cache` reflects the ORIGINAL render, not this request.
68
+ * Consumers MUST read CloudFront's `x-cache` first — `x-image-*` are
69
+ * authoritative only when `x-cache` is a Miss.
70
+ */
71
+ observability?: boolean;
48
72
  }
49
73
 
50
74
  export interface TransformParams {
@@ -196,8 +220,28 @@ export function createImageHandler(
196
220
  ): (event: APIGatewayProxyEventV2) => Promise<APIGatewayProxyStructuredResultV2> {
197
221
  const { bucket, pathPrefix = '/media/', validateKey } = config;
198
222
  const region = config.region || process.env.AWS_REGION;
223
+ const observability = config.observability !== false;
224
+
225
+ /**
226
+ * Build additive cache-state + timing headers. Returns {} when
227
+ * observability is disabled so responses are byte-identical to before.
228
+ */
229
+ function timingHeaders(opts: {
230
+ cache?: 'hit' | 'render';
231
+ fetchMs?: number;
232
+ renderMs?: number;
233
+ totalMs: number;
234
+ }): Record<string, string> {
235
+ if (!observability) return {};
236
+ return buildTimingHeaders('image', {
237
+ cache: opts.cache,
238
+ totalMs: opts.totalMs,
239
+ segments: { fetch: opts.fetchMs, render: opts.renderMs },
240
+ });
241
+ }
199
242
 
200
243
  return async (event: APIGatewayProxyEventV2): Promise<APIGatewayProxyStructuredResultV2> => {
244
+ const t0 = Date.now();
201
245
  try {
202
246
  // Extract key from path: /media/uploads/abc.jpg → uploads/abc.jpg
203
247
  const path = event.rawPath;
@@ -258,16 +302,20 @@ export function createImageHandler(
258
302
  if (useCache) {
259
303
  s3CacheKey = computeCacheKey(key, params, cachePrefix, queryParams._v);
260
304
  try {
305
+ const fetchStart = Date.now();
261
306
  const cached = await client.send(
262
307
  new GetObjectCommand({ Bucket: bucket, Key: s3CacheKey })
263
308
  );
264
309
  if (cached.Body) {
265
310
  const bytes = await cached.Body.transformToByteArray();
311
+ const fetchMs = Date.now() - fetchStart;
266
312
  return {
267
313
  statusCode: 200,
268
314
  headers: {
269
315
  'Content-Type': cached.ContentType || FORMAT_CONTENT_TYPES[params.fm || 'webp'],
270
316
  ...cacheHeaders,
317
+ // S3 render-cache hit — no Sharp ran. See truth table on ImageHandlerConfig.observability.
318
+ ...timingHeaders({ cache: 'hit', fetchMs, totalMs: Date.now() - t0 }),
271
319
  },
272
320
  body: Buffer.from(bytes).toString('base64'),
273
321
  isBase64Encoded: true,
@@ -282,7 +330,9 @@ export function createImageHandler(
282
330
  // --- Fetch original from S3 for processing ---
283
331
  let originalData: Buffer;
284
332
  let originalContentType: string | undefined;
333
+ let fetchMs: number | undefined;
285
334
  try {
335
+ const fetchStart = Date.now();
286
336
  const result = await client.send(
287
337
  new GetObjectCommand({ Bucket: bucket, Key: key })
288
338
  );
@@ -296,6 +346,7 @@ export function createImageHandler(
296
346
  originalContentType = result.ContentType || undefined;
297
347
  const bytes = await result.Body.transformToByteArray();
298
348
  originalData = Buffer.from(bytes);
349
+ fetchMs = Date.now() - fetchStart;
299
350
  } catch (error: any) {
300
351
  if (isNotFound(error)) {
301
352
  return {
@@ -338,6 +389,7 @@ export function createImageHandler(
338
389
  headers: {
339
390
  'Content-Type': originalContentType || 'application/octet-stream',
340
391
  ...cacheHeaders,
392
+ ...timingHeaders({ fetchMs, totalMs: Date.now() - t0 }),
341
393
  },
342
394
  body: originalData.toString('base64'),
343
395
  isBase64Encoded: true,
@@ -348,7 +400,9 @@ export function createImageHandler(
348
400
  const hasTransforms = params.w || params.h || params.fit || params.fm || params.q;
349
401
 
350
402
  // Process with Sharp — catch decode failures (e.g. HEIC without libheif)
403
+ // Time spent here is the Sharp render cost reported as x-render-ms.
351
404
  let processed: { data: Buffer; contentType: string } | undefined;
405
+ const renderStart = Date.now();
352
406
  try {
353
407
  processed = hasTransforms
354
408
  ? await processImage(originalData, params)
@@ -389,6 +443,7 @@ export function createImageHandler(
389
443
  headers: {
390
444
  'Content-Type': originalContentType || 'application/octet-stream',
391
445
  ...cacheHeaders,
446
+ ...timingHeaders({ fetchMs, totalMs: Date.now() - t0 }),
392
447
  },
393
448
  body: originalData.toString('base64'),
394
449
  isBase64Encoded: true,
@@ -396,6 +451,8 @@ export function createImageHandler(
396
451
  }
397
452
  }
398
453
 
454
+ const renderMs = Date.now() - renderStart;
455
+
399
456
  // --- Write rendered image to S3 cache (fire-and-forget) ---
400
457
  if (useCache && s3CacheKey) {
401
458
  client
@@ -418,6 +475,8 @@ export function createImageHandler(
418
475
  headers: {
419
476
  'Content-Type': processed.contentType,
420
477
  ...cacheHeaders,
478
+ // Fresh render — Sharp ran. x-render-ms is the Sharp cost.
479
+ ...timingHeaders({ cache: 'render', fetchMs, renderMs, totalMs: Date.now() - t0 }),
421
480
  },
422
481
  body: processed.data.toString('base64'),
423
482
  isBase64Encoded: true,
package/src/index.ts CHANGED
@@ -16,6 +16,10 @@ import type {
16
16
  } from 'aws-lambda';
17
17
 
18
18
  import { log } from './log';
19
+ import { buildTimingHeaders } from './timing';
20
+
21
+ export { buildTimingHeaders } from './timing';
22
+ export type { TimingHeaderOptions } from './timing';
19
23
 
20
24
  // --- Types ---
21
25
 
@@ -80,6 +84,8 @@ export interface LambdaHandlerOptions {
80
84
  /** Optional log sink — writes structured request logs to S3 or other storage.
81
85
  * Non-blocking: if the sink fails, logs still go to CloudWatch via stdout. */
82
86
  logSink?: LogSink;
87
+ /** Emit Server-Timing headers on responses. Default true. */
88
+ observability?: boolean;
83
89
  }
84
90
 
85
91
  // --- Event Adapter ---
@@ -224,10 +230,14 @@ export function createLambdaHandler(
224
230
  }
225
231
  // HTTP event — Function URL or API Gateway
226
232
  const httpEvent = event as APIGatewayProxyEventV2;
233
+ // Prefer the CloudFront edge request id: it also lands in the CDN access
234
+ // logs (x-edge-request-id), so app logs join to platform_requests rows.
227
235
  const requestId =
236
+ httpEvent.headers?.['x-amz-cf-id'] ||
228
237
  httpEvent.headers?.['x-amzn-trace-id'] ||
229
238
  httpEvent.headers?.['x-request-id'] ||
230
239
  crypto.randomUUID();
240
+ const t0 = Date.now();
231
241
 
232
242
  try {
233
243
  const { router } = await ensureInitialized();
@@ -241,6 +251,11 @@ export function createLambdaHandler(
241
251
  // Propagate request ID
242
252
  result.headers = { ...result.headers, 'x-request-id': requestId };
243
253
 
254
+ // Timing observability — additive, mirrors the image handler's headers
255
+ if (options.observability !== false && !result.headers['Server-Timing']) {
256
+ Object.assign(result.headers, buildTimingHeaders('server', { totalMs: Date.now() - t0 }));
257
+ }
258
+
244
259
  // Set Cache-Control if not already set by the handler
245
260
  if (!result.headers['cache-control']) {
246
261
  if (method === 'GET' || method === 'HEAD') {
package/src/migrate.ts CHANGED
@@ -1,16 +1,92 @@
1
1
  /**
2
- * @everystack/server/migrate — Drizzle migration runner.
2
+ * @everystack/server/migrate — Drizzle migration runner that cannot lie.
3
+ *
4
+ * drizzle's migrate() only runs what meta/_journal.json lists, and reports
5
+ * nothing either way. A SQL file missing from the journal silently never
6
+ * applies — a failure mode that is miserable to debug. This wrapper:
7
+ *
8
+ * - warns about migration files with no journal entry (the silent skip)
9
+ * - fails fast, by name, on journal entries with no SQL file
10
+ * - reports exactly which migrations applied this run
3
11
  *
4
12
  * Called directly from onAction handler (CLI → IAM → Lambda invoke).
5
13
  * No HTTP, no auth — IAM is the authorization layer.
6
14
  */
7
15
 
16
+ import { readdirSync, readFileSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+ import { sql } from 'drizzle-orm';
8
19
  import { migrate } from 'drizzle-orm/postgres-js/migrator';
9
20
 
21
+ export interface MigrationResult {
22
+ success: boolean;
23
+ message: string;
24
+ /** Journal tags applied in this run. */
25
+ applied: string[];
26
+ /** Drift warnings — surface these to the operator, always. */
27
+ warnings: string[];
28
+ }
29
+
30
+ interface JournalEntry {
31
+ tag: string;
32
+ when: number;
33
+ }
34
+
35
+ function extractRows(result: unknown): Array<Record<string, unknown>> {
36
+ if (Array.isArray(result)) return result as Array<Record<string, unknown>>;
37
+ return ((result as { rows?: unknown[] })?.rows ?? []) as Array<Record<string, unknown>>;
38
+ }
39
+
40
+ /** Highest created_at in drizzle's ledger, or -1 before the first run. */
41
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
42
+ async function lastAppliedWhen(db: any): Promise<number> {
43
+ try {
44
+ const result = await db.execute(
45
+ sql`SELECT max(created_at)::bigint AS max FROM drizzle.__drizzle_migrations`,
46
+ );
47
+ const max = extractRows(result)[0]?.max;
48
+ return max === null || max === undefined ? -1 : Number(max);
49
+ } catch {
50
+ return -1; // ledger table doesn't exist yet — nothing applied
51
+ }
52
+ }
53
+
10
54
  export async function runMigrations(
55
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
11
56
  db: any,
12
57
  migrationsFolder: string
13
- ): Promise<{ success: boolean; message: string }> {
58
+ ): Promise<MigrationResult> {
59
+ const journal = JSON.parse(
60
+ readFileSync(join(migrationsFolder, 'meta/_journal.json'), 'utf8'),
61
+ ) as { entries: JournalEntry[] };
62
+ const entries = journal.entries ?? [];
63
+ const files = readdirSync(migrationsFolder).filter((f) => /^\d{4}_.+\.sql$/.test(f));
64
+ const tags = new Set(entries.map((e) => e.tag));
65
+
66
+ // Journal entry without a file: drizzle would die with an opaque ENOENT.
67
+ const ghosts = entries.filter((e) => !files.includes(`${e.tag}.sql`)).map((e) => e.tag);
68
+ if (ghosts.length > 0) {
69
+ throw new Error(
70
+ `Journal entries with no SQL file (broken journal): ${ghosts.join(', ')}`,
71
+ );
72
+ }
73
+
74
+ // File without a journal entry: drizzle will silently NEVER apply it.
75
+ const warnings = files
76
+ .filter((f) => !tags.has(f.replace(/\.sql$/, '')))
77
+ .map((f) => `${f} has no entry in meta/_journal.json — drizzle will NEVER apply it`);
78
+
79
+ const before = await lastAppliedWhen(db);
14
80
  await migrate(db, { migrationsFolder });
15
- return { success: true, message: 'Migrations applied' };
81
+
82
+ const applied = entries
83
+ .filter((e) => e.when > before)
84
+ .map((e) => e.tag);
85
+
86
+ const message =
87
+ applied.length > 0
88
+ ? `Applied ${applied.length} migration(s): ${applied.join(', ')}`
89
+ : 'Already up to date — no new migrations';
90
+
91
+ return { success: true, message, applied, warnings };
16
92
  }
package/src/plugin.ts CHANGED
@@ -216,7 +216,10 @@ export function createPluginLambdaHandler(
216
216
 
217
217
  // HTTP event — Function URL or API Gateway
218
218
  const httpEvent = event as APIGatewayProxyEventV2;
219
+ // Prefer the CloudFront edge request id: it also lands in the CDN access
220
+ // logs (x-edge-request-id), so app logs join to platform_requests rows.
219
221
  const requestId =
222
+ httpEvent.headers?.['x-amz-cf-id'] ||
220
223
  httpEvent.headers?.['x-amzn-trace-id'] ||
221
224
  httpEvent.headers?.['x-request-id'] ||
222
225
  crypto.randomUUID();
package/src/timing.ts ADDED
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Cache-state + timing observability headers.
3
+ *
4
+ * One builder for every handler (image, SSR, API): prefixed x-<name>-ms
5
+ * headers for machines, a Server-Timing mirror for browser devtools.
6
+ * Additive only — callers gate on their own observability flag and spread
7
+ * the result into response headers.
8
+ */
9
+
10
+ export interface TimingHeaderOptions {
11
+ /** Cache state, e.g. 'hit' | 'render'. Emitted as x-<prefix>-cache. */
12
+ cache?: string;
13
+ /** Total handler wall time. Emitted as x-<prefix>-ms. */
14
+ totalMs: number;
15
+ /** Named sub-timings, each emitted as x-<name>-ms. Undefined values skip. */
16
+ segments?: Record<string, number | undefined>;
17
+ }
18
+
19
+ export function buildTimingHeaders(
20
+ prefix: string,
21
+ options: TimingHeaderOptions,
22
+ ): Record<string, string> {
23
+ const headers: Record<string, string> = {
24
+ [`x-${prefix}-ms`]: String(options.totalMs),
25
+ };
26
+ if (options.cache) headers[`x-${prefix}-cache`] = options.cache;
27
+
28
+ const timings: string[] = [];
29
+ if (options.cache) timings.push(`cache;desc=${options.cache}`);
30
+ for (const [name, ms] of Object.entries(options.segments ?? {})) {
31
+ if (ms === undefined) continue;
32
+ headers[`x-${name}-ms`] = String(ms);
33
+ timings.push(`${name};dur=${ms}`);
34
+ }
35
+ timings.push(`total;dur=${options.totalMs}`);
36
+ headers['Server-Timing'] = timings.join(', ');
37
+
38
+ return headers;
39
+ }