@sdxc/http 2026.9.28 → 2026.9.30

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/README.md CHANGED
@@ -189,19 +189,68 @@ and returns a plain
189
189
  import { csv, markdown, xml } from "@sdxc/http/response";
190
190
 
191
191
  xml("<root><item>Hello</item></root>"); // text/xml
192
- csv("name,age\nAda,36"); // text/csv
192
+ csv("name,age\nAda,36"); // text/csv; charset=utf-8
193
193
  markdown("# Hello"); // text/markdown
194
194
  ```
195
195
 
196
196
  `json(body, init?)` serializes any value through `Response.json`. `text`, `html`, `css`,
197
- `javascript`, `xml`, `csv` and `markdown` each take a string and write their own type.
197
+ `javascript`, `xml` and `markdown` each take a string and write their own type.
198
198
  `pdf(body, init?)` writes `application/pdf` and takes a `Blob`, `ArrayBuffer` or
199
199
  `ReadableStream`.
200
200
 
201
+ #### `csv(body: string | ReadableStream<Uint8Array>, init?: ResponseInit): Response`
202
+
203
+ Writes `text/csv; charset=utf-8`, so a client decodes accented and non-Latin cells as UTF-8.
204
+ The body is a whole document or a stream of its UTF-8 bytes, which sends each row as it is
205
+ written instead of holding the file in memory:
206
+
207
+ ```typescript
208
+ import { csv } from "@sdxc/http/response";
209
+
210
+ let encoder = new TextEncoder();
211
+
212
+ return csv(
213
+ new ReadableStream<Uint8Array>({
214
+ async start(controller) {
215
+ controller.enqueue(encoder.encode("name,city\r\n"));
216
+ for await (let row of readRows()) {
217
+ controller.enqueue(encoder.encode(`${row.name},${row.city}\r\n`));
218
+ }
219
+ controller.close();
220
+ },
221
+ }),
222
+ );
223
+ ```
224
+
225
+ #### `attachment(filename: string): string`
226
+
227
+ A [`Content-Disposition`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Disposition)
228
+ value that makes a response a download saved under `filename`. The quoted `filename` always
229
+ holds an ASCII version of the name, with quotes and backslashes escaped and every other
230
+ character outside printable ASCII shown as `_`. A name that needed such a replacement also
231
+ gets an RFC 8187 `filename*`, which browsers prefer, carrying the exact name in UTF-8.
232
+
233
+ ```typescript
234
+ import { attachment } from "@sdxc/http/response";
235
+
236
+ attachment("report.csv");
237
+ // 'attachment; filename="report.csv"'
238
+ attachment("Café report.csv");
239
+ // "attachment; filename=\"Caf_ report.csv\"; filename*=UTF-8''Caf%C3%A9%20report.csv"
240
+ attachment("報告.csv");
241
+ // "attachment; filename=\"__.csv\"; filename*=UTF-8''%E5%A0%B1%E5%91%8A.csv"
242
+ ```
243
+
244
+ Pass it in the `headers` of any builder, so a CSV or a PDF download keeps its own type:
245
+
246
+ ```typescript
247
+ return csv(report, { headers: { "Content-Disposition": attachment("report-2026-08.csv") } });
248
+ ```
249
+
201
250
  #### `file(body: Blob | ArrayBuffer | ReadableStream, filename: string, init?: ResponseInit): Response`
202
251
 
203
- A download: `application/octet-stream` plus
204
- `Content-Disposition: attachment; filename="…"`.
252
+ A download of opaque bytes: `application/octet-stream` plus the `Content-Disposition` that
253
+ `attachment(filename)` writes.
205
254
 
206
255
  #### `stream(body: ReadableStream, init?: ResponseInit): Response`
207
256
 
@@ -73,16 +73,18 @@ export declare function javascript(body: string, init?: ResponseInit): Response;
73
73
  */
74
74
  export declare function xml(body: string, init?: ResponseInit): Response;
75
75
  /**
76
- * Creates a Response with CSV content and appropriate Content-Type header.
77
- * @param body - The CSV string to send
76
+ * Creates a Response with `text/csv; charset=utf-8`, for a whole document or one streamed
77
+ * as it is written. The charset parameter is what makes a client decode non-ASCII cells as
78
+ * UTF-8 instead of its locale's legacy encoding.
79
+ * @param body - The CSV document, or a stream of its UTF-8 bytes
78
80
  * @param init - Optional response options (status, headers, etc.)
79
81
  * @returns A new Response instance with CSV content
80
82
  * @example
81
83
  * return csv("name,age\nJohn,30\nJane,25");
82
84
  * @example
83
- * return csv("id,value\n1,100", { headers: { "Content-Disposition": "attachment; filename=data.csv" } });
85
+ * return csv(rows, { headers: { "Content-Disposition": attachment("report.csv") } });
84
86
  */
85
- export declare function csv(body: string, init?: ResponseInit): Response;
87
+ export declare function csv(body: string | ReadableStream<Uint8Array>, init?: ResponseInit): Response;
86
88
  /**
87
89
  * Creates a Response with Markdown content and appropriate Content-Type header.
88
90
  * @param body - The Markdown string to send
@@ -107,8 +109,20 @@ export declare function markdown(body: string, init?: ResponseInit): Response;
107
109
  */
108
110
  export declare function pdf(body: Blob | ArrayBuffer | ReadableStream, init?: ResponseInit): Response;
109
111
  /**
110
- * Creates a Response for file downloads with Content-Disposition header.
111
- * Sets appropriate headers to trigger browser download behavior.
112
+ * An RFC 6266 `Content-Disposition: attachment` value for a download's file name. Every
113
+ * name gets a quoted ASCII `filename`, with characters outside printable ASCII shown as `_`;
114
+ * such a name also gets an RFC 8187 `filename*`, which clients prefer, carrying it exactly.
115
+ * @param filename - The name the download is saved as
116
+ * @returns The header value
117
+ * @example
118
+ * attachment("report.csv"); // 'attachment; filename="report.csv"'
119
+ * @example
120
+ * attachment("Café.csv"); // "attachment; filename=\"Caf_.csv\"; filename*=UTF-8''Caf%C3%A9.csv"
121
+ */
122
+ export declare function attachment(filename: string): string;
123
+ /**
124
+ * Creates a Response for a file download, with the name written by `attachment` so a quote
125
+ * or a non-ASCII character in it reaches the client intact.
112
126
  * @param body - The file content as Blob, ArrayBuffer, or ReadableStream
113
127
  * @param filename - The filename to suggest for the download
114
128
  * @param init - Optional response options (status, headers, etc.)
package/dist/response.js CHANGED
@@ -96,18 +96,20 @@ export function xml(body, init) {
96
96
  return new Response(body, { ...init, headers });
97
97
  }
98
98
  /**
99
- * Creates a Response with CSV content and appropriate Content-Type header.
100
- * @param body - The CSV string to send
99
+ * Creates a Response with `text/csv; charset=utf-8`, for a whole document or one streamed
100
+ * as it is written. The charset parameter is what makes a client decode non-ASCII cells as
101
+ * UTF-8 instead of its locale's legacy encoding.
102
+ * @param body - The CSV document, or a stream of its UTF-8 bytes
101
103
  * @param init - Optional response options (status, headers, etc.)
102
104
  * @returns A new Response instance with CSV content
103
105
  * @example
104
106
  * return csv("name,age\nJohn,30\nJane,25");
105
107
  * @example
106
- * return csv("id,value\n1,100", { headers: { "Content-Disposition": "attachment; filename=data.csv" } });
108
+ * return csv(rows, { headers: { "Content-Disposition": attachment("report.csv") } });
107
109
  */
108
110
  export function csv(body, init) {
109
111
  let headers = new Headers(init?.headers);
110
- headers.set("Content-Type", ContentType.CSV);
112
+ headers.set("Content-Type", `${ContentType.CSV}; charset=utf-8`);
111
113
  return new Response(body, { ...init, headers });
112
114
  }
113
115
  /**
@@ -142,8 +144,49 @@ export function pdf(body, init) {
142
144
  return new Response(body, { ...init, headers });
143
145
  }
144
146
  /**
145
- * Creates a Response for file downloads with Content-Disposition header.
146
- * Sets appropriate headers to trigger browser download behavior.
147
+ * Characters outside printable ASCII, which a quoted `filename` cannot carry. Matched per
148
+ * code point, so an astral character becomes one fallback character.
149
+ */
150
+ const NON_PRINTABLE_ASCII = /[^\x20-\x7E]/gu;
151
+ /** The two characters a quoted-string must escape with a backslash. */
152
+ const QUOTED_STRING_SPECIALS = /["\\]/g;
153
+ /** RFC 8187 `attr-char`: the bytes an `ext-value` may carry without percent-encoding. */
154
+ const ATTR_CHAR = /^[A-Za-z0-9!#$&+\-.^_`|~]$/;
155
+ /**
156
+ * An RFC 8187 `ext-value` for a name, percent-encoding every UTF-8 byte outside
157
+ * `attr-char`. A lone surrogate encodes as U+FFFD, so every string yields a valid value.
158
+ * @param value - The name to encode
159
+ * @returns The value with its `UTF-8''` prefix
160
+ */
161
+ function extValue(value) {
162
+ let encoded = "UTF-8''";
163
+ for (let byte of new TextEncoder().encode(value)) {
164
+ let char = String.fromCharCode(byte);
165
+ encoded += ATTR_CHAR.test(char) ? char : `%${byte.toString(16).toUpperCase().padStart(2, "0")}`;
166
+ }
167
+ return encoded;
168
+ }
169
+ /**
170
+ * An RFC 6266 `Content-Disposition: attachment` value for a download's file name. Every
171
+ * name gets a quoted ASCII `filename`, with characters outside printable ASCII shown as `_`;
172
+ * such a name also gets an RFC 8187 `filename*`, which clients prefer, carrying it exactly.
173
+ * @param filename - The name the download is saved as
174
+ * @returns The header value
175
+ * @example
176
+ * attachment("report.csv"); // 'attachment; filename="report.csv"'
177
+ * @example
178
+ * attachment("Café.csv"); // "attachment; filename=\"Caf_.csv\"; filename*=UTF-8''Caf%C3%A9.csv"
179
+ */
180
+ export function attachment(filename) {
181
+ let fallback = filename.replaceAll(NON_PRINTABLE_ASCII, "_");
182
+ let value = `attachment; filename="${fallback.replaceAll(QUOTED_STRING_SPECIALS, "\\$&")}"`;
183
+ if (fallback !== filename)
184
+ value += `; filename*=${extValue(filename)}`;
185
+ return value;
186
+ }
187
+ /**
188
+ * Creates a Response for a file download, with the name written by `attachment` so a quote
189
+ * or a non-ASCII character in it reaches the client intact.
147
190
  * @param body - The file content as Blob, ArrayBuffer, or ReadableStream
148
191
  * @param filename - The filename to suggest for the download
149
192
  * @param init - Optional response options (status, headers, etc.)
@@ -156,7 +199,7 @@ export function pdf(body, init) {
156
199
  export function file(body, filename, init) {
157
200
  let headers = new Headers(init?.headers);
158
201
  headers.set("Content-Type", ContentType.OctetStream);
159
- headers.set("Content-Disposition", `attachment; filename="${filename}"`);
202
+ headers.set("Content-Disposition", attachment(filename));
160
203
  return new Response(body, { ...init, headers });
161
204
  }
162
205
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdxc/http",
3
- "version": "2026.9.28",
3
+ "version": "2026.9.30",
4
4
  "description": "Response builders, content negotiation, and HTTP caching for the Fetch API",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,9 +19,9 @@
19
19
  "@sdxc/duration": "2026.9.15",
20
20
  "@sdxc/location": "2026.9.11",
21
21
  "@sdxc/result": "2026.9.15",
22
- "remix": "3.0.0-rc.2"
22
+ "remix": "3.0.0-rc.4"
23
23
  },
24
- "gitHead": "00c01e349b11f65b9f47902c5345a817e8afd2b1",
24
+ "gitHead": "81432d2333817dfd7ee61634baff813432ecee9c",
25
25
  "publishConfig": {
26
26
  "access": "public"
27
27
  },