@everystack/server 0.3.1 → 0.3.3
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/backup-run.ts +45 -12
- package/src/image.ts +66 -1
- package/src/plugin.ts +5 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/server",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "Server runtime primitives for Lambda — event adapters, routing, SSR, image processing",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"author": "Scalable Technology, Inc. <licensing@scalable.technology>",
|
|
@@ -139,8 +139,8 @@
|
|
|
139
139
|
"sst": "4.13.1",
|
|
140
140
|
"ts-jest": "29.4.9",
|
|
141
141
|
"typescript": "5.9.3",
|
|
142
|
-
"@everystack/auth": "0.3.
|
|
143
|
-
"@everystack/cli": "0.3.
|
|
142
|
+
"@everystack/auth": "0.3.1",
|
|
143
|
+
"@everystack/cli": "0.3.27"
|
|
144
144
|
},
|
|
145
145
|
"scripts": {
|
|
146
146
|
"test": "jest",
|
package/src/backup-run.ts
CHANGED
|
@@ -132,17 +132,10 @@ export async function listBackups(deps: BackupRunDeps, stage: string): Promise<B
|
|
|
132
132
|
* pg_restore throws with its stderr. (We start strict and observe benign-error behavior on the
|
|
133
133
|
* deploy smoke test; the flags --no-owner/--no-privileges/--no-comments remove the usual offenders.)
|
|
134
134
|
*/
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const
|
|
139
|
-
const dbName = dbNameFromUrl(deps.adminUrl);
|
|
140
|
-
|
|
141
|
-
const s3 = await getS3(deps.region);
|
|
142
|
-
const { GetObjectCommand } = await import('@aws-sdk/client-s3');
|
|
143
|
-
const obj: any = await s3.send(new GetObjectCommand({ Bucket: deps.bucket, Key: key }));
|
|
144
|
-
if (!obj.Body) throw new Error(`Backup not found: ${id}`);
|
|
145
|
-
const body = obj.Body as Readable;
|
|
135
|
+
/** The shared restore pipeline: gzipped custom-format archive stream → gunzip → pg_restore. */
|
|
136
|
+
async function restoreFromStream(adminUrl: string, body: Readable): Promise<void> {
|
|
137
|
+
const env = { ...process.env, ...pgEnvFromUrl(adminUrl) };
|
|
138
|
+
const dbName = dbNameFromUrl(adminUrl);
|
|
146
139
|
|
|
147
140
|
const child = spawn('pg_restore', pgRestoreArgs(dbName), { env, stdio: ['pipe', 'ignore', 'pipe'] });
|
|
148
141
|
let stderr = '';
|
|
@@ -154,8 +147,48 @@ export async function runRestore(deps: BackupRunDeps, id: string): Promise<{ id:
|
|
|
154
147
|
});
|
|
155
148
|
|
|
156
149
|
await Promise.all([
|
|
157
|
-
pipeline(body, createGunzip(), child.stdin!), //
|
|
150
|
+
pipeline(body, createGunzip(), child.stdin!), // source → gunzip → pg_restore stdin
|
|
158
151
|
childExit,
|
|
159
152
|
]);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function runRestore(deps: BackupRunDeps, id: string): Promise<{ id: string; restored: true }> {
|
|
156
|
+
const key = keyForId(id);
|
|
157
|
+
if (!key) throw new Error(`Malformed backup id: ${id}`);
|
|
158
|
+
|
|
159
|
+
const s3 = await getS3(deps.region);
|
|
160
|
+
const { GetObjectCommand } = await import('@aws-sdk/client-s3');
|
|
161
|
+
const obj: any = await s3.send(new GetObjectCommand({ Bucket: deps.bucket, Key: key }));
|
|
162
|
+
if (!obj.Body) throw new Error(`Backup not found: ${id}`);
|
|
163
|
+
|
|
164
|
+
await restoreFromStream(deps.adminUrl, obj.Body as Readable);
|
|
160
165
|
return { id, restored: true };
|
|
161
166
|
}
|
|
167
|
+
|
|
168
|
+
/** The URL mode's precondition — a presigned http(s) download, never a path or bucket ref. */
|
|
169
|
+
export function assertRestoreUrl(url: string): void {
|
|
170
|
+
if (!/^https?:\/\//.test(url)) {
|
|
171
|
+
throw new Error('db:restore url must be an http(s) URL — a presigned backup download (everystack db:backup:download / db:fork mints one).');
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Restore from a presigned URL — the cross-stage fork's hand-off
|
|
177
|
+
* (docs/plans/branch-stages.md). A backup lives in its SOURCE stage's
|
|
178
|
+
* bucket, and this stage's Lambda has no IAM path to it; the operator who
|
|
179
|
+
* can read the source's backups and invoke this action IS the
|
|
180
|
+
* authorization, and the URL expires. Same stream pipeline as the id mode.
|
|
181
|
+
*/
|
|
182
|
+
export async function runRestoreFromUrl(
|
|
183
|
+
deps: Pick<BackupRunDeps, 'adminUrl'>,
|
|
184
|
+
url: string,
|
|
185
|
+
): Promise<{ restored: true }> {
|
|
186
|
+
assertRestoreUrl(url);
|
|
187
|
+
const res = await fetch(url);
|
|
188
|
+
if (!res.ok || !res.body) {
|
|
189
|
+
throw new Error(`fetching the backup failed: HTTP ${res.status} — presigned URLs expire in an hour; mint a fresh one.`);
|
|
190
|
+
}
|
|
191
|
+
const { Readable: NodeReadable } = await import('node:stream');
|
|
192
|
+
await restoreFromStream(deps.adminUrl, NodeReadable.fromWeb(res.body as any));
|
|
193
|
+
return { restored: true };
|
|
194
|
+
}
|
package/src/image.ts
CHANGED
|
@@ -155,6 +155,63 @@ export function parseParams(query: Record<string, string | undefined>): Transfor
|
|
|
155
155
|
return params;
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
/**
|
|
159
|
+
* ISOBMFF `ftyp` brands that mean "HEVC-coded HEIF" — i.e. a HEIC still image
|
|
160
|
+
* (iPhones shoot these by default). Sharp reports all of these as format
|
|
161
|
+
* `'heif'`, and our HEIC rescue path (heic-decode) can decode them.
|
|
162
|
+
*/
|
|
163
|
+
const HEIC_BRANDS = new Set([
|
|
164
|
+
'heic', 'heix', 'heim', 'heis', 'hevc', 'hevx', 'hevm', 'hevs', 'mif1', 'msf1',
|
|
165
|
+
]);
|
|
166
|
+
const AVIF_BRANDS = new Set(['avif', 'avis']);
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Identify an image format from its magic bytes, independently of Sharp.
|
|
170
|
+
*
|
|
171
|
+
* Sharp builds without libheif cannot even *identify* a HEIF container —
|
|
172
|
+
* `sharp(buf).metadata()` throws, format detection yields `undefined`, and a
|
|
173
|
+
* real HEIC photo is misrouted down the non-image passthrough path (413 when
|
|
174
|
+
* over the passthrough cap, or raw `image/heic` bytes that only Safari renders).
|
|
175
|
+
* Trusting the bytes instead of Sharp makes HEIC detection build-independent and
|
|
176
|
+
* lets the existing `heif` rescue path fire.
|
|
177
|
+
*
|
|
178
|
+
* Returns a Sharp-compatible format string (`jpeg`, `png`, `gif`, `webp`,
|
|
179
|
+
* `heif`, `avif`) or `undefined` when the bytes aren't a recognized image.
|
|
180
|
+
*/
|
|
181
|
+
export function sniffImageFormat(data: Buffer): string | undefined {
|
|
182
|
+
if (data.length < 3) return undefined;
|
|
183
|
+
|
|
184
|
+
// JPEG: FF D8 FF
|
|
185
|
+
if (data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) return 'jpeg';
|
|
186
|
+
// PNG: 89 50 4E 47 0D 0A 1A 0A
|
|
187
|
+
if (
|
|
188
|
+
data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47 &&
|
|
189
|
+
data[4] === 0x0d && data[5] === 0x0a && data[6] === 0x1a && data[7] === 0x0a
|
|
190
|
+
) return 'png';
|
|
191
|
+
// GIF: "GIF87a" / "GIF89a"
|
|
192
|
+
if (data.toString('ascii', 0, 3) === 'GIF') return 'gif';
|
|
193
|
+
// WebP: "RIFF"...."WEBP"
|
|
194
|
+
if (data.toString('ascii', 0, 4) === 'RIFF' && data.toString('ascii', 8, 12) === 'WEBP') return 'webp';
|
|
195
|
+
// TIFF: "II*\0" / "MM\0*"
|
|
196
|
+
if (
|
|
197
|
+
(data[0] === 0x49 && data[1] === 0x49 && data[2] === 0x2a && data[3] === 0x00) ||
|
|
198
|
+
(data[0] === 0x4d && data[1] === 0x4d && data[2] === 0x00 && data[3] === 0x2a)
|
|
199
|
+
) return 'tiff';
|
|
200
|
+
|
|
201
|
+
// ISOBMFF (HEIC/HEIF/AVIF): [4-byte size]["ftyp"][major brand][minor][compatible…]
|
|
202
|
+
if (data.toString('ascii', 4, 8) === 'ftyp') {
|
|
203
|
+
const boxSize = Math.min(data.readUInt32BE(0) || data.length, data.length);
|
|
204
|
+
const brands: string[] = [data.toString('ascii', 8, 12)];
|
|
205
|
+
for (let off = 16; off + 4 <= boxSize; off += 4) {
|
|
206
|
+
brands.push(data.toString('ascii', off, off + 4));
|
|
207
|
+
}
|
|
208
|
+
if (brands.some((b) => HEIC_BRANDS.has(b))) return 'heif';
|
|
209
|
+
if (brands.some((b) => AVIF_BRANDS.has(b))) return 'avif';
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return undefined;
|
|
213
|
+
}
|
|
214
|
+
|
|
158
215
|
function isNotFound(error: any): boolean {
|
|
159
216
|
return (
|
|
160
217
|
error.name === 'NotFound' ||
|
|
@@ -367,7 +424,15 @@ export function createImageHandler(
|
|
|
367
424
|
const meta = await sharp(originalData).metadata();
|
|
368
425
|
detectedFormat = meta.format; // jpeg, png, webp, gif, tiff, heif, etc.
|
|
369
426
|
} catch {
|
|
370
|
-
// Sharp can't parse it — not an image
|
|
427
|
+
// Sharp can't parse it — not an image, OR a HEIF container this Sharp
|
|
428
|
+
// build has no libheif to identify. Fall through to the magic-byte sniff.
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// A Sharp build without libheif throws on HEIF and leaves detectedFormat
|
|
432
|
+
// undefined, misrouting real HEIC photos to the non-image passthrough
|
|
433
|
+
// below. Sniff the bytes so `heif` is detected and the rescue path fires.
|
|
434
|
+
if (!detectedFormat) {
|
|
435
|
+
detectedFormat = sniffImageFormat(originalData);
|
|
371
436
|
}
|
|
372
437
|
|
|
373
438
|
if (!detectedFormat) {
|
package/src/plugin.ts
CHANGED
|
@@ -797,16 +797,17 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
797
797
|
};
|
|
798
798
|
|
|
799
799
|
actions['db:restore'] = async (payload, _ctx) => {
|
|
800
|
-
const { id, confirm } = (payload ?? {}) as { id?: string; confirm?: boolean };
|
|
801
|
-
if (!id) return { error:
|
|
800
|
+
const { id, url, confirm } = (payload ?? {}) as { id?: string; url?: string; confirm?: boolean };
|
|
801
|
+
if (!id && !url) return { error: "db:restore requires { id } (a backup in this stage's bucket) or { url } (a presigned cross-stage hand-off — db:fork mints one)" };
|
|
802
|
+
if (id && url) return { error: 'db:restore takes { id } or { url }, not both' };
|
|
802
803
|
if (confirm !== true) {
|
|
803
804
|
return { error: 'db:restore is destructive (drops + replaces the target DB). Pass confirm:true to proceed.' };
|
|
804
805
|
}
|
|
805
806
|
const r = await resolveBackupDeps();
|
|
806
807
|
if ('error' in r) return { error: r.error };
|
|
807
|
-
const { runRestore } = await import('./backup-run');
|
|
808
|
+
const { runRestore, runRestoreFromUrl } = await import('./backup-run');
|
|
808
809
|
try {
|
|
809
|
-
return await runRestore(r.deps, id);
|
|
810
|
+
return url ? await runRestoreFromUrl(r.deps, url) : await runRestore(r.deps, id!);
|
|
810
811
|
} catch (err: any) {
|
|
811
812
|
return { error: err?.message || String(err) };
|
|
812
813
|
}
|