diff --git a/.changeset/old-moons-lick.md b/.changeset/old-moons-lick.md new file mode 100644 index 000000000000..bb045ae458c1 --- /dev/null +++ b/.changeset/old-moons-lick.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/adapter-cloudflare': minor +--- + +feat: add support for `('$app/server').read` diff --git a/.changeset/weak-pillows-doubt.md b/.changeset/weak-pillows-doubt.md new file mode 100644 index 000000000000..e8dc03d9742c --- /dev/null +++ b/.changeset/weak-pillows-doubt.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +feat: export `('@sveltejs/kit/adapter').streamFileContent` for server read implementation in adapter diff --git a/packages/adapter-cloudflare/index.js b/packages/adapter-cloudflare/index.js index 5bb705f6a6ae..9607ea934e32 100644 --- a/packages/adapter-cloudflare/index.js +++ b/packages/adapter-cloudflare/index.js @@ -161,6 +161,9 @@ export default function (options = {}) { return prerender ? emulated.prerender_platform : emulated.platform; } }; + }, + supports: { + read: () => true } }; } diff --git a/packages/adapter-cloudflare/src/worker.js b/packages/adapter-cloudflare/src/worker.js index a2ea423c1653..cf23f0adeab6 100644 --- a/packages/adapter-cloudflare/src/worker.js +++ b/packages/adapter-cloudflare/src/worker.js @@ -1,6 +1,7 @@ import { Server } from 'SERVER'; import { manifest, prerendered, base_path } from 'MANIFEST'; import * as Cache from 'worktop/cfw.cache'; +import { streamFileContent } from '@sveltejs/kit/adapter'; const server = new Server(manifest); @@ -19,7 +20,9 @@ export default { async fetch(req, env, context) { await server.init({ // @ts-expect-error env contains environment variables and bindings - env + env, + read: (file) => + streamFileContent({ fetch: env.ASSETS.fetch, url: new URL('/' + file, req.url) }) }); // skip cache if "cache-control: no-cache" in request diff --git a/packages/adapter-cloudflare/test/apps/pages/src/routes/read/+server.js b/packages/adapter-cloudflare/test/apps/pages/src/routes/read/+server.js new file mode 100644 index 000000000000..b4b7c15dda16 --- /dev/null +++ b/packages/adapter-cloudflare/test/apps/pages/src/routes/read/+server.js @@ -0,0 +1,6 @@ +import { read } from '$app/server'; +import file from '../../../../../file.txt?url'; + +export function GET() { + return read(file); +} diff --git a/packages/adapter-cloudflare/test/apps/pages/test/test.js b/packages/adapter-cloudflare/test/apps/pages/test/test.js index aab7cbca568a..c4266487f53c 100644 --- a/packages/adapter-cloudflare/test/apps/pages/test/test.js +++ b/packages/adapter-cloudflare/test/apps/pages/test/test.js @@ -1,6 +1,17 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import fs from 'node:fs/promises'; import { expect, test } from '@playwright/test'; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + test('worker works', async ({ page }) => { await page.goto('/'); await expect(page.locator('h1')).toContainText('Sum: 3'); }); + +test("('$app/server').read works", async ({ page }) => { + const filecontent = await fs.readFile(path.resolve(__dirname, '../../../file.txt'), 'utf-8'); + const response = await page.goto('/read'); + expect(await response.text()).toBe(filecontent); +}); diff --git a/packages/adapter-cloudflare/test/apps/workers/src/routes/read/+server.js b/packages/adapter-cloudflare/test/apps/workers/src/routes/read/+server.js new file mode 100644 index 000000000000..b4b7c15dda16 --- /dev/null +++ b/packages/adapter-cloudflare/test/apps/workers/src/routes/read/+server.js @@ -0,0 +1,6 @@ +import { read } from '$app/server'; +import file from '../../../../../file.txt?url'; + +export function GET() { + return read(file); +} diff --git a/packages/adapter-cloudflare/test/apps/workers/test/test.js b/packages/adapter-cloudflare/test/apps/workers/test/test.js index aab7cbca568a..c4266487f53c 100644 --- a/packages/adapter-cloudflare/test/apps/workers/test/test.js +++ b/packages/adapter-cloudflare/test/apps/workers/test/test.js @@ -1,6 +1,17 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import fs from 'node:fs/promises'; import { expect, test } from '@playwright/test'; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + test('worker works', async ({ page }) => { await page.goto('/'); await expect(page.locator('h1')).toContainText('Sum: 3'); }); + +test("('$app/server').read works", async ({ page }) => { + const filecontent = await fs.readFile(path.resolve(__dirname, '../../../file.txt'), 'utf-8'); + const response = await page.goto('/read'); + expect(await response.text()).toBe(filecontent); +}); diff --git a/packages/adapter-cloudflare/test/file.txt b/packages/adapter-cloudflare/test/file.txt new file mode 100644 index 000000000000..ad4af0cbe381 --- /dev/null +++ b/packages/adapter-cloudflare/test/file.txt @@ -0,0 +1 @@ +Hello! This file is read by `('$app/server').read`. diff --git a/packages/kit/package.json b/packages/kit/package.json index 1689f92728fc..a30ad1134d97 100644 --- a/packages/kit/package.json +++ b/packages/kit/package.json @@ -83,6 +83,10 @@ "types": "./types/index.d.ts", "import": "./src/exports/index.js" }, + "./adapter": { + "types": "./types/index.d.ts", + "import": "./src/exports/adapter/index.js" + }, "./node": { "types": "./types/index.d.ts", "import": "./src/exports/node/index.js" diff --git a/packages/kit/scripts/generate-dts.js b/packages/kit/scripts/generate-dts.js index e8579ec59054..421f5867d81c 100644 --- a/packages/kit/scripts/generate-dts.js +++ b/packages/kit/scripts/generate-dts.js @@ -5,6 +5,7 @@ await createBundle({ output: 'types/index.d.ts', modules: { '@sveltejs/kit': 'src/exports/public.d.ts', + '@sveltejs/kit/adapter': 'src/exports/adapter/index.js', '@sveltejs/kit/hooks': 'src/exports/hooks/index.js', '@sveltejs/kit/node': 'src/exports/node/index.js', '@sveltejs/kit/node/polyfills': 'src/exports/node/polyfills.js', diff --git a/packages/kit/src/exports/adapter/index.js b/packages/kit/src/exports/adapter/index.js new file mode 100644 index 000000000000..22a7180d18b5 --- /dev/null +++ b/packages/kit/src/exports/adapter/index.js @@ -0,0 +1,44 @@ +/** + * @typedef StreamFileContentOptions + * @property {typeof fetch} fetch The fetch function to use for fetching the asset. + * @property {string | URL} url The URL of the asset to fetch. + * @property {AbortController} [controller] An optional AbortController to cancel the fetch operation. + */ + +/** + * synchronously returns a ReadableStream containing the body of an asynchronously fetched asset + * original use case: adapters' server read implementation + * @param {StreamFileContentOptions } options + * @returns {ReadableStream} + */ +export function streamFileContent(options) { + const { fetch, url, controller: abortController = new AbortController() } = options; + + return new ReadableStream({ + async start(controller) { + try { + const response = await fetch(new URL(url), { signal: abortController.signal }); + if (!response.ok) { + throw new Error(`Failed to fetch (${response.status} - ${response.statusText})`); + } + if (!response.body) { + controller.close(); + return; + } + const reader = response.body.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + controller.enqueue(value); + } + + controller.close(); + } catch (error) { + controller.error(error); + } + }, + cancel(reason) { + abortController.abort(reason); + } + }); +} diff --git a/packages/kit/src/exports/adapter/index.spec.js b/packages/kit/src/exports/adapter/index.spec.js new file mode 100644 index 000000000000..d352e85f5bae --- /dev/null +++ b/packages/kit/src/exports/adapter/index.spec.js @@ -0,0 +1,75 @@ +import { test, expect, vi } from 'vitest'; +import { streamFileContent } from './index.js'; + +const mockedFetch = vi.fn(fetch); + +test('stream successfully', async () => { + const content = 'Hello, world!'; + mockedFetch.mockReturnValueOnce( + new Promise((resolve) => + resolve(new Response(content, { status: 200, headers: { 'Content-Type': 'text/plain' } })) + ) + ); + const stream = streamFileContent({ + fetch: mockedFetch, + url: 'http://assets.local/file.txt' + }); + expect(await new Response(stream).text()).toBe(content); +}); + +test('stream with 404', async () => { + mockedFetch.mockReturnValueOnce( + new Promise((resolve) => resolve(new Response(null, { status: 404, statusText: 'Not Found' }))) + ); + const stream = streamFileContent({ + fetch: mockedFetch, + url: 'http://assets.local/missing.txt' + }); + await expect(new Response(stream).text()).rejects.toThrow('404 - Not Found'); +}); + +test('stream with null body', async () => { + mockedFetch.mockReturnValueOnce( + new Promise((resolve) => resolve(new Response(null, { status: 204, statusText: 'No Content' }))) + ); + const stream = streamFileContent({ + fetch: mockedFetch, + url: 'http://assets.local/empty.txt' + }); + expect(await new Response(stream).text()).toBe(''); +}); + +test('stream with invalid URL', async () => { + const stream = streamFileContent({ + fetch: mockedFetch, + url: '/assets.local/invalid.txt' + }); + await expect(new Response(stream).text()).rejects.toThrow('Invalid URL'); +}); + +test('stream with aborted fetch', async () => { + const controller = new AbortController(); + // abort with DOMException otherwise will fail in CI + // see https://github.com/nodejs/node/issues/49557 + controller.abort(new DOMException('Timeout')); + const stream = streamFileContent({ + fetch: mockedFetch, + url: 'http://assets.local/abort.txt', + controller + }); + await expect(new Response(stream).text()).rejects.toThrow('Timeout'); +}); + +test('cancelled stream should trigger abort signal', async () => { + const controller = new AbortController(); + mockedFetch.mockReturnValueOnce(new Promise(() => {})); + const stream = streamFileContent({ + fetch: mockedFetch, + url: 'http://assets.local/cancel.txt', + controller + }); + const abortListener = vi.fn(); + controller.signal.addEventListener('abort', abortListener); + await stream.cancel('User cancelled'); + expect(abortListener).toHaveBeenCalled(); +}); diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 300a6f726ebb..df797ad8e668 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -2056,6 +2056,30 @@ declare module '@sveltejs/kit' { export {}; } +declare module '@sveltejs/kit/adapter' { + /** + * synchronously returns a ReadableStream containing the body of an asynchronously fetched asset + * original use case: adapters' server read implementation + * */ + export function streamFileContent(options: StreamFileContentOptions): ReadableStream; + export type StreamFileContentOptions = { + /** + * The fetch function to use for fetching the asset. + */ + fetch: typeof fetch; + /** + * The URL of the asset to fetch. + */ + url: string | URL; + /** + * An optional AbortController to cancel the fetch operation. + */ + controller?: AbortController | undefined; + }; + + export {}; +} + declare module '@sveltejs/kit/hooks' { /** * A helper function for sequencing multiple `handle` calls in a middleware-like manner.