diff --git a/packages/file-storage/.changes/patch.update-lazy-file-api.md b/packages/file-storage/.changes/patch.update-lazy-file-api.md new file mode 100644 index 00000000000..792b86ef569 --- /dev/null +++ b/packages/file-storage/.changes/patch.update-lazy-file-api.md @@ -0,0 +1 @@ +Update `@remix-run/fs` peer dependency to use new `openLazyFile()` API diff --git a/packages/file-storage/src/lib/backends/fs.ts b/packages/file-storage/src/lib/backends/fs.ts index 5fe285a3884..7bf358cc071 100644 --- a/packages/file-storage/src/lib/backends/fs.ts +++ b/packages/file-storage/src/lib/backends/fs.ts @@ -1,7 +1,7 @@ import * as fs from 'node:fs' import * as fsp from 'node:fs/promises' import * as path from 'node:path' -import { openFile, writeFile } from '@remix-run/fs' +import { openLazyFile, writeFile } from '@remix-run/fs' import type { FileStorage, FileMetadata, ListOptions, ListResult } from '../file-storage.ts' @@ -69,7 +69,7 @@ export function createFsFileStorage(directory: string): FileStorage { let metaData = await readMetadata(metaPath) - return openFile(filePath, { + return openLazyFile(filePath, { lastModified: metaData.lastModified, name: metaData.name, type: metaData.type, @@ -83,7 +83,7 @@ export function createFsFileStorage(directory: string): FileStorage { try { let meta = await readMetadata(metaPath) - return openFile(filePath, { + return openLazyFile(filePath, { lastModified: meta.lastModified, name: meta.name, type: meta.type, diff --git a/packages/fs/.changes/minor.rename-openfile-to-openlazyfile.md b/packages/fs/.changes/minor.rename-openfile-to-openlazyfile.md new file mode 100644 index 00000000000..e92cd1273fd --- /dev/null +++ b/packages/fs/.changes/minor.rename-openfile-to-openlazyfile.md @@ -0,0 +1,19 @@ +BREAKING CHANGE: Renamed `openFile()` to `openLazyFile()`, removed `getFile()` + +Since `LazyFile` no longer extends `File`, the function name now explicitly reflects the return type. The `getFile()` alias has also been removed—use `openLazyFile()` instead. + +**Migration:** + +```ts +import { openLazyFile } from '@remix-run/fs' + +let lazyFile = openLazyFile('./document.pdf') + +// Streaming +let response = new Response(lazyFile.stream()) + +// For non-streaming APIs that require a complete File (e.g. FormData) +formData.append('file', await lazyFile.toFile()) +``` + +**Note:** `.toFile()` and `.toBlob()` read the entire file into memory. Only use these for non-streaming APIs that require a complete `File` or `Blob` (e.g. `FormData`). Always prefer `.stream()` if possible. diff --git a/packages/fs/README.md b/packages/fs/README.md index dffa3a2c009..958d24d0bca 100644 --- a/packages/fs/README.md +++ b/packages/fs/README.md @@ -1,12 +1,12 @@ # fs -Filesystem utilities using the Web File API. +Lazy, streaming filesystem utilities for JavaScript. -This package provides utilities for working with files on the local filesystem using the Web [File API](https://developer.mozilla.org/en-US/docs/Web/API/File). +This package provides utilities for working with files on the local filesystem using the [`LazyFile`](https://github.com/remix-run/remix/tree/main/packages/lazy-file)/ native [`File`](https://developer.mozilla.org/en-US/docs/Web/API/File) API. ## Features -- **Web Standards** - Use the Web File API for maximum portability +- **Web Standards** - Uses [`LazyFile`](https://github.com/remix-run/remix/tree/main/packages/lazy-file) which matches the native [`File`](https://developer.mozilla.org/en-US/docs/Web/API/File) API and provides `.stream()`, `.toFile()`, and `.toBlob()` for converting to native types. - **Seamless Node.js Compat** - Works seamlessly with Node.js file descriptors and handles ## Installation @@ -19,19 +19,19 @@ npm install @remix-run/fs ## Usage -### Opening Files +### Opening Lazy Files ```ts -import { openFile } from '@remix-run/fs' +import { openLazyFile } from '@remix-run/fs' // Open a file from the filesystem -let file = openFile('./path/to/file.json') +let lazyFile = openLazyFile('./path/to/file.json') -// The file is lazy - no data is read until you call file.text(), file.bytes(), etc. -let json = JSON.parse(await file.text()) +// The file is lazy - no data is read until you call lazyFile.text(), lazyFile.bytes(), etc. +let json = JSON.parse(await lazyFile.text()) // You can override file metadata -let customFile = openFile('./image.jpg', { +let customLazyFile = openLazyFile('./image.jpg', { name: 'custom-name.jpg', type: 'image/jpeg', lastModified: Date.now(), @@ -41,16 +41,16 @@ let customFile = openFile('./image.jpg', { ### Writing Files ```ts -import { openFile, writeFile } from '@remix-run/fs' +import { openLazyFile, writeFile } from '@remix-run/fs' // Read a file and write it elsewhere -let file = openFile('./source.txt') -await writeFile('./destination.txt', file) +let lazyFile = openLazyFile('./source.txt') +await writeFile('./destination.txt', lazyFile) // Write to an open file handle import * as fsp from 'node:fs/promises' let handle = await fsp.open('./destination.txt', 'w') -await writeFile(handle, file) +await writeFile(handle, lazyFile) await handle.close() ``` diff --git a/packages/fs/src/index.ts b/packages/fs/src/index.ts index 26f66ee1135..ca895d32998 100644 --- a/packages/fs/src/index.ts +++ b/packages/fs/src/index.ts @@ -1,2 +1,2 @@ -export type { GetFileOptions, OpenFileOptions } from './lib/fs.ts' -export { getFile, openFile, writeFile } from './lib/fs.ts' +export type { OpenLazyFileOptions } from './lib/fs.ts' +export { openLazyFile, writeFile } from './lib/fs.ts' diff --git a/packages/fs/src/lib/fs.test.ts b/packages/fs/src/lib/fs.test.ts index d48a9596c93..b0d42b4bff4 100644 --- a/packages/fs/src/lib/fs.test.ts +++ b/packages/fs/src/lib/fs.test.ts @@ -5,9 +5,9 @@ import * as os from 'node:os' import * as path from 'node:path' import { beforeEach, afterEach, describe, it } from 'node:test' -import { openFile, writeFile } from './fs.ts' +import { openLazyFile, writeFile } from './fs.ts' -describe('openFile', () => { +describe('openLazyFile', () => { let tmpDir: string beforeEach(() => { @@ -33,11 +33,11 @@ describe('openFile', () => { it('opens a file and reads content', async () => { let filePath = createTestFile('test.txt', 'hello world') - let file = openFile(filePath) + let lazyFile = openLazyFile(filePath) - assert.equal(file.name, filePath) - assert.equal(file.size, 11) - assert.equal(await file.text(), 'hello world') + assert.equal(lazyFile.name, filePath) + assert.equal(lazyFile.size, 11) + assert.equal(await lazyFile.text(), 'hello world') }) it('sets MIME type based on file extension', () => { @@ -45,50 +45,50 @@ describe('openFile', () => { let jsonPath = createTestFile('test.json', '{}') let txtPath = createTestFile('test.txt', 'text') - assert.equal(openFile(htmlPath).type, 'text/html') - assert.equal(openFile(jsonPath).type, 'application/json') - assert.equal(openFile(txtPath).type, 'text/plain') + assert.equal(openLazyFile(htmlPath).type, 'text/html') + assert.equal(openLazyFile(jsonPath).type, 'application/json') + assert.equal(openLazyFile(txtPath).type, 'text/plain') }) it('sets lastModified from file stats', () => { let filePath = createTestFile('test.txt', 'content') let stats = fs.statSync(filePath) - let file = openFile(filePath) + let lazyFile = openLazyFile(filePath) - assert.equal(file.lastModified, stats.mtimeMs) + assert.equal(lazyFile.lastModified, stats.mtimeMs) }) it('overrides file name with options.name', () => { let filePath = createTestFile('test.txt', 'content') - let file = openFile(filePath, { name: 'custom.txt' }) + let lazyFile = openLazyFile(filePath, { name: 'custom.txt' }) - assert.equal(file.name, 'custom.txt') + assert.equal(lazyFile.name, 'custom.txt') }) it('overrides MIME type with options.type', () => { let filePath = createTestFile('test.txt', 'content') - let file = openFile(filePath, { type: 'application/custom' }) + let lazyFile = openLazyFile(filePath, { type: 'application/custom' }) - assert.equal(file.type, 'application/custom') + assert.equal(lazyFile.type, 'application/custom') }) it('overrides lastModified with options.lastModified', () => { let filePath = createTestFile('test.txt', 'content') let customTime = Date.now() - 1000000 - let file = openFile(filePath, { lastModified: customTime }) + let lazyFile = openLazyFile(filePath, { lastModified: customTime }) - assert.equal(file.lastModified, customTime) + assert.equal(lazyFile.lastModified, customTime) }) it('reads file as ArrayBuffer', async () => { let filePath = createTestFile('test.txt', 'hello') - let file = openFile(filePath) - let buffer = await file.arrayBuffer() + let lazyFile = openLazyFile(filePath) + let buffer = await lazyFile.arrayBuffer() assert.equal(buffer.byteLength, 5) assert.equal(new TextDecoder().decode(buffer), 'hello') @@ -97,10 +97,10 @@ describe('openFile', () => { it('streams file content', async () => { let filePath = createTestFile('test.txt', 'streaming content') - let file = openFile(filePath) + let lazyFile = openLazyFile(filePath) let chunks: Uint8Array[] = [] - for await (let chunk of file.stream()) { + for await (let chunk of lazyFile.stream()) { chunks.push(chunk) } @@ -117,27 +117,27 @@ describe('openFile', () => { it('handles empty files', async () => { let filePath = createTestFile('empty.txt', '') - let file = openFile(filePath) + let lazyFile = openLazyFile(filePath) - assert.equal(file.size, 0) - assert.equal(await file.text(), '') + assert.equal(lazyFile.size, 0) + assert.equal(await lazyFile.text(), '') }) it('handles large files', async () => { let largeContent = 'x'.repeat(10000) let filePath = createTestFile('large.txt', largeContent) - let file = openFile(filePath) + let lazyFile = openLazyFile(filePath) - assert.equal(file.size, 10000) - assert.equal(await file.text(), largeContent) + assert.equal(lazyFile.size, 10000) + assert.equal(await lazyFile.text(), largeContent) }) it('throws error for non-existent files', () => { let nonExistentPath = path.join(tmpDir, 'nonexistent.txt') assert.throws( - () => openFile(nonExistentPath), + () => openLazyFile(nonExistentPath), (error: Error) => error.message.includes('ENOENT'), ) }) @@ -147,7 +147,7 @@ describe('openFile', () => { fs.mkdirSync(dirPath) assert.throws( - () => openFile(dirPath), + () => openLazyFile(dirPath), (error: Error) => error.message.includes('is not a file'), ) }) @@ -171,8 +171,8 @@ describe('writeFile', () => { let destPath = path.join(tmpDir, 'dest.txt') fs.writeFileSync(sourcePath, 'test content') - let file = openFile(sourcePath) - await writeFile(destPath, file) + let lazyFile = openLazyFile(sourcePath) + await writeFile(destPath, lazyFile) assert.equal(fs.readFileSync(destPath, 'utf-8'), 'test content') }) @@ -182,10 +182,10 @@ describe('writeFile', () => { let destPath = path.join(tmpDir, 'dest.txt') fs.writeFileSync(sourcePath, 'test content') - let file = openFile(sourcePath) + let lazyFile = openLazyFile(sourcePath) let fd = fs.openSync(destPath, 'w') - await writeFile(fd, file) + await writeFile(fd, lazyFile) // Note: fd is automatically closed by the write stream assert.equal(fs.readFileSync(destPath, 'utf-8'), 'test content') @@ -196,10 +196,10 @@ describe('writeFile', () => { let destPath = path.join(tmpDir, 'dest.txt') fs.writeFileSync(sourcePath, 'test content') - let file = openFile(sourcePath) + let lazyFile = openLazyFile(sourcePath) let handle = await fsp.open(destPath, 'w') - await writeFile(handle, file) + await writeFile(handle, lazyFile) await handle.close() assert.equal(fs.readFileSync(destPath, 'utf-8'), 'test content') @@ -210,8 +210,8 @@ describe('writeFile', () => { let destPath = path.join(tmpDir, 'dest.txt') fs.writeFileSync(sourcePath, '') - let file = openFile(sourcePath) - await writeFile(destPath, file) + let lazyFile = openLazyFile(sourcePath) + await writeFile(destPath, lazyFile) assert.equal(fs.readFileSync(destPath, 'utf-8'), '') }) @@ -222,8 +222,8 @@ describe('writeFile', () => { let destPath = path.join(tmpDir, 'dest.txt') fs.writeFileSync(sourcePath, largeContent) - let file = openFile(sourcePath) - await writeFile(destPath, file) + let lazyFile = openLazyFile(sourcePath) + await writeFile(destPath, lazyFile) assert.equal(fs.readFileSync(destPath, 'utf-8'), largeContent) }) @@ -234,8 +234,8 @@ describe('writeFile', () => { fs.writeFileSync(sourcePath, 'content') fs.mkdirSync(path.dirname(destPath), { recursive: true }) - let file = openFile(sourcePath) - await writeFile(destPath, file) + let lazyFile = openLazyFile(sourcePath) + await writeFile(destPath, lazyFile) assert.ok(fs.existsSync(destPath)) assert.equal(fs.readFileSync(destPath, 'utf-8'), 'content') @@ -247,8 +247,8 @@ describe('writeFile', () => { let destPath = path.join(tmpDir, 'dest.dat') fs.writeFileSync(sourcePath, binaryData) - let file = openFile(sourcePath) - await writeFile(destPath, file) + let lazyFile = openLazyFile(sourcePath) + await writeFile(destPath, lazyFile) let written = fs.readFileSync(destPath) assert.deepEqual(written, binaryData) diff --git a/packages/fs/src/lib/fs.ts b/packages/fs/src/lib/fs.ts index 01a5228dbf8..eb244b44584 100644 --- a/packages/fs/src/lib/fs.ts +++ b/packages/fs/src/lib/fs.ts @@ -4,9 +4,9 @@ import { detectMimeType } from '@remix-run/mime' import { type LazyContent, LazyFile } from '@remix-run/lazy-file' /** - * Options for opening a file from the local filesystem. + * Options for opening a lazy file from the local filesystem. */ -export interface OpenFileOptions { +export interface OpenLazyFileOptions { /** * Overrides the name of the file. * @@ -28,19 +28,16 @@ export interface OpenFileOptions { } /** - * Returns a `File` from the local filesytem. + * Returns a `LazyFile` from the local filesystem. * * The returned file's `name` property will be set to the `filename` argument as provided, * unless overridden via `options.name`. * - * [MDN `File` Reference](https://developer.mozilla.org/en-US/docs/Web/API/File) - * - * @alias getFile * @param filename The path to the file * @param options Options to override the file's metadata - * @returns A `File` object + * @returns A `LazyFile` object */ -export function openFile(filename: string, options?: OpenFileOptions): File { +export function openLazyFile(filename: string, options?: OpenLazyFileOptions): LazyFile { let stats = fs.statSync(filename) if (!stats.isFile()) { @@ -57,7 +54,7 @@ export function openFile(filename: string, options?: OpenFileOptions): File { return new LazyFile(content, options?.name ?? filename, { type: options?.type ?? detectMimeType(filename) ?? '', lastModified: options?.lastModified ?? stats.mtimeMs, - }) as File + }) } function streamFile( @@ -80,19 +77,21 @@ function streamFile( }) } -// Preserve backwards compat with v3.0 -export { type OpenFileOptions as GetFileOptions, openFile as getFile } - /** - * Writes a `File` to the local filesytem and resolves when the stream is finished. + * Writes a file-like object to the local filesystem and resolves when the stream is finished. + * + * Accepts any object with a `stream()` method, including native `File`, `Blob`, and `LazyFile`. * * [MDN `File` Reference](https://developer.mozilla.org/en-US/docs/Web/API/File) * * @param to The path to write the file to, or an open file descriptor - * @param file The file to write + * @param file The file to write (any object with a `stream()` method) * @returns A promise that resolves when the file is written */ -export function writeFile(to: string | number | fs.promises.FileHandle, file: File): Promise { +export function writeFile( + to: string | number | fs.promises.FileHandle, + file: { stream(): ReadableStream }, +): Promise { return new Promise(async (resolve, reject) => { let writeStream = typeof to === 'string' diff --git a/packages/lazy-file/.changes/minor.lazy-classes-not-subclasses.md b/packages/lazy-file/.changes/minor.lazy-classes-not-subclasses.md new file mode 100644 index 00000000000..064aa077435 --- /dev/null +++ b/packages/lazy-file/.changes/minor.lazy-classes-not-subclasses.md @@ -0,0 +1,30 @@ +BREAKING CHANGE: `LazyFile` and `LazyBlob` no longer extend native `File` and `Blob` + +Some runtimes (like Bun) bypass the JavaScript layer when accessing `File`/`Blob` internals, leading to issues with missing content due to the lazy loading behavior. `LazyFile` and `LazyBlob` now implement the same interface as their native counterparts but are standalone classes. + +As a result: + +- `lazyFile instanceof File` now returns `false` +- You cannot pass `LazyFile`/`LazyBlob` directly to `new Response(file)` or `formData.append('file', file)` +- Passing a `LazyFile`/`LazyBlob` directly to `Response` will throw an error with guidance on correct usage + +**Migration:** + +```ts +// Before +let response = new Response(lazyFile) + +// After - streaming +let response = new Response(lazyFile.stream()) + +// After - for non-streaming APIs that require a complete File (e.g. FormData) +formData.append('file', await lazyFile.toFile()) +``` + +**New methods added:** + +- `LazyFile.toFile()` +- `LazyFile.toBlob()` +- `LazyBlob.toBlob()` + +**Note:** `.toFile()` and `.toBlob()` read the entire content into memory. Only use these for non-streaming APIs that require a complete `File` or `Blob` (e.g. `FormData`). Always prefer `.stream()` when possible. diff --git a/packages/lazy-file/README.md b/packages/lazy-file/README.md index 7d2e52673c9..bbe3606a7bc 100644 --- a/packages/lazy-file/README.md +++ b/packages/lazy-file/README.md @@ -2,12 +2,13 @@ `lazy-file` is a lazy, streaming `Blob`/`File` implementation for JavaScript. -It allows you to easily create [Blob](https://developer.mozilla.org/en-US/docs/Web/API/Blob) and [File](https://developer.mozilla.org/en-US/docs/Web/API/File) objects that defer reading their contents until needed, which is ideal for situations where a file's contents do not fit in memory all at once. When file contents are read, they are streamed to avoid buffering. +It allows you to easily create [Blob](https://developer.mozilla.org/en-US/docs/Web/API/Blob)-like and [File](https://developer.mozilla.org/en-US/docs/Web/API/File)-like objects that defer reading their contents until needed, which is ideal for situations where a file's contents do not fit in memory all at once. When file contents are read, they are streamed to avoid buffering. ## Features - **Deferred Loading** - Blob/file contents loaded on demand to minimize memory usage -- **Drop-in Replacement** - `LazyBlob extends Blob` and `LazyFile extends File` so instances can be used anywhere you'd normally expect a regular `Blob`/`File` +- **Familiar Interface** - `LazyBlob` and `LazyFile` implement the same interface as native `Blob` and `File` +- **Easy Conversion** - Convert to native `ReadableStream` with `.stream()`, or to native `Blob`/`File` with `.toBlob()` and `.toFile()` - **Standard Constructors** - Accepts all the same content types as the original [`Blob()`](https://developer.mozilla.org/en-US/docs/Web/API/Blob/Blob) and [`File()`](https://developer.mozilla.org/en-US/docs/Web/API/File/File) constructors - **Slice Support** - Supports [`Blob.slice()`](https://developer.mozilla.org/en-US/docs/Web/API/Blob/slice), even on streaming content @@ -25,7 +26,7 @@ A `LazyFile` improves this model by accepting an additional content type in its let lazyContent: LazyContent = { /* See below for usage */ } -let file = new LazyFile(lazyContent, 'hello.txt', { type: 'text/plain' }) +let lazyFile = new LazyFile(lazyContent, 'hello.txt', { type: 'text/plain' }) ``` All other `File` functionality works as you'd expect. @@ -40,7 +41,7 @@ npm install @remix-run/lazy-file ## Usage -The low-level API can be used to create a `File` that streams content from anywhere: +The low-level API can be used to create a `LazyFile` that streams content from anywhere: ```ts import { type LazyContent, LazyFile } from '@remix-run/lazy-file' @@ -61,13 +62,44 @@ let content: LazyContent = { }, } -let file = new LazyFile(content, 'example.txt', { type: 'text/plain' }) -await file.arrayBuffer() // ArrayBuffer of the file's content -file.name // "example.txt" -file.type // "text/plain" +let lazyFile = new LazyFile(content, 'example.txt', { type: 'text/plain' }) +await lazyFile.arrayBuffer() // ArrayBuffer of the file's content +lazyFile.name // "example.txt" +lazyFile.type // "text/plain" ``` -All file contents are read on-demand and nothing is ever buffered. +All file contents are read on-demand and nothing is ever buffered unless you explicitly call `.toFile()` or `.toBlob()`. + +### Streaming Content + +Use `.stream()` to get a `ReadableStream` for `Response` and other streaming APIs: + +```ts +import { openLazyFile } from '@remix-run/fs' + +let lazyFile = openLazyFile('./large-video.mp4') + +let response = new Response(lazyFile.stream(), { + headers: { + 'Content-Type': lazyFile.type, + 'Content-Length': String(lazyFile.size), + }, +}) +``` + +### Converting to Native File/Blob + +For non-streaming APIs that require a complete `File` or `Blob` (e.g. `FormData`), use `.toFile()` or `.toBlob()`. + +```ts +let lazyFile = openLazyFile('./document.pdf') +let realFile = await lazyFile.toFile() + +let formData = new FormData() +formData.append('document', realFile) +``` + +> **Note:** `.toFile()` and `.toBlob()` read the entire file into memory. Only use these for non-streaming APIs that require a complete `File` or `Blob` (e.g. `FormData`). Always prefer `.stream()` if possible. ## Related Packages diff --git a/packages/lazy-file/src/lib/lazy-file.test.ts b/packages/lazy-file/src/lib/lazy-file.test.ts index 991a3f511e3..3b57850e82f 100644 --- a/packages/lazy-file/src/lib/lazy-file.test.ts +++ b/packages/lazy-file/src/lib/lazy-file.test.ts @@ -1,7 +1,11 @@ import * as assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { type LazyContent, LazyFile } from './lazy-file.ts' +import { type LazyContent, LazyBlob, LazyFile } from './lazy-file.ts' + +// Type assertions: ensure LazyBlob and LazyFile implement all native Blob/File APIs. +null as unknown as LazyBlob satisfies Record +null as unknown as LazyFile satisfies Record function createLazyContent(value = ''): LazyContent { let buffer = new TextEncoder().encode(value) @@ -18,32 +22,133 @@ function createLazyContent(value = ''): LazyContent { } } +describe('LazyBlob', () => { + it('has the correct size and type', () => { + let blob = new LazyBlob(createLazyContent('X'.repeat(100)), { + type: 'text/plain', + }) + + assert.equal(blob.size, 100) + assert.equal(blob.type, 'text/plain') + }) + + it('is not an instance of Blob', () => { + let blob = new LazyBlob(createLazyContent('hello'), { type: 'text/plain' }) + assert.equal(blob instanceof Blob, false) + }) + + it('has the correct Symbol.toStringTag', () => { + let blob = new LazyBlob(createLazyContent('hello'), { type: 'text/plain' }) + assert.equal(Object.prototype.toString.call(blob), '[object LazyBlob]') + }) + + it("returns the blob's contents as a stream", async () => { + let content = createLazyContent('hello world') + let blob = new LazyBlob(content, { type: 'text/plain' }) + + let decoder = new TextDecoder() + let result = '' + for await (let chunk of blob.stream()) { + result += decoder.decode(chunk, { stream: true }) + } + result += decoder.decode() + + assert.equal(result, 'hello world') + }) + + it("returns the blob's contents as a string", async () => { + let content = createLazyContent('hello world') + let blob = new LazyBlob(content, { type: 'text/plain' }) + + assert.equal(await blob.text(), 'hello world') + }) + + it("returns the blob's contents as bytes", async () => { + let content = createLazyContent('hello') + let blob = new LazyBlob(content, { type: 'text/plain' }) + let bytes = await blob.bytes() + + assert.equal(bytes.length, 5) + assert.deepEqual(bytes, new TextEncoder().encode('hello')) + }) + + it("returns the blob's contents as an ArrayBuffer", async () => { + let content = createLazyContent('hello') + let blob = new LazyBlob(content, { type: 'text/plain' }) + let buffer = await blob.arrayBuffer() + + assert.equal(buffer.byteLength, 5) + }) + + describe('toBlob()', () => { + it('converts to a native Blob', async () => { + let lazyBlob = new LazyBlob(createLazyContent('hello world'), { type: 'text/plain' }) + let blob = await lazyBlob.toBlob() + + assert.equal(blob instanceof Blob, true) + assert.equal(blob.size, 11) + assert.equal(blob.type, 'text/plain') + assert.equal(await blob.text(), 'hello world') + }) + }) + + describe('slice()', () => { + it('returns a LazyBlob with the correct size', () => { + let blob = new LazyBlob(createLazyContent('hello world'), { type: 'text/plain' }) + let slice = blob.slice(0, 5) + assert.equal(slice instanceof LazyBlob, true) + assert.equal(slice.size, 5) + }) + }) + + describe('toString()', () => { + it('throws a TypeError to prevent misuse with Response', () => { + let blob = new LazyBlob(createLazyContent('hello'), { type: 'text/plain' }) + assert.throws(() => blob.toString(), { + name: 'TypeError', + message: + 'Cannot convert LazyBlob to string. Use .stream() to get a ReadableStream for Response and other streaming APIs, or .toBlob() for non-streaming APIs that require a complete Blob (e.g. FormData). Always prefer .stream() when possible.', + }) + }) + }) +}) + describe('LazyFile', () => { it('has the correct name, size, type, and lastModified timestamp', () => { let now = Date.now() - let file = new LazyFile(createLazyContent('X'.repeat(100)), 'example.txt', { + let lazyFile = new LazyFile(createLazyContent('X'.repeat(100)), 'example.txt', { type: 'text/plain', lastModified: now, }) - assert.equal(file.name, 'example.txt') - assert.equal(file.size, 100) - assert.equal(file.type, 'text/plain') - assert.equal(file.lastModified, now) + assert.equal(lazyFile.name, 'example.txt') + assert.equal(lazyFile.size, 100) + assert.equal(lazyFile.type, 'text/plain') + assert.equal(lazyFile.lastModified, now) + }) + + it('is not an instance of File', () => { + let lazyFile = new LazyFile(createLazyContent('hello'), 'hello.txt', { type: 'text/plain' }) + assert.equal(lazyFile instanceof File, false) + }) + + it('has the correct Symbol.toStringTag', () => { + let lazyFile = new LazyFile(createLazyContent('hello'), 'hello.txt', { type: 'text/plain' }) + assert.equal(Object.prototype.toString.call(lazyFile), '[object LazyFile]') }) it('can be initialized with a [Blob] as the content', async () => { let content = [new Blob(['hello world'], { type: 'text/plain' })] - let file = new LazyFile(content, 'hello.txt', { type: 'text/plain' }) - assert.equal(file.size, 11) - assert.equal('hello world', await file.text()) + let lazyFile = new LazyFile(content, 'hello.txt', { type: 'text/plain' }) + assert.equal(lazyFile.size, 11) + assert.equal('hello world', await lazyFile.text()) }) it('can be initialized with another LazyFile as the content', async () => { let content = [new LazyFile(['hello world'], 'hello.txt', { type: 'text/plain' })] - let file = new LazyFile(content, 'hello.txt', { type: 'text/plain' }) - assert.equal(file.size, 11) - assert.equal('hello world', await file.text()) + let lazyFile = new LazyFile(content, 'hello.txt', { type: 'text/plain' }) + assert.equal(lazyFile.size, 11) + assert.equal('hello world', await lazyFile.text()) }) it('can be initialized with multiple Blobs and strings as the content and can slice them correctly', async () => { @@ -53,18 +158,18 @@ describe('LazyFile', () => { new Blob(['!', ' '], { type: 'text/plain' }), 'extra stuff', ] - let file = new LazyFile(parts, 'hello.txt', { type: 'text/plain' }) - assert.equal(file.size, 27) - assert.equal(await file.slice(2, -13).text(), 'hello world!') + let lazyFile = new LazyFile(parts, 'hello.txt', { type: 'text/plain' }) + assert.equal(lazyFile.size, 27) + assert.equal(await lazyFile.slice(2, -13).text(), 'hello world!') }) it("returns the file's contents as a stream", async () => { let content = createLazyContent('hello world') - let file = new LazyFile(content, 'hello.txt', { type: 'text/plain' }) + let lazyFile = new LazyFile(content, 'hello.txt', { type: 'text/plain' }) let decoder = new TextDecoder() let result = '' - for await (let chunk of file.stream()) { + for await (let chunk of lazyFile.stream()) { result += decoder.decode(chunk, { stream: true }) } result += decoder.decode() @@ -74,43 +179,76 @@ describe('LazyFile', () => { it("returns the file's contents as a string", async () => { let content = createLazyContent('hello world') - let file = new LazyFile(content, 'hello.txt', { + let lazyFile = new LazyFile(content, 'hello.txt', { type: 'text/plain', }) - assert.equal(await file.text(), 'hello world') + assert.equal(await lazyFile.text(), 'hello world') + }) + + describe('toFile()', () => { + it('converts to a native File', async () => { + let now = Date.now() + let lazyFile = new LazyFile(createLazyContent('hello world'), 'hello.txt', { + type: 'text/plain', + lastModified: now, + }) + let file = await lazyFile.toFile() + + assert.equal(file instanceof File, true) + assert.equal(file.name, 'hello.txt') + assert.equal(file.size, 11) + assert.equal(file.type, 'text/plain') + assert.equal(file.lastModified, now) + assert.equal(await file.text(), 'hello world') + }) + }) + + describe('toBlob()', () => { + it('converts to a native Blob', async () => { + let lazyFile = new LazyFile(createLazyContent('hello world'), 'hello.txt', { + type: 'text/plain', + }) + let blob = await lazyFile.toBlob() + + assert.equal(blob instanceof Blob, true) + assert.equal(blob.size, 11) + assert.equal(blob.type, 'text/plain') + assert.equal(await blob.text(), 'hello world') + }) }) describe('slice()', () => { - it('returns a file with the same size as the original when slicing from 0 to the end', () => { - let file = new LazyFile(createLazyContent('hello world'), 'hello.txt', { + it('returns a LazyBlob with the same size as the original when slicing from 0 to the end', () => { + let lazyFile = new LazyFile(createLazyContent('hello world'), 'hello.txt', { type: 'text/plain', }) - let slice = file.slice(0) - assert.equal(slice.size, file.size) + let slice = lazyFile.slice(0) + assert.equal(slice instanceof LazyBlob, true) + assert.equal(slice.size, lazyFile.size) }) - it('returns a file with size 0 when the "start" index is greater than the content length', () => { - let file = new LazyFile(['hello world'], 'hello.txt', { + it('returns a LazyBlob with size 0 when the "start" index is greater than the content length', () => { + let lazyFile = new LazyFile(['hello world'], 'hello.txt', { type: 'text/plain', }) - let slice = file.slice(100) + let slice = lazyFile.slice(100) assert.equal(slice.size, 0) }) - it('returns a file with size 0 when the "start" index is greater than the "end" index', () => { - let file = new LazyFile(['hello world'], 'hello.txt', { + it('returns a LazyBlob with size 0 when the "start" index is greater than the "end" index', () => { + let lazyFile = new LazyFile(['hello world'], 'hello.txt', { type: 'text/plain', }) - let slice = file.slice(5, 0) + let slice = lazyFile.slice(5, 0) assert.equal(slice.size, 0) }) it('calls content.stream() with the correct range', (t) => { let content = createLazyContent('X'.repeat(100)) let read = t.mock.method(content, 'stream') - let file = new LazyFile(content, 'example.txt', { type: 'text/plain' }) - file.slice(10, 20).stream() + let lazyFile = new LazyFile(content, 'example.txt', { type: 'text/plain' }) + lazyFile.slice(10, 20).stream() assert.equal(read.mock.calls.length, 1) assert.deepEqual(read.mock.calls[0].arguments, [10, 20]) }) @@ -118,8 +256,8 @@ describe('LazyFile', () => { it('calls content.stream() with the correct range when slicing a file with a negative "start" index', (t) => { let content = createLazyContent('X'.repeat(100)) let read = t.mock.method(content, 'stream') - let file = new LazyFile(content, 'example.txt', { type: 'text/plain' }) - file.slice(-10).stream() + let lazyFile = new LazyFile(content, 'example.txt', { type: 'text/plain' }) + lazyFile.slice(-10).stream() assert.equal(read.mock.calls.length, 1) assert.deepEqual(read.mock.calls[0].arguments, [90, 100]) }) @@ -127,8 +265,8 @@ describe('LazyFile', () => { it('calls content.stream() with the correct range when slicing a file with a negative "end" index', (t) => { let content = createLazyContent('X'.repeat(100)) let read = t.mock.method(content, 'stream') - let file = new LazyFile(content, 'example.txt', { type: 'text/plain' }) - file.slice(0, -10).stream() + let lazyFile = new LazyFile(content, 'example.txt', { type: 'text/plain' }) + lazyFile.slice(0, -10).stream() assert.equal(read.mock.calls.length, 1) assert.deepEqual(read.mock.calls[0].arguments, [0, 90]) }) @@ -136,8 +274,8 @@ describe('LazyFile', () => { it('calls content.stream() with the correct range when slicing a file with negative "start" and "end" indexes', (t) => { let content = createLazyContent('X'.repeat(100)) let read = t.mock.method(content, 'stream') - let file = new LazyFile(content, 'example.txt', { type: 'text/plain' }) - file.slice(-20, -10).stream() + let lazyFile = new LazyFile(content, 'example.txt', { type: 'text/plain' }) + lazyFile.slice(-20, -10).stream() assert.equal(read.mock.calls.length, 1) assert.deepEqual(read.mock.calls[0].arguments, [80, 90]) }) @@ -145,10 +283,21 @@ describe('LazyFile', () => { it('calls content.stream() with the correct range when slicing a file with a "start" index greater than the "end" index', (t) => { let content = createLazyContent('X'.repeat(100)) let read = t.mock.method(content, 'stream') - let file = new LazyFile(content, 'example.txt', { type: 'text/plain' }) - file.slice(20, 10).stream() + let lazyFile = new LazyFile(content, 'example.txt', { type: 'text/plain' }) + lazyFile.slice(20, 10).stream() assert.equal(read.mock.calls.length, 1) assert.deepEqual(read.mock.calls[0].arguments, [20, 20]) }) }) + + describe('toString()', () => { + it('throws a TypeError to prevent misuse with Response', () => { + let lazyFile = new LazyFile(createLazyContent('hello'), 'hello.txt', { type: 'text/plain' }) + assert.throws(() => lazyFile.toString(), { + name: 'TypeError', + message: + 'Cannot convert LazyFile to string. Use .stream() to get a ReadableStream for Response and other streaming APIs, or .toFile()/.toBlob() for non-streaming APIs that require a complete File/Blob (e.g. FormData). Always prefer .stream() when possible.', + }) + }) + }) }) diff --git a/packages/lazy-file/src/lib/lazy-file.ts b/packages/lazy-file/src/lib/lazy-file.ts index 4e595ec0d02..3e6442ee606 100644 --- a/packages/lazy-file/src/lib/lazy-file.ts +++ b/packages/lazy-file/src/lib/lazy-file.ts @@ -39,32 +39,32 @@ export interface LazyBlobOptions { } /** - * A [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob) that may be backed by a stream - * of data. This is useful for working with large blobs that would be impractical to load into - * memory all at once. + * A lazy, streaming alternative to [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob). * - * This class is an extension of JavaScript's built-in `Blob` class with the following additions: + * **Important:** Since `LazyBlob` is not a `Blob` subclass, you cannot pass it directly to APIs + * that expect a real `Blob` (like `new Response(blob)` or `formData.append('file', blob)`). + * Instead, use one of: * - * - The constructor may accept a `LazyContent` object instead of a `BlobPart[]` array - * - The constructor may accept a `range` in the options to specify a subset of the content - * - * In normal usage you shouldn't have to specify the `range` yourself. The `slice()` method - * automatically takes care of creating new `LazyBlob` instances with the correct range. + * - `.stream()` - Returns a `ReadableStream` for `Response` and other streaming APIs + * - `.toBlob()` - Returns a `Promise` for non-streaming APIs that require a complete `Blob` (e.g. `FormData`) * * [MDN `Blob` Reference](https://developer.mozilla.org/en-US/docs/Web/API/Blob) */ -export class LazyBlob extends Blob { +export class LazyBlob { readonly #content: BlobContent /** * @param parts The blob parts or lazy content * @param options Options for the blob */ - constructor(parts: BlobPart[] | LazyContent, options?: LazyBlobOptions) { - super([], options) + constructor(parts: BlobPartLike[] | LazyContent, options?: LazyBlobOptions) { this.#content = new BlobContent(parts, options) } + get [Symbol.toStringTag](): string { + return 'LazyBlob' + } + /** * Returns the blob's contents as an [`ArrayBuffer`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer). * @@ -97,16 +97,25 @@ export class LazyBlob extends Blob { } /** - * Returns a new [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob) that contains the data in the specified range. + * The MIME type of the blob. + * + * [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/Blob/type) + */ + get type(): string { + return this.#content.type + } + + /** + * Returns a new `LazyBlob` that contains the data in the specified range. * * [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/Blob/slice) * * @param start The start index (inclusive) * @param end The end index (exclusive) * @param contentType The content type of the new blob - * @returns A new `Blob` containing the sliced data + * @returns A new `LazyBlob` containing the sliced data */ - slice(start?: number, end?: number, contentType?: string): Blob { + slice(start?: number, end?: number, contentType?: string): LazyBlob { return this.#content.slice(start, end, contentType) } @@ -131,6 +140,29 @@ export class LazyBlob extends Blob { text(): Promise { return this.#content.text() } + + /** + * Converts this `LazyBlob` to a native `Blob`. + * + * **Warning:** This reads the entire content into memory, which defeats the purpose of using + * a lazy blob for large files. Only use this for non-streaming APIs that require a complete `Blob`. + * Use `.stream()` to get a `ReadableStream` for `Response` and other streaming APIs. + * + * @returns A promise that resolves to a native `Blob` + */ + async toBlob(): Promise { + return new Blob([await this.bytes()], { type: this.type }) + } + + /** + * @throws Always throws a TypeError. LazyBlob cannot be implicitly converted to a string. + * Use `.stream()` to get a `ReadableStream` for `Response` and other streaming APIs, or `.toBlob()` for non-streaming APIs that require a complete `Blob` (e.g. `FormData`). Always prefer `.stream()` when possible. + */ + toString(): never { + throw new TypeError( + 'Cannot convert LazyBlob to string. Use .stream() to get a ReadableStream for Response and other streaming APIs, or .toBlob() for non-streaming APIs that require a complete Blob (e.g. FormData). Always prefer .stream() when possible.', + ) + } } /** @@ -148,31 +180,57 @@ export interface LazyFileOptions extends LazyBlobOptions { } /** - * A [`File`](https://developer.mozilla.org/en-US/docs/Web/API/File) that may be backed by a stream - * of data. This is useful for working with large files that would be impractical to load into - * memory all at once. + * A lazy, streaming alternative to [`File`](https://developer.mozilla.org/en-US/docs/Web/API/File). * - * This class is an extension of JavaScript's built-in `File` class with the following additions: + * **Important:** Since `LazyFile` is not a `File` subclass, you cannot pass it directly to APIs + * that expect a real `File` (like `new Response(file)` or `formData.append('file', file)`). + * Instead, use one of: * - * - The constructor may accept a `LazyContent` object instead of a `BlobPart[]` array - * - The constructor may accept a `range` in the options to specify a subset of the content - * - * In normal usage you shouldn't have to specify the `range` yourself. The `slice()` method - * automatically takes care of creating new `LazyBlob` instances with the correct range. + * - `.stream()` - Returns a `ReadableStream` for `Response` and other streaming APIs + * - `.toFile()` - Returns a `Promise` for non-streaming APIs that require a complete `File` (e.g. `FormData`) + * - `.toBlob()` - Returns a `Promise` for non-streaming APIs that require a complete `Blob` (e.g. `FormData`) * * [MDN `File` Reference](https://developer.mozilla.org/en-US/docs/Web/API/File) */ -export class LazyFile extends File { +export class LazyFile { readonly #content: BlobContent + /** + * The name of the file. + * + * [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/File/name) + */ + readonly name: string + + /** + * The last modified timestamp of the file in milliseconds. + * + * [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/File/lastModified) + */ + readonly lastModified: number + + /** + * Always empty string. This property exists only for structural compatibility with the native + * `File` interface. It's a browser-specific property for files selected via `` + * with the `webkitdirectory` attribute, which doesn't apply to programmatically created files. + * + * [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/File/webkitRelativePath) + */ + readonly webkitRelativePath = '' + /** * @param parts The file parts or lazy content * @param name The name of the file * @param options Options for the file */ - constructor(parts: BlobPart[] | LazyContent, name: string, options?: LazyFileOptions) { - super([], name, options) + constructor(parts: BlobPartLike[] | LazyContent, name: string, options?: LazyFileOptions) { this.#content = new BlobContent(parts, options) + this.name = name + this.lastModified = options?.lastModified ?? Date.now() + } + + get [Symbol.toStringTag](): string { + return 'LazyFile' } /** @@ -207,16 +265,27 @@ export class LazyFile extends File { } /** - * Returns a new [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob) that contains the data in the specified range. + * The MIME type of the file. + * + * [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/Blob/type) + */ + get type(): string { + return this.#content.type + } + + /** + * Returns a new `LazyBlob` that contains the data in the specified range. + * + * Note: Like the native `File.slice()`, this returns a `Blob` (not a `File`). * * [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/Blob/slice) * * @param start The start index (inclusive) * @param end The end index (exclusive) * @param contentType The content type of the new blob - * @returns A new `Blob` containing the sliced data + * @returns A new `LazyBlob` containing the sliced data */ - slice(start?: number, end?: number, contentType?: string): Blob { + slice(start?: number, end?: number, contentType?: string): LazyBlob { return this.#content.slice(start, end, contentType) } @@ -241,21 +310,74 @@ export class LazyFile extends File { text(): Promise { return this.#content.text() } + + /** + * Converts this `LazyFile` to a native `Blob`. + * + * **Warning:** This reads the entire content into memory, which defeats the purpose of using + * a lazy file for large files. Only use this for non-streaming APIs that require a complete `Blob`. + * Use `.stream()` to get a `ReadableStream` for `Response` and other streaming APIs. + * + * @returns A promise that resolves to a native `Blob` + */ + async toBlob(): Promise { + return new Blob([await this.bytes()], { type: this.type }) + } + + /** + * Converts this `LazyFile` to a native `File`. + * + * **Warning:** This reads the entire content into memory, which defeats the purpose of using + * a lazy file for large files. Only use this for non-streaming APIs that require a complete `File` + * (e.g. `FormData`). For streaming, use `.stream()` instead. + * + * @returns A promise that resolves to a native `File` + */ + async toFile(): Promise { + return new File([await this.bytes()], this.name, { + type: this.type, + lastModified: this.lastModified, + }) + } + + /** + * @throws Always throws a TypeError. LazyFile cannot be implicitly converted to a string. + * Use `.stream()` to get a `ReadableStream` for `Response` and other streaming APIs, or `.toFile()`/`.toBlob()` for non-streaming APIs that require a complete `File`/`Blob` (e.g. `FormData`). Always prefer `.stream()` when possible. + */ + toString(): never { + throw new TypeError( + 'Cannot convert LazyFile to string. Use .stream() to get a ReadableStream for Response and other streaming APIs, or .toFile()/.toBlob() for non-streaming APIs that require a complete File/Blob (e.g. FormData). Always prefer .stream() when possible.', + ) + } +} + +/** + * Union of Blob and lazy blob types. + */ +type BlobLike = Blob | LazyBlob | LazyFile + +/** + * Union of BlobPart and lazy blob types. Used for constructor signatures. + */ +type BlobPartLike = BlobPart | LazyBlob | LazyFile + +function isBlobLike(value: unknown): value is BlobLike { + return value instanceof Blob || value instanceof LazyBlob || value instanceof LazyFile } class BlobContent { - readonly source: (Blob | Uint8Array)[] | LazyContent + readonly source: (BlobLike | Uint8Array)[] | LazyContent readonly totalSize: number readonly range?: ByteRange readonly type: string - constructor(parts: BlobPart[] | LazyContent, options?: LazyBlobOptions) { + constructor(parts: BlobPartLike[] | LazyContent, options?: LazyBlobOptions) { if (Array.isArray(parts)) { this.source = [] this.totalSize = 0 for (let part of parts) { - if (part instanceof Blob) { + if (isBlobLike(part)) { this.source.push(part) this.totalSize += part.size } else { @@ -358,7 +480,7 @@ function streamContentArray( } async function pushPart(part: Blob | Uint8Array) { - if (part instanceof Blob) { + if (isBlobLike(part)) { if (bytesRead + part.size <= start) { // We can skip this part entirely. bytesRead += part.size diff --git a/packages/response/.changes/minor.file-response-accepts-lazyfile.md b/packages/response/.changes/minor.file-response-accepts-lazyfile.md new file mode 100644 index 00000000000..2169fc7d125 --- /dev/null +++ b/packages/response/.changes/minor.file-response-accepts-lazyfile.md @@ -0,0 +1,21 @@ +`createFileResponse()` is now generic and accepts any file-like object + +The function now accepts any object satisfying the `FileLike` interface, which includes both native `File` and `LazyFile` from `@remix-run/lazy-file`. This change supports the updated `LazyFile` class which no longer extends native `File`. + +The generic type flows through to the `digest` callback in options, so you get the exact type you passed in: + +```ts +// With native File - digest receives File +createFileResponse(nativeFile, request, { + digest: async (file) => { + /* file is typed as File */ + }, +}) + +// With LazyFile - digest receives LazyFile +createFileResponse(lazyFile, request, { + digest: async (file) => { + /* file is typed as LazyFile */ + }, +}) +``` diff --git a/packages/response/README.md b/packages/response/README.md index 853959995ed..31ae6a9025a 100644 --- a/packages/response/README.md +++ b/packages/response/README.md @@ -31,14 +31,14 @@ import { compressResponse } from '@remix-run/response/compress' ### File Responses -The `createFileResponse` helper creates a response for serving files with full HTTP semantics: +The `createFileResponse` helper creates a response for serving files with full HTTP semantics. It works with both native `File` objects and `LazyFile` from `@remix-run/lazy-file`: ```ts import { createFileResponse } from '@remix-run/response/file' -import { openFile } from '@remix-run/fs' +import { openLazyFile } from '@remix-run/fs' -let file = await openFile('./public/image.jpg') -let response = await createFileResponse(file, request, { +let lazyFile = openLazyFile('./public/image.jpg') +let response = await createFileResponse(lazyFile, request, { cacheControl: 'public, max-age=3600', }) ``` diff --git a/packages/response/package.json b/packages/response/package.json index e2c9bcf7967..80453842122 100644 --- a/packages/response/package.json +++ b/packages/response/package.json @@ -47,6 +47,7 @@ } }, "devDependencies": { + "@remix-run/lazy-file": "workspace:*", "@remix-run/mime": "workspace:*", "@types/node": "catalog:", "@typescript/native-preview": "catalog:" diff --git a/packages/response/src/lib/file.test.ts b/packages/response/src/lib/file.test.ts index 4e6237ec7f8..e41b8092a9e 100644 --- a/packages/response/src/lib/file.test.ts +++ b/packages/response/src/lib/file.test.ts @@ -1,7 +1,14 @@ import * as assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { createFileResponse } from './file.ts' +import { LazyFile } from '@remix-run/lazy-file' + +import { createFileResponse, type FileLike } from './file.ts' + +// Type assertions: ensure FileLike is compatible with native File and LazyFile. +// If FileLike drifts from their APIs, TypeScript will error here. +null as unknown as File satisfies FileLike +null as unknown as LazyFile satisfies FileLike describe('createFileResponse()', () => { it('serves a file', async () => { @@ -16,6 +23,18 @@ describe('createFileResponse()', () => { assert.equal(response.headers.get('Content-Length'), '13') }) + it('serves a LazyFile', async () => { + let lazyFile = new LazyFile(['Hello, World!'], 'test.txt', { type: 'text/plain' }) + let request = new Request('http://localhost/test.txt') + + let response = await createFileResponse(lazyFile, request) + + assert.equal(response.status, 200) + assert.equal(await response.text(), 'Hello, World!') + assert.equal(response.headers.get('Content-Type'), 'text/plain; charset=utf-8') + assert.equal(response.headers.get('Content-Length'), '13') + }) + it('serves a file with HEAD request', async () => { let mockFile = new File(['Hello, World!'], 'test.txt', { type: 'text/plain' }) let request = new Request('http://localhost/test.txt', { method: 'HEAD' }) diff --git a/packages/response/src/lib/file.ts b/packages/response/src/lib/file.ts index bf5b7bde224..b7f3e03afac 100644 --- a/packages/response/src/lib/file.ts +++ b/packages/response/src/lib/file.ts @@ -1,6 +1,30 @@ import SuperHeaders from '@remix-run/headers' import { isCompressibleMimeType, mimeTypeToContentType } from '@remix-run/mime' +/** + * Minimal interface for file-like objects used by `createFileResponse`. + */ +export interface FileLike { + /** File compatibility - included for interface completeness */ + readonly name: string + /** Used for Content-Length header and range calculations */ + readonly size: number + /** Used for Content-Type header */ + readonly type: string + /** Used for Last-Modified header and weak ETag generation */ + readonly lastModified: number + /** Used for streaming the response body */ + stream(): ReadableStream + /** Used for strong ETag digest calculation */ + arrayBuffer(): Promise + /** Used for range requests (206 Partial Content) */ + slice( + start?: number, + end?: number, + contentType?: string, + ): { stream(): ReadableStream } +} + /** * Custom function for computing file digests. * @@ -13,12 +37,12 @@ import { isCompressibleMimeType, mimeTypeToContentType } from '@remix-run/mime' * return customHash(buffer) * } */ -export type FileDigestFunction = (file: File) => Promise +export type FileDigestFunction = (file: file) => Promise /** * Options for creating a file response. */ -export interface FileResponseOptions { +export interface FileResponseOptions { /** * Cache-Control header value. If not provided, no Cache-Control header will be set. * @@ -43,14 +67,14 @@ export interface FileResponseOptions { * - String: Web Crypto API algorithm name ('SHA-256', 'SHA-384', 'SHA-512', 'SHA-1'). * Note: Using strong ETags will buffer the entire file into memory before hashing. * Consider using weak ETags (default) or a custom digest function for large files. - * - Function: Custom digest computation that receives a File and returns the digest string + * - Function: Custom digest computation that receives a file and returns the digest string * * Only used when `etag: 'strong'`. Ignored for weak ETags. * * @default 'SHA-256' * @example async (file) => await customHash(file) */ - digest?: AlgorithmIdentifier | FileDigestFunction + digest?: AlgorithmIdentifier | FileDigestFunction /** * Whether to include `Last-Modified` headers. * @@ -78,22 +102,26 @@ export interface FileResponseOptions { * Creates a file [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) * with full HTTP semantics including ETags, Last-Modified, conditional requests, and Range support. * - * @param file The file to send + * Accepts both native `File` objects and `LazyFile` from `@remix-run/lazy-file`. + * + * @param file The file to send (native `File` or `LazyFile`) * @param request The request object * @param options Configuration options * @returns A `Response` object containing the file * * @example * import { createFileResponse } from '@remix-run/response/file' - * let file = openFile('./public/image.jpg') - * return createFileResponse(file, request, { + * import { openLazyFile } from '@remix-run/fs' + * + * let lazyFile = openLazyFile('./public/image.jpg') + * return createFileResponse(lazyFile, request, { * cacheControl: 'public, max-age=3600' * }) */ -export async function createFileResponse( - file: File, +export async function createFileResponse( + file: file, request: Request, - options: FileResponseOptions = {}, + options: FileResponseOptions = {}, ): Promise { let { cacheControl, @@ -239,7 +267,7 @@ export async function createFileResponse( let { start, end } = normalizedRanges[0] let { size } = file - return new Response(file.slice(start, end + 1), { + return new Response(file.slice(start, end + 1).stream(), { status: 206, headers: new SuperHeaders( omitNullableValues({ @@ -256,7 +284,7 @@ export async function createFileResponse( } } - return new Response(request.method === 'HEAD' ? null : file, { + return new Response(request.method === 'HEAD' ? null : file.stream(), { status: 200, headers: new SuperHeaders( omitNullableValues({ @@ -271,7 +299,7 @@ export async function createFileResponse( }) } -function generateWeakETag(file: File): string { +function generateWeakETag(file: FileLike): string { return `W/"${file.size}-${file.lastModified}"` } @@ -296,9 +324,9 @@ function omitNullableValues>(headers: T): OmitNull * @param digestOption Web Crypto algorithm name or custom digest function * @returns The computed digest as a hex string */ -async function computeDigest( - file: File, - digestOption: AlgorithmIdentifier | FileDigestFunction, +async function computeDigest( + file: file, + digestOption: AlgorithmIdentifier | FileDigestFunction, ): Promise { return typeof digestOption === 'function' ? await digestOption(file) @@ -315,7 +343,10 @@ async function computeDigest( * @param algorithm Web Crypto API algorithm name (default: 'SHA-256') * @returns The hash as a hex string */ -async function hashFile(file: File, algorithm: AlgorithmIdentifier = 'SHA-256'): Promise { +async function hashFile( + file: F, + algorithm: AlgorithmIdentifier = 'SHA-256', +): Promise { let buffer = await file.arrayBuffer() let hashBuffer = await crypto.subtle.digest(algorithm, buffer) return Array.from(new Uint8Array(hashBuffer)) @@ -323,10 +354,8 @@ async function hashFile(file: File, algorithm: AlgorithmIdentifier = 'SHA-256'): .join('') } -/** - * Removes milliseconds from a timestamp, returning seconds. - * HTTP dates only have second precision, so this is useful for date comparisons. - */ +// Removes milliseconds from a timestamp, returning seconds. +// HTTP dates only have second precision, so this is useful for date comparisons. function removeMilliseconds(time: number | Date): number { let timestamp = time instanceof Date ? time.getTime() : time return Math.floor(timestamp / 1000) diff --git a/packages/static-middleware/.changes/patch.update-fs-api.md b/packages/static-middleware/.changes/patch.update-fs-api.md new file mode 100644 index 00000000000..792b86ef569 --- /dev/null +++ b/packages/static-middleware/.changes/patch.update-fs-api.md @@ -0,0 +1 @@ +Update `@remix-run/fs` peer dependency to use new `openLazyFile()` API diff --git a/packages/static-middleware/src/lib/static.ts b/packages/static-middleware/src/lib/static.ts index e3b49775be0..166bd89670f 100644 --- a/packages/static-middleware/src/lib/static.ts +++ b/packages/static-middleware/src/lib/static.ts @@ -1,6 +1,6 @@ import * as path from 'node:path' import * as fsp from 'node:fs/promises' -import { openFile } from '@remix-run/fs' +import { openLazyFile } from '@remix-run/fs' import type { Middleware } from '@remix-run/fetch-router' import { createFileResponse as sendFile, type FileResponseOptions } from '@remix-run/response/file' @@ -141,19 +141,19 @@ export function staticFiles(root: string, options: StaticFilesOptions = {}): Mid if (filePath) { let fileName = path.relative(root, filePath) - let file = openFile(filePath, { name: fileName }) + let lazyFile = openLazyFile(filePath, { name: fileName }) let finalFileOptions: FileResponseOptions = { ...fileOptions } - // If acceptRanges is a function, evaluate it with the file + // If acceptRanges is a function, evaluate it with the lazyFile // Otherwise, pass it directly to sendFile if (typeof acceptRanges === 'function') { - finalFileOptions.acceptRanges = acceptRanges(file) + finalFileOptions.acceptRanges = acceptRanges(lazyFile) } else if (acceptRanges !== undefined) { finalFileOptions.acceptRanges = acceptRanges } - return sendFile(file, context.request, finalFileOptions) + return sendFile(lazyFile, context.request, finalFileOptions) } return next() diff --git a/packages/tar-parser/bench/parsers/tar-parser.ts b/packages/tar-parser/bench/parsers/tar-parser.ts index 2859c873c12..529643b1afa 100644 --- a/packages/tar-parser/bench/parsers/tar-parser.ts +++ b/packages/tar-parser/bench/parsers/tar-parser.ts @@ -1,8 +1,8 @@ import { parseTar } from '@remix-run/tar-parser' -import { openFile } from '@remix-run/fs' +import { openLazyFile } from '@remix-run/fs' export async function parse(filename: string): Promise { - let stream = openFile(filename).stream().pipeThrough(new DecompressionStream('gzip')) + let stream = openLazyFile(filename).stream().pipeThrough(new DecompressionStream('gzip')) let start = performance.now() diff --git a/packages/tar-parser/test/utils.ts b/packages/tar-parser/test/utils.ts index 3121d2e9d92..ca2d15e775d 100644 --- a/packages/tar-parser/test/utils.ts +++ b/packages/tar-parser/test/utils.ts @@ -1,6 +1,6 @@ import * as path from 'node:path' import { fileURLToPath } from 'node:url' -import { openFile } from '@remix-run/fs' +import { openLazyFile } from '@remix-run/fs' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const fixturesDir = path.resolve(__dirname, 'fixtures') @@ -29,7 +29,7 @@ export const fixtures = { } export function readFixture(filename: string): ReadableStream { - let stream = openFile(filename).stream() + let stream = openLazyFile(filename).stream() return filename.endsWith('.tar.gz') || filename.endsWith('.tgz') ? stream.pipeThrough(new DecompressionStream('gzip')) : stream diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6105ae384d6..d820753406b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -896,6 +896,9 @@ importers: specifier: workspace:^ version: link:../html-template devDependencies: + '@remix-run/lazy-file': + specifier: workspace:* + version: link:../lazy-file '@remix-run/mime': specifier: workspace:* version: link:../mime