Skip to content

Commit be5c380

Browse files
committed
refactor response factories into own files; fix coverage
1 parent ff50b86 commit be5c380

File tree

13 files changed

+273
-263
lines changed

13 files changed

+273
-263
lines changed

.github/workflows/workflow.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ jobs:
66

77
strategy:
88
matrix:
9-
bun-version: [1.1.0, latest]
9+
bun-version: [1.1.20, latest]
1010

1111
steps:
1212
- name: ➡️ Checkout repository

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
dist
2+
coverage
23

34
# macOS
45
.DS_Store

bunfig.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,7 @@ telemetry = false
33
[install]
44
exact = true
55
auto = "auto"
6+
7+
[test]
8+
coverageReporter = ["text", "lcov"]
9+
coverageDir = "coverage"

index.ts

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,6 @@ export {
1010
type NextFunction,
1111
type SingleHandler,
1212
} from './src/HttpRouter/HttpRouter';
13-
export {
14-
buildFileResponse,
15-
factory,
16-
file,
17-
json,
18-
redirect,
19-
sse,
20-
type Factory,
21-
type FileResponseOptions,
22-
type SseClose,
23-
type SseSend,
24-
type SseSetupFunction,
25-
} from './src/HttpRouter/responseFactories';
2613
export {
2714
applyHandlerIf,
2815
type ApplyHandlerIfArgs,
@@ -57,6 +44,21 @@ export {
5744
type ServeFilesOptions,
5845
} from './src/middleware/serveFiles/serveFiles';
5946
export { trailingSlashes } from './src/middleware/trailingSlashes/trailingSlashes';
47+
export { default as ms } from './src/ms/ms';
48+
export { default as buildFileResponse } from './src/responseFactories/buildFileResponse';
49+
export { default as factory } from './src/responseFactories/factory';
50+
export {
51+
default as file,
52+
type FileResponseOptions,
53+
} from './src/responseFactories/file';
54+
export { default as json } from './src/responseFactories/json';
55+
export { default as redirect } from './src/responseFactories/redirect';
56+
export {
57+
default as sse,
58+
type SseClose,
59+
type SseSend,
60+
type SseSetupFunction,
61+
} from './src/responseFactories/sse';
6062
export {
6163
default as SocketRouter,
6264
type BunHandlers,

src/Context/Context.ts

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,10 @@
11
import type { BunFile, Server } from 'bun';
22
import type HttpRouter from '../HttpRouter/HttpRouter';
3-
import {
4-
factory,
5-
file,
6-
json,
7-
redirect,
8-
sse,
9-
type FileResponseOptions,
10-
type SseSetupFunction,
11-
} from '../HttpRouter/responseFactories';
3+
import factory from '../responseFactories/factory';
4+
import file, { type FileResponseOptions } from '../responseFactories/file';
5+
import json from '../responseFactories/json';
6+
import redirect from '../responseFactories/redirect';
7+
import sse, { type SseSetupFunction } from '../responseFactories/sse';
128

139
const textPlain = factory('text/plain');
1410
const textJs = factory('text/javascript');
@@ -81,7 +77,7 @@ export default class Context<
8177
redirect = (url: string, status = 302) => {
8278
return redirect(url, status);
8379
};
84-
/** A shorthand for `new Response(bunFile, fileHeaders)` */
80+
/** A shorthand for `new Response(bunFile, fileHeaders)` plus range features */
8581
file = async (
8682
filenameOrBunFile: string | BunFile,
8783
fileOptions: FileResponseOptions = {}
Lines changed: 0 additions & 238 deletions
Original file line numberDiff line numberDiff line change
@@ -1,238 +0,0 @@
1-
import { BunFile } from 'bun';
2-
import path from 'node:path';
3-
import Context from '../Context/Context';
4-
import getMimeType from '../getMimeType/getMimeType';
5-
6-
export type Factory = (body: string, init?: ResponseInit) => Response;
7-
8-
const textEncoder = new TextEncoder();
9-
10-
export function json(this: Context, data: any, init: ResponseInit = {}) {
11-
let body: string | Uint8Array = JSON.stringify(data);
12-
init.headers = new Headers(init.headers || {});
13-
if (!init.headers.has('Content-Type')) {
14-
init.headers.set('Content-Type', `application/json; charset=utf-8`);
15-
}
16-
return new Response(body, init);
17-
}
18-
19-
export function factory(contentType: string): Factory {
20-
return function (this: Context, body: string, init: ResponseInit = {}) {
21-
init.headers = new Headers(init.headers || {});
22-
if (!init.headers.has('Content-Type')) {
23-
init.headers.set('Content-Type', `${contentType}; charset=utf-8`);
24-
}
25-
init.headers.set('Content-Length', String(body.length));
26-
return new Response(body, init);
27-
};
28-
}
29-
30-
export const redirect = (url: string, status = 302) => {
31-
return new Response('', {
32-
status,
33-
headers: {
34-
Location: url,
35-
},
36-
});
37-
};
38-
39-
export type FileResponseOptions = {
40-
range?: string;
41-
chunkSize?: number;
42-
disposition?: 'inline' | 'attachment';
43-
acceptRanges?: boolean;
44-
};
45-
export const file = async (
46-
filenameOrBunFile: string | BunFile,
47-
fileOptions: FileResponseOptions = {}
48-
) => {
49-
let file =
50-
typeof filenameOrBunFile === 'string'
51-
? Bun.file(filenameOrBunFile)
52-
: filenameOrBunFile;
53-
if (!(await file.exists())) {
54-
return new Response('File not found', { status: 404 });
55-
}
56-
const resp = await buildFileResponse({
57-
file,
58-
acceptRanges: true,
59-
chunkSize: fileOptions.chunkSize,
60-
rangeHeader: fileOptions.range,
61-
method: 'GET',
62-
});
63-
if (fileOptions.acceptRanges !== false) {
64-
// tell the client that we are capable of handling range requests
65-
resp.headers.set('Accept-Ranges', 'bytes');
66-
}
67-
if (fileOptions.disposition === 'attachment') {
68-
const filename = path.basename(file.name!);
69-
resp.headers.set(
70-
'Content-Disposition',
71-
`${fileOptions.disposition}; filename="${filename}"`
72-
);
73-
} else if (fileOptions.disposition === 'inline') {
74-
resp.headers.set('Content-Disposition', 'inline');
75-
}
76-
return resp;
77-
};
78-
79-
export type SseSend = (
80-
eventName: string,
81-
data?: string | object,
82-
id?: string,
83-
retry?: number
84-
) => void | Promise<void>;
85-
export type SseClose = () => void | Promise<void>;
86-
export type SseSetupFunction = (
87-
send: SseSend,
88-
close: SseClose
89-
) => void | (() => void);
90-
91-
export const sse = (
92-
signal: AbortSignal,
93-
setup: SseSetupFunction,
94-
init: ResponseInit = {}
95-
) => {
96-
const stream = new ReadableStream({
97-
async start(controller: ReadableStreamDefaultController) {
98-
function send(
99-
eventName: string,
100-
data?: string | object,
101-
id?: string,
102-
retry?: number
103-
) {
104-
let encoded: Uint8Array;
105-
if (arguments.length === 1) {
106-
encoded = textEncoder.encode(`data: ${eventName}\n\n`);
107-
} else {
108-
if (data && typeof data !== 'string') {
109-
data = JSON.stringify(data);
110-
}
111-
let message = `event: ${eventName}\ndata:${String(data)}`;
112-
if (id) {
113-
message += `\nid: ${id}`;
114-
}
115-
if (retry) {
116-
message += `\nretry: ${retry}`;
117-
}
118-
message += '\n\n';
119-
encoded = textEncoder.encode(message);
120-
}
121-
if (signal.aborted) {
122-
// client disconnected already
123-
close();
124-
} else {
125-
controller.enqueue(encoded);
126-
}
127-
}
128-
function close() {
129-
if (closed) {
130-
return;
131-
}
132-
closed = true;
133-
cleanup?.();
134-
signal.removeEventListener('abort', close);
135-
controller.close();
136-
}
137-
138-
// setup and listen for abort signal
139-
const cleanup = setup(send, close);
140-
let closed = false;
141-
signal.addEventListener('abort', close);
142-
// close now if somehow it is already aborted
143-
if (signal.aborted) {
144-
/* c8 ignore next */
145-
close();
146-
}
147-
},
148-
});
149-
150-
let headers = new Headers(init.headers);
151-
if (
152-
headers.has('Content-Type') &&
153-
!/^text\/event-stream/.test(headers.get('Content-Type')!)
154-
) {
155-
console.warn(
156-
'Overriding Content-Type header to `text/event-stream; charset=utf-8`'
157-
);
158-
}
159-
if (
160-
headers.has('Cache-Control') &&
161-
headers.get('Cache-Control') !== 'no-cache'
162-
) {
163-
console.warn('Overriding Cache-Control header to `no-cache`');
164-
}
165-
if (headers.has('Connection') && headers.get('Connection') !== 'keep-alive') {
166-
console.warn('Overriding Connection header to `keep-alive`');
167-
}
168-
headers.set('Content-Type', 'text/event-stream; charset=utf-8');
169-
headers.set('Cache-Control', 'no-cache');
170-
headers.set('Connection', 'keep-alive');
171-
// @ts-ignore
172-
return new Response(stream, { ...init, headers });
173-
};
174-
175-
export async function buildFileResponse({
176-
file,
177-
acceptRanges,
178-
chunkSize,
179-
rangeHeader,
180-
method,
181-
}: {
182-
file: BunFile;
183-
acceptRanges: boolean;
184-
chunkSize?: number;
185-
rangeHeader?: string | null;
186-
method: string;
187-
}) {
188-
let response: Response;
189-
const rangeMatch = String(rangeHeader).match(/^bytes=(\d*)-(\d*)$/);
190-
if (acceptRanges && rangeMatch) {
191-
const totalFileSize = file.size;
192-
const start = parseInt(rangeMatch[1]) || 0;
193-
let end = parseInt(rangeMatch[2]);
194-
if (isNaN(end)) {
195-
// Initial request: some browsers use "Range: bytes=0-"
196-
end = Math.min(start + (chunkSize || 3 * 1024 ** 2), totalFileSize - 1);
197-
}
198-
if (start > totalFileSize - 1) {
199-
return new Response('416 Range not satisfiable', { status: 416 });
200-
}
201-
// Bun has a bug when setting content-length and content-range automatically
202-
// so convert file to buffer
203-
let buffer = await file.arrayBuffer();
204-
let status = 200;
205-
// the range is less than the entire file
206-
if (end - 1 < totalFileSize) {
207-
buffer = buffer.slice(start, end + 1);
208-
status = 206;
209-
}
210-
response = new Response(buffer, { status });
211-
if (!response.headers.has('Content-Type')) {
212-
response.headers.set('Content-Type', 'application/octet-stream');
213-
}
214-
response.headers.set('Content-Length', String(buffer.byteLength));
215-
response.headers.set(
216-
'Content-Range',
217-
`bytes ${start}-${end}/${totalFileSize}`
218-
);
219-
} else {
220-
let body: null | ArrayBuffer;
221-
if (method === 'HEAD') {
222-
body = null;
223-
} else {
224-
body = await file.arrayBuffer();
225-
}
226-
response = new Response(body, {
227-
headers: {
228-
'Content-Length': String(body ? body.byteLength : 0),
229-
'Content-Type': getMimeType(file),
230-
},
231-
status: method === 'HEAD' ? 204 : 200,
232-
});
233-
}
234-
if (acceptRanges) {
235-
response.headers.set('Accept-Ranges', 'bytes');
236-
}
237-
return response;
238-
}

src/middleware/serveFiles/serveFiles.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import path from 'path';
22
import type { Middleware } from '../../HttpRouter/HttpRouter';
3-
import { buildFileResponse } from '../../HttpRouter/responseFactories';
43
import ms from '../../ms/ms';
4+
import buildFileResponse from '../../responseFactories/buildFileResponse';
55

66
// see https://expressjs.com/en/4x/api.html#express.static
77
// and https://www.npmjs.com/package/send#dotfiles

0 commit comments

Comments
 (0)