@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/server",
3
- "version": "0.2.3",
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.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",
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
  },
@@ -0,0 +1,9 @@
1
+ declare module 'heic-decode' {
2
+ interface DecodedImage {
3
+ width: number;
4
+ height: number;
5
+ data: Uint8ClampedArray;
6
+ }
7
+ function decode(opts: { buffer: Buffer | ArrayBuffer }): Promise<DecodedImage>;
8
+ export default decode;
9
+ }
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
- pipeline = pipeline.rotate();
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: { 'Content-Type': 'application/json' },
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: { 'Content-Type': 'application/json' },
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: { 'Content-Type': 'application/json' },
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: { 'Content-Type': 'application/json' },
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: { 'Content-Type': 'application/json' },
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
- // 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) {
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
- const processed = hasTransforms
321
- ? await processImage(originalData, params)
322
- : await processImage(originalData, { fm: 'webp', q: 80 });
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
  }