@everystack/server 0.2.2 → 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 +80 -12
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/server",
3
- "version": "0.2.2",
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
@@ -104,8 +104,11 @@ export function parseParams(query: Record<string, string | undefined>): Transfor
104
104
  const h = parseInt(query.h, 10);
105
105
  if (h > 0 && h <= MAX_DIMENSION) params.h = h;
106
106
  }
107
- if (query.fit && VALID_FITS.includes(query.fit as any)) {
108
- params.fit = query.fit as TransformParams['fit'];
107
+ if (query.fit) {
108
+ const fit = query.fit === 'crop' ? 'cover' : query.fit;
109
+ if (VALID_FITS.includes(fit as any)) {
110
+ params.fit = fit as TransformParams['fit'];
111
+ }
109
112
  }
110
113
  if (query.fm && VALID_FORMATS.includes(query.fm as any)) {
111
114
  params.fm = query.fm as TransformParams['fm'];
@@ -151,11 +154,12 @@ export async function processImage(
151
154
 
152
155
  // Resize
153
156
  if (params.w || params.h) {
157
+ const fit = params.fit || 'cover';
154
158
  pipeline = pipeline.resize({
155
159
  width: params.w,
156
160
  height: params.h,
157
- fit: params.fit || 'cover',
158
- withoutEnlargement: true,
161
+ fit,
162
+ withoutEnlargement: fit !== 'cover' && fit !== 'fill',
159
163
  });
160
164
  }
161
165
 
@@ -197,10 +201,17 @@ export function createImageHandler(
197
201
  const path = event.rawPath;
198
202
  const key = path.replace(new RegExp(`^${pathPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`), '');
199
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
+
200
211
  if (!key) {
201
212
  return {
202
213
  statusCode: 400,
203
- headers: { 'Content-Type': 'application/json' },
214
+ headers: errorHeaders,
204
215
  body: JSON.stringify({ error: 'Missing image key' }),
205
216
  };
206
217
  }
@@ -209,7 +220,7 @@ export function createImageHandler(
209
220
  if (key.includes('..') || key.startsWith('/')) {
210
221
  return {
211
222
  statusCode: 403,
212
- headers: { 'Content-Type': 'application/json' },
223
+ headers: errorHeaders,
213
224
  body: JSON.stringify({ error: 'Forbidden' }),
214
225
  };
215
226
  }
@@ -220,7 +231,7 @@ export function createImageHandler(
220
231
  if (!allowed) {
221
232
  return {
222
233
  statusCode: 403,
223
- headers: { 'Content-Type': 'application/json' },
234
+ headers: errorHeaders,
224
235
  body: JSON.stringify({ error: 'Forbidden' }),
225
236
  };
226
237
  }
@@ -268,6 +279,7 @@ export function createImageHandler(
268
279
 
269
280
  // --- Fetch original from S3 for processing ---
270
281
  let originalData: Buffer;
282
+ let originalContentType: string | undefined;
271
283
  try {
272
284
  const result = await client.send(
273
285
  new GetObjectCommand({ Bucket: bucket, Key: key })
@@ -275,25 +287,81 @@ export function createImageHandler(
275
287
  if (!result.Body) {
276
288
  return {
277
289
  statusCode: 404,
278
- headers: { 'Content-Type': 'application/json' },
290
+ headers: errorHeaders,
279
291
  body: JSON.stringify({ error: 'Image not found' }),
280
292
  };
281
293
  }
294
+ originalContentType = result.ContentType || undefined;
282
295
  const bytes = await result.Body.transformToByteArray();
283
296
  originalData = Buffer.from(bytes);
284
297
  } catch (error: any) {
285
298
  if (isNotFound(error)) {
286
299
  return {
287
300
  statusCode: 404,
288
- headers: { 'Content-Type': 'application/json' },
301
+ headers: errorHeaders,
289
302
  body: JSON.stringify({ error: 'Image not found' }),
290
303
  };
291
304
  }
292
305
  throw error;
293
306
  }
294
307
 
295
- // Process with Sharp
296
- const processed = await processImage(originalData, params);
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
+ }
334
+ return {
335
+ statusCode: 200,
336
+ headers: {
337
+ 'Content-Type': originalContentType || 'application/octet-stream',
338
+ ...cacheHeaders,
339
+ },
340
+ body: originalData.toString('base64'),
341
+ isBase64Encoded: true,
342
+ };
343
+ }
344
+
345
+ // No transform params — serve original image as-is (EXIF-corrected webp)
346
+ const hasTransforms = params.w || params.h || params.fit || params.fm || params.q;
347
+
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
+ }
297
365
 
298
366
  // --- Write rendered image to S3 cache (fire-and-forget) ---
299
367
  if (useCache && s3CacheKey) {
@@ -325,7 +393,7 @@ export function createImageHandler(
325
393
  log('error', 'Image processing error', { error: String(error), path: event.rawPath });
326
394
  return {
327
395
  statusCode: 500,
328
- headers: { 'Content-Type': 'application/json' },
396
+ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' },
329
397
  body: JSON.stringify({ error: 'Image processing failed' }),
330
398
  };
331
399
  }