-
-
Notifications
You must be signed in to change notification settings - Fork 7.7k
[typescript-fetch] name downloaded files from Content-Disposition, BlobApiResponse returns a File #24957
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: master
Are you sure you want to change the base?
[typescript-fetch] name downloaded files from Content-Disposition, BlobApiResponse returns a File #24957
Changes from all commits
953a6b6
7537797
04d88e9
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 | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -535,11 +535,89 @@ export class VoidApiResponse { | |||||||||||
| export class BlobApiResponse { | ||||||||||||
| constructor(public raw: Response) {} | ||||||||||||
|
|
||||||||||||
| async value(): Promise<Blob> { | ||||||||||||
| return await this.raw.blob(); | ||||||||||||
| /** | ||||||||||||
| * The body as a File named after the Content-Disposition header, so that a download keeps the | ||||||||||||
| * name the server gave it. A File is a Blob: callers reading a Blob are unaffected, and the name | ||||||||||||
| * is empty when the server did not send one. Runtimes without a global File (Node.js before 20) | ||||||||||||
| * keep receiving the bare Blob. | ||||||||||||
| */ | ||||||||||||
| async value(): Promise<File> { | ||||||||||||
| const blob = await this.raw.blob(); | ||||||||||||
| if (typeof File === 'undefined') { | ||||||||||||
| return blob as File; | ||||||||||||
| } | ||||||||||||
| return new File([blob], parseContentDispositionFilename(this.raw.headers) ?? '', { type: blob.type }); | ||||||||||||
| }; | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| /** | ||||||||||||
| * The file name advertised by a Content-Disposition header (RFC 6266): the RFC 5987 encoded | ||||||||||||
| * `filename*` parameter first, then the plain `filename` as a quoted-string or a token. Parameters | ||||||||||||
| * are split on `;` outside quoted-strings and matched by name case-insensitively. Any directory | ||||||||||||
| * part is dropped so that the name is safe to write to disk as is; undefined when no usable name | ||||||||||||
| * is advertised. | ||||||||||||
| */ | ||||||||||||
| export function parseContentDispositionFilename(headers: Headers): string | undefined { | ||||||||||||
| const value = headers.get('Content-Disposition'); | ||||||||||||
| if (!value) { | ||||||||||||
| return undefined; | ||||||||||||
| } | ||||||||||||
| const params = parseHeaderParameters(value); | ||||||||||||
| const encoded = params.get('filename*'); | ||||||||||||
| if (encoded !== undefined) { | ||||||||||||
| const extValue = /^utf-8'[^']*'(.*)$/i.exec(encoded); | ||||||||||||
| if (extValue) { | ||||||||||||
| try { | ||||||||||||
| return basename(decodeURIComponent(extValue[1])); | ||||||||||||
|
Contributor
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. P2: When Prompt for AI agents
Suggested change
|
||||||||||||
| } catch { | ||||||||||||
| // malformed percent-encoding: fall through to the plain form | ||||||||||||
| } | ||||||||||||
| } | ||||||||||||
| } | ||||||||||||
| const plain = params.get('filename'); | ||||||||||||
| return plain === undefined ? undefined : basename(plain); | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| /** | ||||||||||||
| * The `name=value` parameters of a header value, split on `;` outside quoted-strings. Names are | ||||||||||||
| * lower-cased, quoted-string values are unquoted with their backslash escapes resolved, and the | ||||||||||||
| * first occurrence of a name wins. | ||||||||||||
| */ | ||||||||||||
| function parseHeaderParameters(value: string): Map<string, string> { | ||||||||||||
| const params = new Map<string, string>(); | ||||||||||||
| let start = 0; | ||||||||||||
| let quoted = false; | ||||||||||||
| for (let i = 0; i <= value.length; i++) { | ||||||||||||
| const c = value[i]; | ||||||||||||
| if (i === value.length || (c === ';' && !quoted)) { | ||||||||||||
| const part = value.slice(start, i); | ||||||||||||
| const eq = part.indexOf('='); | ||||||||||||
| if (eq !== -1) { | ||||||||||||
| const name = part.slice(0, eq).trim().toLowerCase(); | ||||||||||||
| let raw = part.slice(eq + 1).trim(); | ||||||||||||
| if (raw.startsWith('"')) { | ||||||||||||
| raw = raw.slice(1, raw.length > 1 && raw.endsWith('"') ? -1 : undefined).replace(/\\(.)/g, '$1'); | ||||||||||||
| } | ||||||||||||
| if (name && !params.has(name)) { | ||||||||||||
| params.set(name, raw); | ||||||||||||
| } | ||||||||||||
| } | ||||||||||||
| start = i + 1; | ||||||||||||
| } else if (c === '"') { | ||||||||||||
| quoted = !quoted; | ||||||||||||
| } else if (c === '\\' && quoted) { | ||||||||||||
| i++; | ||||||||||||
| } | ||||||||||||
| } | ||||||||||||
| return params; | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| /** The last path segment of a file name, or undefined when nothing usable is left. */ | ||||||||||||
| function basename(name: string): string | undefined { | ||||||||||||
| const base = name.slice(Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')) + 1).trim(); | ||||||||||||
| return base === '' || base === '.' || base === '..' ? undefined : base; | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| export class TextApiResponse { | ||||||||||||
| constructor(public raw: Response) {} | ||||||||||||
|
|
||||||||||||
|
|
||||||||||||
Uh oh!
There was an error while loading. Please reload this page.