@everystack/server 0.2.3 → 0.2.4

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