@everystack/server 0.2.4 → 0.2.6
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 +15 -1
- package/package.json +5 -1
- package/src/heic-decode.d.ts +9 -0
- package/src/image.ts +89 -17
package/README.md
CHANGED
|
@@ -156,7 +156,7 @@ interface ImageHandlerConfig {
|
|
|
156
156
|
bucket: string; // S3 bucket name
|
|
157
157
|
region?: string; // AWS region (default: AWS_REGION env)
|
|
158
158
|
pathPrefix?: string; // URL prefix (default: '/media/')
|
|
159
|
-
cacheControl?: string; // Cache-Control header (default: 'public, max-age=
|
|
159
|
+
cacheControl?: string; // Cache-Control header (default: 'public, max-age=86400, s-maxage=31536000, stale-while-revalidate=60')
|
|
160
160
|
validateKey?: (key: string) => boolean | Promise<boolean>; // Optional key validator
|
|
161
161
|
s3Cache?: { // Persist rendered images to S3 (skip Sharp on repeat requests)
|
|
162
162
|
enabled: boolean;
|
|
@@ -185,6 +185,20 @@ const deleted = await deleteImageCache('my-bucket', 'uploads/photo.jpg');
|
|
|
185
185
|
|
|
186
186
|
Options: `{ region?: string; prefix?: string }` (prefix defaults to `'cache/'`).
|
|
187
187
|
|
|
188
|
+
### `purgeImage(bucket, key, options?)`
|
|
189
|
+
|
|
190
|
+
Delete all S3 cached renders and invalidate CloudFront cache for an image. Combines `deleteImageCache` with KVS path invalidation.
|
|
191
|
+
|
|
192
|
+
```typescript
|
|
193
|
+
import { purgeImage } from '@everystack/server/image';
|
|
194
|
+
|
|
195
|
+
const { deleted, invalidated } = await purgeImage('my-bucket', 'uploads/photo.jpg', {
|
|
196
|
+
kvsArn: process.env.KVS_ARN,
|
|
197
|
+
});
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Options: `{ region?: string; prefix?: string; kvsArn?: string; pathPrefix?: string }`. KVS invalidation is skipped when `kvsArn` is omitted.
|
|
201
|
+
|
|
188
202
|
### `parseParams(query)`
|
|
189
203
|
|
|
190
204
|
Validates URL query params into transform options:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/server",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
4
4
|
"description": "Server runtime primitives for Lambda — event adapters, routing, SSR, image processing",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"publishConfig": {
|
|
@@ -65,6 +65,7 @@
|
|
|
65
65
|
"@everystack/cli": ">=0.1.0",
|
|
66
66
|
"drizzle-orm": "0.41.0",
|
|
67
67
|
"postgres": "3.4.9",
|
|
68
|
+
"heic-decode": "2.1.0",
|
|
68
69
|
"sharp": "0.34.5",
|
|
69
70
|
"sst": "4.13.1"
|
|
70
71
|
},
|
|
@@ -78,6 +79,9 @@
|
|
|
78
79
|
"@everystack/cli": {
|
|
79
80
|
"optional": true
|
|
80
81
|
},
|
|
82
|
+
"heic-decode": {
|
|
83
|
+
"optional": true
|
|
84
|
+
},
|
|
81
85
|
"sharp": {
|
|
82
86
|
"optional": true
|
|
83
87
|
},
|
package/src/image.ts
CHANGED
|
@@ -30,8 +30,8 @@ export interface ImageHandlerConfig {
|
|
|
30
30
|
region?: string;
|
|
31
31
|
/** URL path prefix to strip (defaults to '/media/') */
|
|
32
32
|
pathPrefix?: string;
|
|
33
|
-
/** Cache-Control for image responses.
|
|
34
|
-
* Default: 'public, max-age=
|
|
33
|
+
/** Cache-Control for image responses and S3 cached renders.
|
|
34
|
+
* Default: 'public, max-age=86400, s-maxage=31536000, stale-while-revalidate=60' */
|
|
35
35
|
cacheControl?: string;
|
|
36
36
|
/**
|
|
37
37
|
* Optional key validator. Called before fetching from S3.
|
|
@@ -142,15 +142,17 @@ function isNotFound(error: any): boolean {
|
|
|
142
142
|
|
|
143
143
|
export async function processImage(
|
|
144
144
|
data: Buffer,
|
|
145
|
-
params: TransformParams
|
|
145
|
+
params: TransformParams,
|
|
146
|
+
raw?: { width: number; height: number; channels: 1 | 2 | 3 | 4 }
|
|
146
147
|
): Promise<{ data: Buffer; contentType: string }> {
|
|
147
148
|
// Sharp is provided via Lambda layer
|
|
148
149
|
const sharp = (await import('sharp')).default;
|
|
149
150
|
|
|
150
|
-
let pipeline = sharp(data);
|
|
151
|
+
let pipeline = raw ? sharp(data, { raw }) : sharp(data);
|
|
151
152
|
|
|
152
153
|
// Auto-orient based on EXIF metadata (fixes rotated portrait thumbnails)
|
|
153
|
-
|
|
154
|
+
// Skip for raw pixel input — no EXIF to orient
|
|
155
|
+
if (!raw) pipeline = pipeline.rotate();
|
|
154
156
|
|
|
155
157
|
// Resize
|
|
156
158
|
if (params.w || params.h) {
|
|
@@ -244,7 +246,7 @@ export function createImageHandler(
|
|
|
244
246
|
const client = new S3Client({ region });
|
|
245
247
|
|
|
246
248
|
const cacheHeaders = {
|
|
247
|
-
'Cache-Control': config.cacheControl ?? 'public, max-age=
|
|
249
|
+
'Cache-Control': config.cacheControl ?? 'public, max-age=86400, s-maxage=31536000, stale-while-revalidate=60',
|
|
248
250
|
'Vary': 'Accept',
|
|
249
251
|
};
|
|
250
252
|
|
|
@@ -346,21 +348,52 @@ export function createImageHandler(
|
|
|
346
348
|
const hasTransforms = params.w || params.h || params.fit || params.fm || params.q;
|
|
347
349
|
|
|
348
350
|
// Process with Sharp — catch decode failures (e.g. HEIC without libheif)
|
|
349
|
-
let processed: { data: Buffer; contentType: string };
|
|
351
|
+
let processed: { data: Buffer; contentType: string } | undefined;
|
|
350
352
|
try {
|
|
351
353
|
processed = hasTransforms
|
|
352
354
|
? await processImage(originalData, params)
|
|
353
355
|
: await processImage(originalData, { fm: 'webp', q: 80 });
|
|
354
356
|
} catch (sharpError) {
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
357
|
+
// HEIC fallback: decode via WASM (heic-decode) then re-process with Sharp
|
|
358
|
+
if (detectedFormat === 'heif') {
|
|
359
|
+
try {
|
|
360
|
+
const decode = (await import('heic-decode')).default;
|
|
361
|
+
const { width, height, data } = await decode({ buffer: originalData });
|
|
362
|
+
const rawBuffer = Buffer.from(data.buffer);
|
|
363
|
+
const transformParams = hasTransforms ? params : { fm: 'webp' as const, q: 80 };
|
|
364
|
+
processed = await processImage(rawBuffer, transformParams, { width, height, channels: 4 });
|
|
365
|
+
} catch (heicError) {
|
|
366
|
+
log('warn', 'HEIC decode failed, passing through original', {
|
|
367
|
+
key, contentType: originalContentType,
|
|
368
|
+
error: String(heicError),
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (!processed) {
|
|
374
|
+
log('warn', 'Sharp decode failed, passing through original', {
|
|
375
|
+
key, format: detectedFormat, contentType: originalContentType,
|
|
376
|
+
error: String(sharpError),
|
|
377
|
+
});
|
|
378
|
+
// Can't decode — pass through original bytes (better than 422)
|
|
379
|
+
const MAX_PASSTHROUGH = 4_500_000;
|
|
380
|
+
if (originalData.length > MAX_PASSTHROUGH) {
|
|
381
|
+
return {
|
|
382
|
+
statusCode: 422,
|
|
383
|
+
headers: errorHeaders,
|
|
384
|
+
body: JSON.stringify({ error: 'Unsupported image format' }),
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
return {
|
|
388
|
+
statusCode: 200,
|
|
389
|
+
headers: {
|
|
390
|
+
'Content-Type': originalContentType || 'application/octet-stream',
|
|
391
|
+
...cacheHeaders,
|
|
392
|
+
},
|
|
393
|
+
body: originalData.toString('base64'),
|
|
394
|
+
isBase64Encoded: true,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
364
397
|
}
|
|
365
398
|
|
|
366
399
|
// --- Write rendered image to S3 cache (fire-and-forget) ---
|
|
@@ -372,7 +405,7 @@ export function createImageHandler(
|
|
|
372
405
|
Key: s3CacheKey,
|
|
373
406
|
Body: processed.data,
|
|
374
407
|
ContentType: processed.contentType,
|
|
375
|
-
CacheControl: '
|
|
408
|
+
CacheControl: cacheHeaders['Cache-Control'],
|
|
376
409
|
})
|
|
377
410
|
)
|
|
378
411
|
.catch((err: unknown) => {
|
|
@@ -445,3 +478,42 @@ export async function deleteImageCache(
|
|
|
445
478
|
|
|
446
479
|
return deleted;
|
|
447
480
|
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Purge an image: delete all S3 cached renders and invalidate CloudFront.
|
|
484
|
+
*
|
|
485
|
+
* Call this when an original image is deleted or replaced. Combines
|
|
486
|
+
* `deleteImageCache()` (S3 cleanup) with KVS path invalidation
|
|
487
|
+
* (CloudFront cache expiry for all variants of the image).
|
|
488
|
+
*
|
|
489
|
+
* KVS invalidation is optional — when `kvsArn` is omitted, only S3
|
|
490
|
+
* cached renders are deleted.
|
|
491
|
+
*/
|
|
492
|
+
export async function purgeImage(
|
|
493
|
+
bucket: string,
|
|
494
|
+
key: string,
|
|
495
|
+
options?: {
|
|
496
|
+
region?: string;
|
|
497
|
+
/** S3 cache prefix. Default: 'cache/' */
|
|
498
|
+
prefix?: string;
|
|
499
|
+
/** CloudFront KVS ARN. When provided, invalidates CDN cache. */
|
|
500
|
+
kvsArn?: string;
|
|
501
|
+
/** URL path prefix for CDN invalidation. Default: '/media/' */
|
|
502
|
+
pathPrefix?: string;
|
|
503
|
+
}
|
|
504
|
+
): Promise<{ deleted: number; invalidated: boolean }> {
|
|
505
|
+
const deleted = await deleteImageCache(bucket, key, {
|
|
506
|
+
region: options?.region,
|
|
507
|
+
prefix: options?.prefix,
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
let invalidated = false;
|
|
511
|
+
if (options?.kvsArn) {
|
|
512
|
+
const { invalidateKvsPaths } = await import('./kvs.js');
|
|
513
|
+
const mediaPath = (options.pathPrefix ?? '/media/') + key;
|
|
514
|
+
await invalidateKvsPaths(options.kvsArn, [mediaPath]);
|
|
515
|
+
invalidated = true;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
return { deleted, invalidated };
|
|
519
|
+
}
|