@everystack/server 0.2.3 → 0.2.5
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/package.json +7 -3
- package/src/heic-decode.d.ts +9 -0
- package/src/image.ts +92 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/server",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"description": "Server runtime primitives for Lambda — event adapters, routing, SSR, image processing",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"publishConfig": {
|
|
@@ -59,12 +59,13 @@
|
|
|
59
59
|
"lint": "tsc --noEmit"
|
|
60
60
|
},
|
|
61
61
|
"peerDependencies": {
|
|
62
|
-
"@aws-sdk/client-s3": "3.
|
|
63
|
-
"@aws-sdk/s3-request-presigner": "3.
|
|
62
|
+
"@aws-sdk/client-s3": "3.1053.0",
|
|
63
|
+
"@aws-sdk/s3-request-presigner": "3.1053.0",
|
|
64
64
|
"@everystack/auth": ">=0.1.0",
|
|
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
|
@@ -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) {
|
|
@@ -201,10 +203,17 @@ export function createImageHandler(
|
|
|
201
203
|
const path = event.rawPath;
|
|
202
204
|
const key = path.replace(new RegExp(`^${pathPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`), '');
|
|
203
205
|
|
|
206
|
+
// Error responses must never be cached by CloudFront — stale 500s
|
|
207
|
+
// are invisible and hard to debug.
|
|
208
|
+
const errorHeaders = {
|
|
209
|
+
'Content-Type': 'application/json',
|
|
210
|
+
'Cache-Control': 'no-store',
|
|
211
|
+
};
|
|
212
|
+
|
|
204
213
|
if (!key) {
|
|
205
214
|
return {
|
|
206
215
|
statusCode: 400,
|
|
207
|
-
headers:
|
|
216
|
+
headers: errorHeaders,
|
|
208
217
|
body: JSON.stringify({ error: 'Missing image key' }),
|
|
209
218
|
};
|
|
210
219
|
}
|
|
@@ -213,7 +222,7 @@ export function createImageHandler(
|
|
|
213
222
|
if (key.includes('..') || key.startsWith('/')) {
|
|
214
223
|
return {
|
|
215
224
|
statusCode: 403,
|
|
216
|
-
headers:
|
|
225
|
+
headers: errorHeaders,
|
|
217
226
|
body: JSON.stringify({ error: 'Forbidden' }),
|
|
218
227
|
};
|
|
219
228
|
}
|
|
@@ -224,7 +233,7 @@ export function createImageHandler(
|
|
|
224
233
|
if (!allowed) {
|
|
225
234
|
return {
|
|
226
235
|
statusCode: 403,
|
|
227
|
-
headers:
|
|
236
|
+
headers: errorHeaders,
|
|
228
237
|
body: JSON.stringify({ error: 'Forbidden' }),
|
|
229
238
|
};
|
|
230
239
|
}
|
|
@@ -280,7 +289,7 @@ export function createImageHandler(
|
|
|
280
289
|
if (!result.Body) {
|
|
281
290
|
return {
|
|
282
291
|
statusCode: 404,
|
|
283
|
-
headers:
|
|
292
|
+
headers: errorHeaders,
|
|
284
293
|
body: JSON.stringify({ error: 'Image not found' }),
|
|
285
294
|
};
|
|
286
295
|
}
|
|
@@ -291,17 +300,39 @@ export function createImageHandler(
|
|
|
291
300
|
if (isNotFound(error)) {
|
|
292
301
|
return {
|
|
293
302
|
statusCode: 404,
|
|
294
|
-
headers:
|
|
303
|
+
headers: errorHeaders,
|
|
295
304
|
body: JSON.stringify({ error: 'Image not found' }),
|
|
296
305
|
};
|
|
297
306
|
}
|
|
298
307
|
throw error;
|
|
299
308
|
}
|
|
300
309
|
|
|
301
|
-
//
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
310
|
+
// Detect actual image format from magic bytes — content types from
|
|
311
|
+
// user uploads are frequently wrong (e.g. application/x-www-form-urlencoded
|
|
312
|
+
// on a 19 MB JPEG). Trust the bytes, not the metadata.
|
|
313
|
+
const sharp = (await import('sharp')).default;
|
|
314
|
+
let detectedFormat: string | undefined;
|
|
315
|
+
try {
|
|
316
|
+
const meta = await sharp(originalData).metadata();
|
|
317
|
+
detectedFormat = meta.format; // jpeg, png, webp, gif, tiff, heif, etc.
|
|
318
|
+
} catch {
|
|
319
|
+
// Sharp can't parse it — not an image
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (!detectedFormat) {
|
|
323
|
+
// Not an image — pass through non-image files (fonts, CSS, etc.)
|
|
324
|
+
// Lambda responses are capped at 6 MB. Base64 inflates ~33%.
|
|
325
|
+
const MAX_PASSTHROUGH = 4_500_000; // ~4.5 MB raw → ~6 MB base64
|
|
326
|
+
if (originalData.length > MAX_PASSTHROUGH) {
|
|
327
|
+
log('warn', 'File too large for passthrough', {
|
|
328
|
+
key, size: originalData.length, contentType: originalContentType,
|
|
329
|
+
});
|
|
330
|
+
return {
|
|
331
|
+
statusCode: 413,
|
|
332
|
+
headers: errorHeaders,
|
|
333
|
+
body: JSON.stringify({ error: 'File too large' }),
|
|
334
|
+
};
|
|
335
|
+
}
|
|
305
336
|
return {
|
|
306
337
|
statusCode: 200,
|
|
307
338
|
headers: {
|
|
@@ -316,10 +347,54 @@ export function createImageHandler(
|
|
|
316
347
|
// No transform params — serve original image as-is (EXIF-corrected webp)
|
|
317
348
|
const hasTransforms = params.w || params.h || params.fit || params.fm || params.q;
|
|
318
349
|
|
|
319
|
-
// Process with Sharp
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
350
|
+
// Process with Sharp — catch decode failures (e.g. HEIC without libheif)
|
|
351
|
+
let processed: { data: Buffer; contentType: string } | undefined;
|
|
352
|
+
try {
|
|
353
|
+
processed = hasTransforms
|
|
354
|
+
? await processImage(originalData, params)
|
|
355
|
+
: await processImage(originalData, { fm: 'webp', q: 80 });
|
|
356
|
+
} catch (sharpError) {
|
|
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
|
+
}
|
|
397
|
+
}
|
|
323
398
|
|
|
324
399
|
// --- Write rendered image to S3 cache (fire-and-forget) ---
|
|
325
400
|
if (useCache && s3CacheKey) {
|
|
@@ -351,7 +426,7 @@ export function createImageHandler(
|
|
|
351
426
|
log('error', 'Image processing error', { error: String(error), path: event.rawPath });
|
|
352
427
|
return {
|
|
353
428
|
statusCode: 500,
|
|
354
|
-
headers: { 'Content-Type': 'application/json' },
|
|
429
|
+
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' },
|
|
355
430
|
body: JSON.stringify({ error: 'Image processing failed' }),
|
|
356
431
|
};
|
|
357
432
|
}
|