Skip to content

Commit 85ede3b

Browse files
committed
Add mime package, update default acceptRanges logic
1 parent 0b8924d commit 85ede3b

34 files changed

+1189
-228
lines changed

demos/sse/app/router.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ middleware.push(
1919
cacheControl: 'no-store, must-revalidate',
2020
etag: false,
2121
lastModified: false,
22-
acceptRanges: false,
2322
}),
2423
)
2524

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"scripts": {
1818
"build": "pnpm -r build",
1919
"clean": "git clean -fdX -e '!/.env' .",
20+
"codegen": "pnpm -r run codegen",
2021
"create-github-release": "node --env-file .env ./scripts/create-github-release.js",
2122
"lint": "eslint . --max-warnings=0",
2223
"lint:fix": "eslint . --fix",

packages/fetch-router/CHANGELOG.md

Lines changed: 30 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,36 @@
22

33
This is the changelog for [`fetch-router`](https://github.com/remix-run/remix/tree/main/packages/fetch-router). It follows [semantic versioning](https://semver.org/).
44

5+
## Unreleased
6+
7+
- Add `compress()` response helper for compressing responses based on client's `Accept-Encoding` header
8+
9+
```tsx
10+
import * as res from '@remix-run/fetch-router/response-helpers'
11+
12+
router.get('/data', async ({ request }) => {
13+
let data = await getHugeDataset()
14+
let response = new Response(JSON.stringify(data), {
15+
headers: { 'Content-Type': 'application/json' },
16+
})
17+
return res.compress(response, request)
18+
})
19+
```
20+
21+
- Add `compression()` middleware for automatic response compression
22+
23+
```tsx
24+
import { compression } from '@remix-run/fetch-router/compression-middleware'
25+
26+
let router = createRouter({
27+
middleware: [compression()],
28+
})
29+
```
30+
31+
- Modify the default behavior of the `acceptRanges` option for `staticFiles()` middleware and the `file()` response helper to enable ranges only for non-compressible MIME types, as defined by `isCompressibleMimeType()` from `@remix-run/mime`. This is to allow compression for text-based assets while still supporting resumable downloads for media files.
32+
33+
- Add callback support for `acceptRanges` option in `staticFiles()` middleware so it can be customized to enable range requests only for specific MIME types, file sizes, etc.
34+
535
## v0.9.0 (2025-11-18)
636

737
- Add `session` middleware for automatic management of `context.session` across requests
@@ -65,39 +95,6 @@ This is the changelog for [`fetch-router`](https://github.com/remix-run/remix/tr
6595
})
6696
```
6797

68-
- Add `compress()` response helper for compressing responses based on client's `Accept-Encoding` header
69-
70-
```tsx
71-
import * as res from '@remix-run/fetch-router/response-helpers'
72-
73-
router.get('/data', async ({ request }) => {
74-
let data = await getHugeDataset()
75-
let response = new Response(JSON.stringify(data), {
76-
headers: { 'Content-Type': 'application/json' },
77-
})
78-
return res.compress(response, request)
79-
})
80-
```
81-
82-
- Add `compression()` middleware for automatic response compression
83-
84-
```tsx
85-
import { compression } from '@remix-run/fetch-router/compression-middleware'
86-
87-
let router = createRouter({
88-
middleware: [compression()],
89-
})
90-
```
91-
92-
- Add `isCompressibleMediaType()` helper
93-
94-
```tsx
95-
import { isCompressibleMediaType } from '@remix-run/fetch-router/compression-middleware'
96-
97-
isCompressibleMediaType('text/html') // true
98-
isCompressibleMediaType('image/jpeg') // false
99-
```
100-
10198
## v0.8.0 (2025-11-03)
10299

103100
- BREAKING CHANGE: Rework how middleware works in the router. This change has far-reaching implications.

packages/fetch-router/README.md

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -718,7 +718,8 @@ return res.file(file, request, {
718718
lastModified: true,
719719

720720
// Whether to support HTTP Range requests for partial content.
721-
// Defaults to `true`.
721+
// Defaults to enabling ranges only for non-compressible MIME types,
722+
// as defined by `isCompressibleMimeType()` from `@remix-run/mime`.
722723
acceptRanges: true,
723724
})
724725
```
@@ -757,6 +758,21 @@ return res.file(file, request, {
757758
})
758759
```
759760

761+
##### Range Requests and Compression
762+
763+
By default, the `file()` helper enables Range requests only for non-compressible MIME types (like video, audio, and images). This allows text-based assets (HTML, CSS, JavaScript, etc.) to be compressed by the compression middleware while still supporting resumable downloads for media files.
764+
765+
You can override this behavior by explicitly enabling or disabling ranges with the `acceptRanges` option:
766+
767+
```ts
768+
// Force range request support
769+
return res.file(file, request, {
770+
acceptRanges: true,
771+
})
772+
```
773+
774+
**Note:** Range requests and compression are mutually exclusive. When `Accept-Ranges: bytes` is present in the response headers, the compression middleware will not compress the response. This is why the default behavior enables ranges only for non-compressible types.
775+
760776
#### Using `findFile()` with `file()`
761777

762778
When you need to map a route pattern to a directory of files on disk, you can use the `findFile()` function from `@remix-run/lazy-file/fs` to resolve files before sending them with the `file()` response helper:
@@ -812,6 +828,43 @@ staticFiles('./images', {
812828
})
813829
```
814830

831+
Since the `staticFiles()` middleware handles multiple files generically, the `acceptRanges` option can also accept a function that receives the file:
832+
833+
```ts
834+
import { staticFiles } from '@remix-run/fetch-router/static-middleware'
835+
import { isCompressibleMimeType } from '@remix-run/mime'
836+
837+
// Enable ranges only for large files
838+
let router = createRouter({
839+
middleware: [
840+
staticFiles('./public', {
841+
acceptRanges: (file) => file.size > 10 * 1024 * 1024,
842+
}),
843+
],
844+
})
845+
846+
// Or enable ranges only for videos
847+
let router = createRouter({
848+
middleware: [
849+
staticFiles('./public', {
850+
acceptRanges: (file) => file.type.startsWith('video/'),
851+
}),
852+
],
853+
})
854+
855+
// Or use custom logic combining file size and MIME type
856+
let router = createRouter({
857+
middleware: [
858+
staticFiles('./public', {
859+
acceptRanges: (file) => {
860+
let mediaType = file.type.split(';')[0].trim()
861+
return !isCompressibleMimeType(mediaType) || file.size > 10 * 1024 * 1024
862+
},
863+
}),
864+
],
865+
})
866+
```
867+
815868
### Compressing Responses
816869

817870
The router provides a couple of tools for serving files:
@@ -892,26 +945,24 @@ let router = createRouter({
892945
})
893946
```
894947

895-
The middleware also applies an additional **Content-Type filter** to only apply compression to appropriate media types (MIME types). By default, this uses the `isCompressibleMediaType(mediaType)` helper to check if the media type is compressible. You can customize this behavior with the `filterMediaType` option, re-using the built-in filter if needed.
948+
The middleware also applies an additional **Content-Type filter** to only apply compression to appropriate media types (MIME types). By default, this uses the `isCompressibleMimeType(mimeType)` helper to check if the MIME type is compressible. You can customize this behavior with the `filterMediaType` option, re-using the built-in filter if needed.
896949

897950
```ts
898-
import {
899-
compression,
900-
isCompressibleMediaType,
901-
} from '@remix-run/fetch-router/compression-middleware'
951+
import { compression } from '@remix-run/fetch-router/compression-middleware'
952+
import { isCompressibleMimeType } from '@remix-run/mime'
902953

903954
let router = createRouter({
904955
middleware: [
905956
compression({
906957
filterMediaType(mediaType) {
907-
return isCompressibleMediaType(mediaType) || mediaType === 'application/vnd.example+data'
958+
return isCompressibleMimeType(mediaType) || mediaType === 'application/vnd.example+data'
908959
},
909960
}),
910961
],
911962
})
912963
```
913964

914-
The `isCompressibleMediaType` helper determines whether a media type should be compressed. It returns `true` for:
965+
The `isCompressibleMimeType` helper determines whether a MIME type should be compressed. It returns `true` for:
915966

916967
- Known compressible types from the [mime-db](https://www.npmjs.com/package/mime-db) database (e.g., `application/json`, `text/html`, `text/css`), except those starting with `x-` (experimental) or `vnd.` (vendor-specific).
917968
- All `text/*` types (e.g., `text/plain`, `text/markdown`)

packages/fetch-router/package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,6 @@
7676
"@remix-run/cookie": "workspace:*",
7777
"@remix-run/session": "workspace:*",
7878
"@types/node": "^24.6.0",
79-
"mime-db": "^1.53.0",
8079
"typescript": "^5.9.3"
8180
},
8281
"peerDependencies": {
@@ -85,13 +84,13 @@
8584
"@remix-run/headers": "workspace:^",
8685
"@remix-run/html-template": "workspace:^",
8786
"@remix-run/lazy-file": "workspace:^",
87+
"@remix-run/mime": "workspace:^",
8888
"@remix-run/route-pattern": "workspace:^",
8989
"@remix-run/session": "workspace:^"
9090
},
9191
"scripts": {
9292
"build": "tsc -p tsconfig.build.json",
9393
"clean": "git clean -fdX",
94-
"generate:compressible": "node --disable-warning=ExperimentalWarning ./scripts/generate-compressible.js",
9594
"prepublishOnly": "pnpm run build",
9695
"test": "node --disable-warning=ExperimentalWarning --test './src/**/*.test.ts'",
9796
"typecheck": "tsc --noEmit"

packages/fetch-router/scripts/generate-compressible.js

Lines changed: 0 additions & 46 deletions
This file was deleted.
Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1 @@
1-
export {
2-
compression,
3-
type CompressionOptions,
4-
isCompressibleMediaType,
5-
} from './lib/middleware/compression.ts'
1+
export { compression, type CompressionOptions } from './lib/middleware/compression.ts'

packages/fetch-router/src/lib/compressible-media-types.test.ts

Lines changed: 0 additions & 23 deletions
This file was deleted.

packages/fetch-router/src/lib/middleware/compression.test.ts

Lines changed: 4 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2,45 +2,14 @@ import * as assert from 'node:assert/strict'
22
import { gunzip } from 'node:zlib'
33
import { promisify } from 'node:util'
44
import { describe, it } from 'node:test'
5+
import { isCompressibleMimeType } from '@remix-run/mime'
56

67
import { createRoutes } from '../route-map.ts'
78
import { createRouter } from '../router.ts'
8-
import { compression, isCompressibleMediaType } from './compression.ts'
9+
import { compression } from './compression.ts'
910

1011
const gunzipAsync = promisify(gunzip)
1112

12-
describe('isCompressibleMediaType()', () => {
13-
it('returns true for common compressible media types', () => {
14-
assert.equal(isCompressibleMediaType('text/html'), true)
15-
assert.equal(isCompressibleMediaType('text/plain'), true)
16-
assert.equal(isCompressibleMediaType('application/json'), true)
17-
assert.equal(isCompressibleMediaType('application/javascript'), true)
18-
assert.equal(isCompressibleMediaType('text/css'), true)
19-
})
20-
21-
it('returns true for text/* types', () => {
22-
assert.equal(isCompressibleMediaType('text/custom'), true)
23-
assert.equal(isCompressibleMediaType('text/markdown'), true)
24-
})
25-
26-
it('returns true for types with +json, +text, or +xml suffix', () => {
27-
assert.equal(isCompressibleMediaType('application/vnd.api+json'), true)
28-
assert.equal(isCompressibleMediaType('application/custom+xml'), true)
29-
assert.equal(isCompressibleMediaType('application/something+text'), true)
30-
})
31-
32-
it('returns false for non-compressible media types', () => {
33-
assert.equal(isCompressibleMediaType('image/png'), false)
34-
assert.equal(isCompressibleMediaType('image/jpeg'), false)
35-
assert.equal(isCompressibleMediaType('video/mp4'), false)
36-
assert.equal(isCompressibleMediaType('audio/mpeg'), false)
37-
})
38-
39-
it('returns false for empty string', () => {
40-
assert.equal(isCompressibleMediaType(''), false)
41-
})
42-
})
43-
4413
describe('compression()', () => {
4514
it('compresses compressible content types', async () => {
4615
let routes = createRoutes({
@@ -183,7 +152,7 @@ describe('compression()', () => {
183152
assert.equal(await htmlResponse.text(), '<html>test</html>')
184153
})
185154

186-
it('allows custom filterMediaType to use isCompressibleMediaType', async () => {
155+
it('allows custom filterMediaType to use isCompressibleMimeType', async () => {
187156
let routes = createRoutes({
188157
json: '/data.json',
189158
html: '/page.html',
@@ -194,7 +163,7 @@ describe('compression()', () => {
194163
compression({
195164
filterMediaType: (mediaType) => {
196165
// Only compress if it's compressible AND not HTML
197-
return isCompressibleMediaType(mediaType) && !mediaType.includes('html')
166+
return isCompressibleMimeType(mediaType) && !mediaType.includes('html')
198167
},
199168
}),
200169
],

0 commit comments

Comments
 (0)