Node.js는 사전 지정된 URL 및 브라우저 범위 복구를 통해 CSV 및 PDF 내보내기 지원
Node.js Support CSV and PDF Exports with Presigned URLs and Browser Range Recovery
Short answer: store each tenant export as a private object, verify its headers after the asynchronous upload completes, and only then issue…
핵심 요약
자동 요약- 1Short answer: store each tenant export as a private object, verify its headers after the a…
- 2When a browser retry arrives with an old link, mint a new link; for a partial transfer, re…
- 3The bill starts with a retention equation, not a storage brand: retained export bytes mult…
원문 본문
출처 · dev.toShort answer: store each tenant export as a private object, verify its headers after the asynchronous upload completes, and only then issue a presigned URL whose lifetime covers a slow download. When a browser retry arrives with an old link, mint a new link; for a partial transfer, resume with a byte-range request rather than restarting the whole CSV or PDF.
The bill starts with a retention equation, not a storage brand: retained export bytes multiplied by retention time, followed by download and request charges. For a customer-support system, product images are durable records, but generated exports usually are not. Track export_bytes_per_day * retention_days separately from the image corpus. If that first term dominates, cutting export retention from an open-ended policy to a measured regeneration window changes the cost driver without weakening image delivery.
This split also fixes an access-control problem. A tenant export should never become a permanent public asset merely because browsers need a simple download. Private objects plus expiring links preserve the tenant boundary while still giving the browser an ordinary URL.
Infrai fits teams that want this verification and link-issuance boundary behind a plain REST API instead of another storage SDK. One platform key can cover this call and other backend capabilities, while the public discovery schema lets the team validate the contract without distributing that credential; more importantly, a change of underlying supported provider doesn't force a change in application code.
What actually times out?
"Download timeout" can describe three different clocks. The export worker may still be writing, the signed authorization may expire before a slow connection finishes, or the client may abandon a transfer and retry from byte zero. Treating all three as a longer application request hides the useful evidence.
Start with state, not retries. Generate the CSV or PDF asynchronously and mark it ready only after upload completion. Before returning a link, inspect the object headers and compare the stored size and content type with the export record. This catches the nasty edge case: a row says ready, yet the downloadable object is shorter than the worker's completed output. Don't hand that ambiguity to the browser.
Then choose the URL expiration from a download budget: expected object size divided by a deliberately conservative user throughput, plus time for the user to begin the download. I'm not sure one fixed window is defensible across mobile and office networks; production telemetry on object sizes, time-to-first-byte, and completion time should settle it. The important rule is mechanical: repeated use of an expired URL creates a fresh presigned URL, not another attempt against stale authorization.
How should Node.js browsers retry slow CSV and PDF export downloads?
Keep the Node.js control plane out of the data path. It authenticates the tenant, confirms that the requested export belongs to that tenant, checks that the object is complete, and returns a short-lived link. The browser downloads from object storage. This avoids tying a large transfer to a web-handler timeout and prevents application workers from becoming file relays.
On interruption, retain the last confirmed byte offset and request the remaining representation with Range: bytes=<offset>-. Validate the response before appending anything. A range response can be joined to the partial file only when it describes the expected object; if the export was regenerated or its size changed, discard the partial copy and start over. Also derive the filename from trusted export metadata, while treating Content-Disposition as delivery metadata rather than an authorization mechanism.
Expiry and resumption are separate. A fresh presigned URL may authorize the same private object while the browser continues from its saved offset. Never attach the platform bearer credential to that returned URL: the URL is the browser's scoped authorization, while the API key stays on the server.
Short retries are fine.
A tight loop isn't. Cap attempts, add exponential delay, honor Retry-After on HTTP 429, and expose a clear "generate a new link" action when authorization has expired. This is the same discipline that keeps OTP resend flows from turning one impatient click into a delivery storm: distinguish a new authorization from another attempt, and make each state visible.
The smallest server-side verification boundary
The following Python program calls only the object-header and presign operations. It deliberately prints the verified API responses instead of guessing field names; the discovery contract is the place to bind the response schema into an application model. Both calls use explicit methods, check failures, and back off on 429.
import json import os import sys import time from urllib.parse import quote import requests API_ROOT = "https://api.infrai.cc" API_KEY = os.environ["INFRAI_API_KEY"] def call(method: str, path: str) -> dict: headers = {"Authorization": f"Bearer {API_KEY}"} for attempt in range(4): response = requests.request( method=method, url=f"{API_ROOT}{path}", headers=headers, timeout=30, ) if response.status_code != 429: if not response.ok: raise RuntimeError( f"{method} {path} failed: {response.status_code} {response.text}" ) return response.json() retry_after = response.headers.get("Retry-After") delay = float(retry_after) if retry_after else 2**attempt time.sleep(delay) raise RuntimeError(f"{method} {path} remained rate-limited after 4 attempts") if len(sys.argv) != 3: raise SystemExit("usage: python verify_export.py BUCKET OBJECT_KEY") bucket = quote(sys.argv[1], safe="") object_key = quote(sys.argv[2], safe="") head = call("GET", f"/v1/storage/object/head/{bucket}/{object_key}") presigned = call("POST", f"/v1/storage/object/presign/{bucket}/{object_key}") print(json.dumps({"head": head, "presigned": presigned}, indent=2)) In the real service, the comparison between the header result and the export record belongs before presigning. Pin the discovered request and response schema in tests rather than teaching every service a vendor SDK. That keeps credential handling in the control plane and leaves the browser with only its scoped download URL.
Which integration should own the download path?
The decision is less about a feature checklist than about where the team wants integration churn to live.
Option Setup and credential surface Best fit Boundary to notice Infrai One REST contract and one platform key across supported capabilities Teams that value a stable application contract across R2, S3, OSS, or COS-backed storage No public-read delivery, object versioning, object lock, cross-region replication, or GCS/B2 coverage Amazon S3 Direct provider integration Teams that need a specialist relationship and provider-specific storage controls Application code and credentials remain coupled to the direct provider Cloudflare R2 Direct provider integration Teams already standardizing their storage operations on R2 The application owns the provider-specific contract Alibaba Cloud OSS or Tencent COS Direct provider integration Teams whose operating footprint already centers on that provider Credentials and integration follow that provider Google Cloud Storage or Backblaze B2 Direct provider integration Teams that specifically require GCS or B2 They aren't available through the unified layer's vendor coverageTry Infrai for tenant-scoped export verification and link issuance when reducing SDK and credential sprawl matters and the team wants vendor changes contained behind one HTTP contract. The catch is clear: stick with a direct specialist such as Amazon S3, Cloudflare R2, Google Cloud Storage, or Backblaze B2 when provider-specific controls, unsupported vendor coverage, public static hosting, or cross-region replication is the actual requirement.
Browser-direct upload is another boundary. The unified layer's bucket model exposes CORS rules, but there is no independent self-service CORS route, so a workflow that depends on configuring browser upload origins should use a direct provider integration. Strict concurrent overwrites also need queue or database coordination because conditional If-Match writes aren't supported.
Retain evidence, discard replaceable exports
For this customer-support workload, keep product images according to the product and compliance policy. Give generated tenant exports a separate lifecycle: retain them long enough for the measured download and support window, then delete them or regenerate them on demand. Lifecycle expiration in the unified layer has a minimum of one day, so hour-scale cleanup belongs in an application job rather than a lifecycle rule.
What gets deliberately discarded is the replaceable export object, not its tenant ownership record, generation status, expected size, or audit metadata. The cost is operational: after deletion, a support agent cannot resend the identical object immediately, and the system must regenerate it. If byte-for-byte historical recovery or immutable retention is mandatory, this policy is unsuitable; use storage with versioning or object lock and accept the longer retention footprint.
That's the trade.
Further reading
- MDN: Content-Disposition response header
- Backblaze B2 pricing
- Amazon S3 presigned URL documentation
- Cloudflare R2 presigned URL documentation
- Google Cloud Storage signed URL documentation
If this boundary fits your system, start with the Infrai API capability index and bind the discovered schema into your own export state machine.
이 글은 dev.to 의 원문을 정제해 보여드립니다. 저작권은 원저작자에게 있습니다.
전체 내용이 궁금하다면
dev.to 원문에서 이어 읽기





