Skip to content

Commit 6da55ed

Browse files
committed
refactor: extract reusable ('@sveltejs/kit/adapter').streamFileContent util for server read
1 parent 9a525d6 commit 6da55ed

File tree

6 files changed

+150
-25
lines changed

6 files changed

+150
-25
lines changed

packages/adapter-cloudflare/src/worker.js

Lines changed: 2 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Server } from 'SERVER';
22
import { manifest, prerendered, base_path } from 'MANIFEST';
33
import * as Cache from 'worktop/cfw.cache';
4+
import { streamFileContent } from '@sveltejs/kit/adapter';
45

56
const server = new Server(manifest);
67

@@ -21,31 +22,7 @@ export default {
2122
// @ts-expect-error env contains environment variables and bindings
2223
env,
2324
read: (file) =>
24-
new ReadableStream({
25-
async start(controller) {
26-
try {
27-
const response = await env.ASSETS.fetch(new URL('/' + file, req.url));
28-
if (!response.ok) {
29-
throw new Error(`Failed to fetch (${response.status} - ${response.statusText})`);
30-
}
31-
const reader = response.body.getReader();
32-
/** @returns {Promise<void>} */
33-
async function pump() {
34-
const { done, value } = await reader.read();
35-
if (done) {
36-
controller.close();
37-
return;
38-
}
39-
controller.enqueue(value);
40-
return pump();
41-
}
42-
return pump();
43-
} catch (error) {
44-
console.error(`Error reading file ${file}:`, error);
45-
controller.error(error);
46-
}
47-
}
48-
})
25+
streamFileContent({ fetch: env.ASSETS.fetch, url: new URL('/' + file, req.url) })
4926
});
5027

5128
// skip cache if "cache-control: no-cache" in request

packages/kit/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,10 @@
8383
"types": "./types/index.d.ts",
8484
"import": "./src/exports/index.js"
8585
},
86+
"./adapter": {
87+
"types": "./types/index.d.ts",
88+
"import": "./src/exports/adapter/index.js"
89+
},
8690
"./node": {
8791
"types": "./types/index.d.ts",
8892
"import": "./src/exports/node/index.js"

packages/kit/scripts/generate-dts.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ await createBundle({
55
output: 'types/index.d.ts',
66
modules: {
77
'@sveltejs/kit': 'src/exports/public.d.ts',
8+
'@sveltejs/kit/adapter': 'src/exports/adapter/index.js',
89
'@sveltejs/kit/hooks': 'src/exports/hooks/index.js',
910
'@sveltejs/kit/node': 'src/exports/node/index.js',
1011
'@sveltejs/kit/node/polyfills': 'src/exports/node/polyfills.js',
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/**
2+
* @typedef StreamFileContentOptions
3+
* @property {typeof fetch} fetch The fetch function to use for fetching the asset.
4+
* @property {string | URL} url The URL of the asset to fetch.
5+
* @property {AbortController} [controller] An optional AbortController to cancel the fetch operation.
6+
*/
7+
8+
/**
9+
* synchronously returns a ReadableStream containing the body of an asynchronously fetched asset
10+
* original use case: adapters' server read implementation
11+
* @param {StreamFileContentOptions } options
12+
* @returns {ReadableStream}
13+
*/
14+
export function streamFileContent(options) {
15+
const { fetch, url, controller: abortController = new AbortController() } = options;
16+
17+
return new ReadableStream({
18+
async start(controller) {
19+
try {
20+
const response = await fetch(new URL(url), { signal: abortController.signal });
21+
if (!response.ok) {
22+
throw new Error(`Failed to fetch (${response.status} - ${response.statusText})`);
23+
}
24+
if (!response.body) {
25+
controller.close();
26+
return;
27+
}
28+
const reader = response.body.getReader();
29+
while (true) {
30+
const { done, value } = await reader.read();
31+
if (done) break;
32+
controller.enqueue(value);
33+
}
34+
35+
controller.close();
36+
} catch (error) {
37+
controller.error(error);
38+
}
39+
},
40+
cancel(reason) {
41+
abortController.abort(reason);
42+
}
43+
});
44+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { test, expect, vi } from 'vitest';
2+
import { streamFileContent } from './index.js';
3+
4+
const mockedFetch = vi.fn(fetch);
5+
6+
test('stream successfully', async () => {
7+
const content = 'Hello, world!';
8+
mockedFetch.mockReturnValueOnce(
9+
new Promise((resolve) =>
10+
resolve(new Response(content, { status: 200, headers: { 'Content-Type': 'text/plain' } }))
11+
)
12+
);
13+
const stream = streamFileContent({
14+
fetch: mockedFetch,
15+
url: 'http://assets.local/file.txt'
16+
});
17+
expect(await new Response(stream).text()).toBe(content);
18+
});
19+
20+
test('stream with 404', async () => {
21+
mockedFetch.mockReturnValueOnce(
22+
new Promise((resolve) => resolve(new Response(null, { status: 404, statusText: 'Not Found' })))
23+
);
24+
const stream = streamFileContent({
25+
fetch: mockedFetch,
26+
url: 'http://assets.local/missing.txt'
27+
});
28+
await expect(new Response(stream).text()).rejects.toThrow('404 - Not Found');
29+
});
30+
31+
test('stream with null body', async () => {
32+
mockedFetch.mockReturnValueOnce(
33+
new Promise((resolve) => resolve(new Response(null, { status: 204, statusText: 'No Content' })))
34+
);
35+
const stream = streamFileContent({
36+
fetch: mockedFetch,
37+
url: 'http://assets.local/empty.txt'
38+
});
39+
expect(await new Response(stream).text()).toBe('');
40+
});
41+
42+
test('stream with invalid URL', async () => {
43+
const stream = streamFileContent({
44+
fetch: mockedFetch,
45+
url: '/assets.local/invalid.txt'
46+
});
47+
await expect(new Response(stream).text()).rejects.toThrow('Invalid URL');
48+
});
49+
50+
test('stream with aborted fetch', async () => {
51+
const controller = new AbortController();
52+
// abort with DOMException otherwise will fail in CI
53+
// see https://github.com/nodejs/node/issues/49557
54+
controller.abort(new DOMException('Timeout'));
55+
const stream = streamFileContent({
56+
fetch: mockedFetch,
57+
url: 'http://assets.local/abort.txt',
58+
controller
59+
});
60+
await expect(new Response(stream).text()).rejects.toThrow('Timeout');
61+
});
62+
63+
test('cancelled stream should trigger abort signal', async () => {
64+
const controller = new AbortController();
65+
mockedFetch.mockReturnValueOnce(new Promise(() => {}));
66+
const stream = streamFileContent({
67+
fetch: mockedFetch,
68+
url: 'http://assets.local/cancel.txt',
69+
controller
70+
});
71+
const abortListener = vi.fn();
72+
controller.signal.addEventListener('abort', abortListener);
73+
await stream.cancel('User cancelled');
74+
expect(abortListener).toHaveBeenCalled();
75+
});

packages/kit/types/index.d.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2056,6 +2056,30 @@ declare module '@sveltejs/kit' {
20562056
export {};
20572057
}
20582058

2059+
declare module '@sveltejs/kit/adapter' {
2060+
/**
2061+
* synchronously returns a ReadableStream containing the body of an asynchronously fetched asset
2062+
* original use case: adapters' server read implementation
2063+
* */
2064+
export function streamFileContent(options: StreamFileContentOptions): ReadableStream;
2065+
export type StreamFileContentOptions = {
2066+
/**
2067+
* The fetch function to use for fetching the asset.
2068+
*/
2069+
fetch: typeof fetch;
2070+
/**
2071+
* The URL of the asset to fetch.
2072+
*/
2073+
url: string | URL;
2074+
/**
2075+
* An optional AbortController to cancel the fetch operation.
2076+
*/
2077+
controller?: AbortController | undefined;
2078+
};
2079+
2080+
export {};
2081+
}
2082+
20592083
declare module '@sveltejs/kit/hooks' {
20602084
/**
20612085
* A helper function for sequencing multiple `handle` calls in a middleware-like manner.

0 commit comments

Comments
 (0)