@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.
- package/package.json +3 -3
- 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.
|
|
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.
|
|
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",
|
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
|
|
108
|
-
|
|
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
|
|
158
|
-
withoutEnlargement:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
//
|
|
296
|
-
|
|
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
|
}
|