@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 +31 -0
- package/package.json +1 -1
- package/src/image.ts +59 -0
- package/src/index.ts +15 -0
- package/src/migrate.ts +79 -3
- package/src/plugin.ts +3 -0
- package/src/timing.ts +39 -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
|
@@ -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<
|
|
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
|
-
|
|
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
|
+
}
|