import { createPresignedPost } from "@aws-sdk/s3-presigned-post";
const { url, fields } = await createPresignedPost(s3, {
Bucket: "uploads-quarantine",
Key: `u/${userId}/${crypto.randomUUID()}`,
Conditions: [
["content-length-range", 1, 100 * 1024 * 1024],
["starts-with", "$Content-Type", "video/"],
],
Fields: { "Content-Type": declaredType },
Expires: 300, // seconds
});
// browser: FormData(fields..., file LAST) → POST url
Scenarios: File Upload, Download & Streaming
draftedMock interview production scenarios — upload 100MB đồng thời, presigned URL/POST, multipart & resumable, streaming qua backend với backpressure, validate file, download/Range/zip, SSE/NDJSON/LLM streaming, Socket.IO realtime ở scale, media pipeline và chi phí S3.
0/58 rated · mastery 0%
Bài học
6 bài · ~95 phút đọc. Học theo thứ tự, rồi làm câu hỏi.- 01~17 phút· 11 câuverify
Upload file lớn: đưa bytes ra khỏi API
Playbook cho 100 upload 100 MB đồng thời: tính memory, presigned PUT vs POST, state machine + confirm, giới hạn từng tầng, burst deadline và thiết kế file service multi-tenant. Có số đo thật: Multer memoryStorage vs busboy, presigned PUT/POST trên MinIO.
- 02~15 phút· 7 câuverify
Multipart, resumable upload và integrity
Part size, CORS ExposeHeaders ETag, resume bằng ListParts, progress/cancel/retry, checksum và ETag multipart, S3 vs tus vs tự viết, incomplete MPU trong bill. Có lab thật trên MinIO: resume sau crash, ETag = md5 của các md5 + -N, EntityTooSmall.
- 03~15 phút· 8 câuverify
Stream upload qua backend với backpressure
Khi bytes bắt buộc đi qua Node: busboy → Transform → lib-storage, memory thật, limit/truncated, abort và leak, Transform async mất backpressure, thiết kế 200 upload 1 GB trên pod 2 GB.
- 04~16 phút· 11 câuverify
Validate, quét virus và pipeline xử lý file
Magic bytes, re-encode, SVG XSS, path traversal, zip bomb/pixel bomb, EXIF, header khi serve, quarantine + scan fail-closed, S3 event at-least-once, job transcode idempotent, threat model.
- 05~18 phút· 12 câuverify
Download, Range request, ZIP và export lớn
Presigned GET/CloudFront signed URL/cookie, proxy stream đúng (pipeline, Web Streams), Range/206/416, ZIP on-the-fly vs async, export NDJSON/CSV qua COPY, HLS và chi phí S3/CloudFront.
- 06~15 phút· 9 câuverify
SSE, LLM streaming và Socket.IO ở scale
SSE vs WebSocket, buffering ở proxy/compression, huỷ upstream LLM, Last-Event-ID replay, slow consumer và conflation, seq + snapshot khi reconnect, Redis adapter fan-out, half-open connection.
Tóm tắt & cheat sheet của track
TL;DR
- Quy tắc số 1: bytes không đi qua API server nếu không bắt buộc. Client upload/download thẳng với object storage (S3) bằng presigned URL/POST hoặc CloudFront signed URL/cookie; API chỉ ký, kiểm quyền, lưu metadata.
- Khi bytes bắt buộc đi qua backend: stream end-to-end (busboy →
@aws-sdk/lib-storageUpload), giới hạn size trong lúc stream, abort khi client ngắt, và hiểu memory thật =partSize × queueSize × số upload đồng thời, không phải "64KB". - Upload không "xong" khi client nói xong: dùng state machine trong DB (
pending → uploaded → scanned → ready | rejected), xác nhận bằngHeadObject/S3 event, xử lý nặng (scan, thumbnail, transcode) qua queue + worker idempotent. - Validate như kẻ tấn công: không tin
Content-Type/extension, sniff magic bytes, re-encode ảnh, giới hạn giải nén, key do server sinh, serve từ domain riêng +Content-Disposition+nosniff. - Streaming response (NDJSON, SSE, LLM tokens, Socket.IO) chết vì buffering ở giữa (proxy, compression), slow consumer và reconnect không resync. Luôn có heartbeat, sequence number, và chiến lược drop/coalesce.
Khái niệm cốt lõi
| Khái niệm | Một câu | Ví dụ ngắn |
|---|---|---|
| Presigned PUT | URL ký SigV4 cho phép PUT đúng 1 key, trong TTL | getSignedUrl(s3, new PutObjectCommand({Bucket, Key}), { expiresIn: 300 }) |
| Presigned POST | Form POST + policy có điều kiện (size, content-type prefix, key prefix) | ["content-length-range", 1, 104857600] |
| Multipart upload | Chia object thành part (≥5 MiB trừ part cuối, tối đa 10,000 part), upload song song, Complete bằng danh sách ETag | 100 MB → 10 part × 10 MB |
| Resumable upload | Lưu uploadId + part đã xong, ListParts để tiếp tục | Browser đóng tab, mở lại upload tiếp part 7 |
| Backpressure | Consumer chậm báo producer dừng (write() trả false → chờ 'drain') | S3 chậm → busboy ngừng đọc socket → TCP window co lại |
highWaterMark | Ngưỡng buffer nội bộ của stream trước khi báo "đầy" | fs.createReadStream mặc định 64 KiB |
pipeline() | Nối stream + propagate error + destroy tất cả khi lỗi/abort | await pipeline(s3Body, gzip, res) |
| Range request | Client xin một đoạn byte → server trả 206 Partial Content | Range: bytes=0-1048575 |
| NDJSON | Mỗi dòng một JSON object, parse được từng dòng | {"id":1}\n{"id":2}\n |
| SSE | text/event-stream một chiều server→client, tự reconnect với Last-Event-ID | id: 42\ndata: {...}\n\n |
| Conflation / coalesce | Gộp nhiều update của cùng key, chỉ gửi bản mới nhất cho client chậm | Giá AAPL đổi 50 lần/s, gửi 4 lần/s |
| Magic bytes | Chữ ký đầu file xác định format thật | PNG bắt đầu 89 50 4E 47 |
Cơ chế hoạt động
Direct-to-S3 upload với confirm + async processing
Upload bắt buộc qua backend — streaming với backpressure
Realtime stream (Socket.IO) với resync
Khung trả lời
Dùng cho mọi câu "file lớn / streaming trong production":
- Hỏi lại con số: kích thước file (p50/p99/max), số upload/download đồng thời, client (browser/mobile/server), SLA, data nhạy cảm không, có yêu cầu compliance bắt bytes đi qua mình không.
- Tính memory/bandwidth trên giấy:
concurrent × buffered bytes. 100 × 100 MB buffered = 10 GB; 100 × (5 MB × 4) lib-storage = 2 GB; 100 × 64 KB stream thuần = 6.4 MB. - Đưa bytes ra khỏi API: presigned POST/PUT, multipart presigned từng part, CloudFront cho download.
- Nếu phải qua backend: stream end-to-end, size limit lúc stream, abort + cleanup, giới hạn ở proxy/LB, admission control (số upload đồng thời/pod → 429/503).
- Trạng thái & bất đồng bộ: state machine DB, event → queue → worker idempotent, không serve trước khi scan xong.
- Bảo mật: magic bytes, re-encode, zip/decompression limits, key server-generated, domain riêng,
nosniff,Content-Disposition. - Vận hành & chi phí: lifecycle rules, incomplete multipart, request cost, egress, metrics (bytes in-flight, upload fail rate, p99 duration), alert.
Trade-offs
| Cách upload | Memory API | Enforce size | Validate trước khi lưu | Resumable | Phức tạp | Hợp khi |
|---|---|---|---|---|---|---|
Multer memoryStorage | Toàn bộ file × N | limits.fileSize | Có | Không | Thấp | File nhỏ (avatar < 5 MB), traffic thấp |
Multer diskStorage | Thấp, nhưng tốn disk tạm | Có | Có (đọc lại từ disk) | Không | Thấp | Cần CLI xử lý file local |
| busboy → lib-storage | partSize × queueSize mỗi upload | Có, lúc stream | Có (inline) | Không (trừ tự làm) | Trung bình | Compliance bắt đi qua backend |
| Presigned PUT | ~0 | Không (chỉ ký header) | Sau khi upload | Không | Thấp | File vừa, client tin cậy tương đối |
| Presigned POST | ~0 | Có (content-length-range) | Sau khi upload | Không | Thấp | Browser form upload, cần chặn size |
| Multipart presigned từng part | ~0 | Qua số part × part size + check khi Complete | Sau khi upload | Có | Cao | File > 100 MB, mạng yếu |
| tus protocol (tusd/Uppy) | Tuỳ server | Có (Upload-Length, max-size) | Có hook | Có, chuẩn hoá | Trung bình | Cần resumable đa nền tảng, không lock vào S3 API |
| Streaming response | Hướng | Reconnect | Qua proxy | Hợp khi |
|---|---|---|---|---|
| Chunked + NDJSON | Server → client, 1 request | Tự làm (cursor) | Dễ bị buffer | Export lớn, batch API |
| SSE | Server → client | Built-in Last-Event-ID | Cần tắt buffering, HTTP/1.1 giới hạn ~6 connection/host | LLM tokens, notification, dashboard |
| WebSocket / Socket.IO | Hai chiều | Tự làm (Socket.IO có connection state recovery) | Cần Upgrade, sticky session | Trading, chat, collaborative |
Pitfalls
- Tin rằng "dùng Multer là stream":
memoryStoragegiữ nguyên file trongfile.buffer;diskStorageghi ra disk.file.streamkhông có trong engine built-in. - Presigned PUT không giới hạn size được — cần presigned POST hoặc kiểm tra sau upload + xoá.
PQueue({ concurrency: 5 })trong handler chỉ giới hạn việc xử lý; 500 request vẫn đang mở socket và nhận bytes. Admission control phải ở LB/proxy hoặc trước khi đọc body.- Dùng
.pipe()cho download S3 → client abort thì S3 socket không bị destroy → leak connection trong pool của SDK (mặc địnhmaxSockets50 — verify). - Không có
AbortIncompleteMultipartUpload→ part rác tính tiền storage vô thời hạn và không hiện trongListObjects. - Serve file user upload từ cùng domain app với
Content-Typedo client gửi → SVG/HTML chạy script = stored XSS. - Zip-on-the-fly: status 200 đã gửi, lỗi giữa chừng → file zip hỏng mà client không biết.
- SSE/NDJSON bị
compression()hoặc nginxproxy_buffering ongom lại → client nhận một cục ở cuối. - Socket.IO server
emitcho client chậm → buffer của socket phình vô hạn → OOM ở giờ mở cửa thị trường.
Cheat sheet
- S3: single PUT ≤ 5 GiB; multipart part 5 MiB–5 GiB, ≤ 10,000 part; object tối đa 5 TiB (AWS có thông báo nâng lên 50 TB cuối 2025 — verify). Presigned URL tối đa 7 ngày (SigV4), nhưng hết hạn sớm hơn nếu ký bằng temporary credentials.
- Part size cho file lớn:
ceil(size / 10000)làm tròn lên ≥ 5 MiB; 100 GB → ≥ ~10.5 MB/part. @aws-sdk/lib-storageUpload: mặc địnhpartSize5 MiB,queueSize4 → ~20 MB buffer mỗi upload (verify). Cóupload.abort()và eventhttpUploadProgress.- Proxy/LB: nginx
client_max_body_sizemặc định 1m,proxy_request_buffering onmặc định; ALB idle timeout mặc định 60s; API Gateway payload 10 MB; Lambda sync invoke 6 MB request/response (verify). - Node streams:
highWaterMarkmặc định 64 KiB cho byte stream từ Node 22 (trước đó 16 KiB), 16 object cho object mode;fs.createReadStream64 KiB (verify). Luônawait pipeline(...)từnode:stream/promises. - Validate:
file-typeđọc magic bytes;sharpre-encode +limitInputPixels; unzip giới hạn tổng bytes, số entry, ratio, và chặn../. - Download: presigned GET TTL ngắn +
ResponseContentDisposition; CloudFront signed cookies cho HLS (nhiều segment), signed URL cho 1 file. - Realtime: sticky session (khi có polling), Redis/Streams adapter, heartbeat,
seqmỗi message, snapshot + delta khi gap lớn, conflate cho client chậm.
References
- Amazon S3 — Uploading and copying objects using multipart upload (limits) ↗
- Amazon S3 — Sharing objects with presigned URLs ↗
- Amazon S3 — Browser-based uploads using POST (POST policy) ↗
- Amazon S3 — Configuring a lifecycle rule to abort incomplete multipart uploads ↗
- Amazon S3 — Checking object integrity (checksums) ↗
- Amazon S3 — Event notifications ↗
- AWS SDK for JavaScript v3 — lib-storage ↗
- AWS SDK for JavaScript v2 — end-of-support announcement ↗
- Amazon CloudFront — Serving private content with signed URLs and signed cookies ↗
- Elastic Load Balancing — ALB connection idle timeout ↗
- API Gateway quotas ↗
- AWS Lambda quotas ↗
- Node.js — Stream API ↗
- Node.js — Backpressuring in streams ↗
- Node.js — Web Streams API ↗
- MDN — HTTP range requests ↗
- MDN — Using server-sent events ↗
- MDN — Content-Disposition ↗
- RFC 9110 — HTTP Semantics (Range, 206, 416) ↗
- WHATWG HTML — Server-sent events ↗
- OWASP — File Upload Cheat Sheet ↗
- nginx — ngx_http_proxy_module (proxy_request_buffering, proxy_buffering) ↗
- Multer README (storage engines, file object) ↗
- busboy ↗
- tus resumable upload protocol ↗
- Socket.IO — Redis adapter / Connection state recovery ↗
- Apple — HTTP Live Streaming ↗
Personal notes (Notion)
Ghi chú cá nhân — có thể sai hoặc lỗi thời, dùng để tham khảo.
Interview Questions
Showing 58 / 58
{
"Rules": [
{ "ID": "abort-mpu", "Status": "Enabled", "Filter": {},
"AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 3 } },
{ "ID": "expire-pending", "Status": "Enabled",
"Filter": { "Tag": { "Key": "status", "Value": "pending" } },
"Expiration": { "Days": 1 } }
]
}
CREATE TABLE upload (
id uuid PRIMARY KEY,
tenant_id uuid NOT NULL,
owner_id uuid NOT NULL,
s3_key text NOT NULL UNIQUE,
declared_size bigint NOT NULL,
declared_type text NOT NULL,
status text NOT NULL CHECK (status IN ('pending','uploaded','scanning','ready','rejected','expired')),
sha256 text,
created_at timestamptz NOT NULL DEFAULT now()
);
UPDATE upload SET status = 'uploaded' WHERE id = $1 AND owner_id = $2 AND status = 'pending';
import AWS from "aws-sdk";
const s3 = new AWS.S3();
export async function getUploadUrl(fileName: string) {
return s3.getSignedUrlPromise("putObject", {
Bucket: "uploads",
Key: `videos/${fileName}`,
ContentType: "video/mp4",
Expires: 3600,
});
}
[
{
"AllowedOrigins": ["https://app.example.com"],
"AllowedMethods": ["PUT", "POST", "GET"],
"AllowedHeaders": ["*"],
"MaxAgeSeconds": 3000
}
]
@Post("upload")
@UseInterceptors(FileInterceptor("file", { limits: { fileSize: 150 * 1024 * 1024 } }))
upload(@UploadedFile() file: Express.Multer.File) {
return s3.upload({ Bucket, Key: `v/${file.originalname}`, Body: file.stream }).promise();
}
import busboy from "busboy";
import { Upload } from "@aws-sdk/lib-storage";
import { PassThrough } from "node:stream";
app.post("/upload", (req, res) => {
const bb = busboy({ headers: req.headers, limits: { files: 1, fileSize: 150 * 1024 * 1024 } });
bb.on("file", (_name, file, info) => {
const body = new PassThrough();
const upload = new Upload({
client: s3,
params: { Bucket, Key: `q/${crypto.randomUUID()}`, Body: body, ContentType: "application/octet-stream" },
partSize: 8 * 1024 * 1024,
queueSize: 2,
});
file.on("limit", () => { upload.abort(); res.status(413).end(); });
req.on("close", () => { if (!res.writableEnded) upload.abort(); });
file.pipe(body);
upload.done().then(() => res.status(201).json({ ok: true })).catch(() => { if (!res.headersSent) res.status(500).end(); });
});
req.pipe(bb);
});
const queue = new PQueue({ concurrency: 5 });
app.post("/upload", upload.single("file"), (req, res) => {
queue.add(() => uploadToS3(req.file!.buffer)).then(() => res.sendStatus(201));
});
import { fileTypeFromBuffer } from "file-type"; // ESM-only
const head = await readFirstBytes(s3Stream, 4100);
const ft = await fileTypeFromBuffer(head);
if (!ft || !["image/png", "image/jpeg"].includes(ft.mime)) throw new RejectedUpload("type");
import path from "node:path";
import fs from "node:fs";
bb.on("file", (_field, file, info) => {
const target = path.join("/data/uploads", req.user.tenantId, info.filename);
file.pipe(fs.createWriteStream(target));
});
app.get("/exports/:id/download", async (req, res) => {
const obj = await s3.send(new GetObjectCommand({ Bucket, Key: keyFor(req.params.id) }));
const bytes = await obj.Body!.transformToByteArray();
res.setHeader("Content-Type", "application/zip");
res.send(Buffer.from(bytes));
});
app.get("/orders/export", (req, res) => {
const stream = db.streamOrders();
res.write("[");
stream.on("data", (row) => {
res.write(JSON.stringify(row));
});
res.end("]");
});
retry: 3000
id: 1842
event: order-updated
data: {"orderId":"o_1","status":"shipped"}
: ping
import { Transform } from "node:stream";
import { createHash } from "node:crypto";
export function inspect(maxBytes: number) {
const hash = createHash("sha256");
let seen = 0; let head = Buffer.alloc(0); let checked = false;
const t = new Transform({
transform(chunk: Buffer, _enc, cb) {
seen += chunk.length;
if (seen > maxBytes) return cb(new Error("too_large"));
if (!checked) {
head = Buffer.concat([head, chunk]).subarray(0, 5);
if (head.length >= 5) {
checked = true;
if (head.toString("latin1") !== "%PDF-") return cb(new Error("not_pdf"));
}
}
hash.update(chunk);
cb(null, chunk);
},
flush(cb) { t.emit("digest", hash.digest("hex"), seen); cb(); },
});
return t;
}
app.get("/folders/:id/zip", async (req, res) => {
const keys = await listKeys(req.params.id); // 8,000 keys
const archive = archiver("zip");
archive.pipe(res);
for (const key of keys) {
const obj = await s3.send(new GetObjectCommand({ Bucket, Key: key }));
archive.append(obj.Body as Readable, { name: key });
}
archive.finalize();
});
const bb = busboy({ headers: req.headers, limits: { fileSize: 50 * 1024 * 1024 } });
bb.on("file", (_n, file) => {
const upload = new Upload({ client: s3, params: { Bucket, Key: key, Body: file } });
upload.done().then(() => markReady(key)).then(() => res.sendStatus(201));
});
req.pipe(bb);
const toDb = new Transform({
objectMode: true,
transform(row, _enc, cb) {
db.insert("items", row); // returns a Promise
cb();
},
});
await pipeline(fs.createReadStream(file), csvParser(), toDb, new Writable({ objectMode: true, write(_c, _e, cb) { cb(); } }));
import { Readable } from "node:stream";
export async function GET(req: Request) {
const obj = await s3.send(new GetObjectCommand({ Bucket, Key }), { abortSignal: req.signal });
return new Response(Readable.toWeb(obj.Body as Readable) as ReadableStream, {
headers: { "Content-Type": obj.ContentType ?? "application/octet-stream", "Content-Length": String(obj.ContentLength) },
});
}
app.get("/media/:id", async (req, res) => {
const file = pathFor(req.params.id);
const { size } = await fs.promises.stat(file);
const [s, e] = (req.headers.range ?? "bytes=0-").replace("bytes=", "").split("-");
const start = Number(s);
const end = e ? Number(e) : size;
res.writeHead(206, {
"Content-Range": `bytes ${start}-${end}/${size}`,
"Content-Length": end - start,
"Content-Type": "video/mp4",
});
fs.createReadStream(file, { start, end }).pipe(res);
});
import { to as copyTo } from "pg-copy-streams";
import { pipeline } from "node:stream/promises";
import { createGzip } from "node:zlib";
if (!/^[0-9a-f-]{36}$/i.test(tenantId)) throw new Error("bad tenant id"); // COPY has no bind params
const client = await replicaPool.connect();
try {
await client.query("SET statement_timeout = '10min'");
const src = client.query(copyTo(
"COPY (SELECT id, booked_at, amount, currency FROM txn WHERE tenant_id = '" + tenantId + "') TO STDOUT WITH (FORMAT csv, HEADER)"
));
res.setHeader("Content-Type", "text/csv");
res.setHeader("Content-Encoding", "gzip");
res.setHeader("Content-Disposition", 'attachment; filename="transactions.csv"');
await pipeline(src, createGzip(), res);
} finally { client.release(); }
// NOTE: COPY does not accept bind parameters — tenantId must be a validated UUID (or use a view/temp function).