-
Notifications
You must be signed in to change notification settings - Fork 54
feat(download): serve HTTP Range on the proxy route #270
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
f7f9e9e
75fd09d
9e99d2b
b71e6f5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,12 +1,28 @@ | ||
| import { Readable } from 'node:stream' | ||
| import type { RangeRequest } from '~/lib/storage' | ||
| import { pipeline } from 'node:stream/promises' | ||
| import { z } from 'zod' | ||
| import { logger } from '~/lib/logger' | ||
| import { getStorage } from '~/lib/storage' | ||
| import { getStorage, RangeNotSatisfiableError } from '~/lib/storage' | ||
|
|
||
| const pathParamsSchema = z.object({ | ||
| cacheEntryId: z.string(), | ||
| }) | ||
|
|
||
| // Single ranges only; multi-range and suffix (`bytes=-500`) fall through to a 200. | ||
| const RANGE_RE = /^bytes=(\d+)-(\d*)$/i | ||
|
|
||
| function parseRange(header: string | undefined): RangeRequest | undefined { | ||
| if (!header) return | ||
| const m = RANGE_RE.exec(header.trim()) | ||
| if (!m) return | ||
| const start = Number(m[1]) | ||
| if (!Number.isSafeInteger(start)) return | ||
| if (m[2] === '') return { start } | ||
| const end = Number(m[2]) | ||
| if (!Number.isSafeInteger(end) || end < start) return | ||
| return { start, end } | ||
| } | ||
|
|
||
| export default defineEventHandler(async (event) => { | ||
| const parsedPathParams = pathParamsSchema.safeParse(event.context.params) | ||
| if (!parsedPathParams.success) | ||
|
|
@@ -16,28 +32,64 @@ export default defineEventHandler(async (event) => { | |
| }) | ||
|
|
||
| const { cacheEntryId } = parsedPathParams.data | ||
|
|
||
| const storage = await getStorage() | ||
| const stream = await storage.download(cacheEntryId) | ||
| if (!stream) | ||
|
|
||
| const range = parseRange(getHeader(event, 'range')) | ||
|
|
||
| // Unmerged entries ignore Range, so clients must key off the status, not this header. | ||
| setHeader(event, 'accept-ranges', 'bytes') | ||
|
|
||
| let download | ||
| try { | ||
| download = await storage.download(cacheEntryId, range) | ||
| } catch (err) { | ||
| if (err instanceof RangeNotSatisfiableError) { | ||
| // An empty object has no satisfiable range, but S3 answers `bytes=0-` with an | ||
| // empty 200 while the other adapters raise. Normalise to the 200 so the | ||
| // response does not depend on which backend is configured. | ||
| if (err.size === 0) { | ||
| setHeader(event, 'content-length', 0) | ||
| return send(event) | ||
| } | ||
| setResponseStatus(event, 416, 'Range Not Satisfiable') | ||
| if (err.size !== undefined) setHeader(event, 'content-range', `bytes */${err.size}`) | ||
| return send(event) | ||
| } | ||
| throw err | ||
| } | ||
| if (!download) | ||
| throw createError({ | ||
| statusCode: 404, | ||
| message: 'Cache file not found', | ||
| }) | ||
|
|
||
| if (range && download.range && download.size !== undefined) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think there's a race here. When someone saves the same key and version again, the entry keeps its id and Could we send an
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch — this is real, and I confirmed the mechanism: I started on
I don't want to claim this closes the race when it doesn't close the parallel case, so I've asked in the top-level comment which direction you'd prefer before I write it. |
||
| setResponseStatus(event, 206) | ||
| setHeader( | ||
| event, | ||
| 'content-range', | ||
| `bytes ${download.range.start}-${download.range.end}/${download.size}`, | ||
| ) | ||
| setHeader(event, 'content-length', download.range.end - download.range.start + 1) | ||
| } else if (download.size !== undefined) { | ||
| setHeader(event, 'content-length', download.size) | ||
| } | ||
|
|
||
| // Take over the response from h3: `sendStream` applies no backpressure and does not | ||
| // notice client aborts, whereas `pipeline` destroys the source and releases its lease. | ||
| // `_handled` is an h3 v1 internal and will need replacing when h3 v2 lands. | ||
| event._handled = true | ||
|
peter-svensson marked this conversation as resolved.
|
||
| try { | ||
| await sendStream(event, Readable.toWeb(stream) as ReadableStream) | ||
| await pipeline(download.stream, event.node.res) | ||
| } catch (err) { | ||
| // Once the response has started flushing, we can't surface stream errors | ||
| // as an HTTP error — Nitro's default error handler would call | ||
| // `setResponseHeaders` after headers were already sent and crash with | ||
| // ERR_HTTP_HEADERS_SENT (logged as an unhandled error). Client aborts on | ||
| // long downloads are expected (cancelled jobs, parallel runners), so we | ||
| // log and swallow once headers are out. | ||
| // Client went away mid-body. Expected on cancelled jobs and parallel runners. | ||
| if ((err as NodeJS.ErrnoException).code === 'ERR_STREAM_PREMATURE_CLOSE') { | ||
| logger.debug(`Client aborted /download/${cacheEntryId}: ${(err as Error).message}`) | ||
| return | ||
| } | ||
| // Headers are out, so Nitro's error handler would crash with ERR_HTTP_HEADERS_SENT. | ||
| if (event.node.res.headersSent) { | ||
| if (event.node.req.destroyed) | ||
| logger.debug(`Client aborted /download/${cacheEntryId}: ${(err as Error).message}`) | ||
| else logger.error(`Download stream failed for ${cacheEntryId}`, { error: err }) | ||
| logger.error(`Download stream failed for ${cacheEntryId}`, { error: err }) | ||
| return | ||
| } | ||
| throw err | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.