Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
7b1b127
Add static-middleware to fetch-router
markdalgleish Oct 31, 2025
e7836d5
Merge branch 'main' into markdalgleish/static-middleware
markdalgleish Nov 4, 2025
6d8f07b
Tidy up stray diffs after merge
markdalgleish Nov 4, 2025
f8cffa8
Fix ReadableStream type error
markdalgleish Nov 4, 2025
9247b87
Merge branch 'main' into markdalgleish/static-middleware
markdalgleish Nov 5, 2025
9776412
Update pnpm lockfile
markdalgleish Nov 5, 2025
d08727d
Migrate more file handler logic to headers package
markdalgleish Nov 5, 2025
7507f2b
Add support for strong ETags
markdalgleish Nov 6, 2025
4c176d0
Use context.url instead of context.request.url
markdalgleish Nov 6, 2025
c66f816
Update readme
markdalgleish Nov 6, 2025
1122a4b
Use new RequestContext instead of manually mocking
markdalgleish Nov 6, 2025
90d5aba
Export ContentRange and ContentRangeInit
markdalgleish Nov 6, 2025
2ce4125
Merge branch 'main' into markdalgleish/static-middleware
markdalgleish Nov 6, 2025
2caf8dc
Migrate to `findFile` and `file` response helper
markdalgleish Nov 9, 2025
323054d
Move findFile util into lazy-file
markdalgleish Nov 9, 2025
98ed205
Merge branch 'main' into markdalgleish/static-middleware
markdalgleish Nov 9, 2025
cc504e3
Add `matches` to If-Range header, refactor, update docs
markdalgleish Nov 10, 2025
61db8e3
Address feedback, refactor
markdalgleish Nov 11, 2025
5061e94
Refactor
markdalgleish Nov 11, 2025
ef68ecf
Reduce test diff
markdalgleish Nov 11, 2025
c0c1c0a
Add docs for Range `normalize`
markdalgleish Nov 11, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
## Architecture
- **Monorepo**: pnpm workspace with packages in `packages/` directory
- **Key packages**: headers, fetch-proxy, fetch-router, file-storage, form-data-parser, lazy-file, multipart-parser, node-fetch-server, route-pattern, tar-parser
- **Package exports**: All `exports` in `package.json` have a dedicated file in `src` that defines the public API by re-exporting from within `src/lib`
- **Philosophy**: Web standards-first, runtime-agnostic (Node.js, Bun, Deno, Cloudflare Workers). Use Web Streams API, Uint8Array, Web Crypto API, Blob/File instead of Node.js APIs
- **Tests run from source** (no build required), using Node.js test runner

Expand All @@ -20,6 +21,7 @@
- **Classes**: Use native fields (omit `public`), `#private` for private members (no TypeScript accessibility modifiers)
- **Formatting**: Prettier (printWidth: 100, no semicolons, single quotes, spaces not tabs)
- **TypeScript**: Strict mode, ESNext target, ES2022 modules, bundler resolution, verbatimModuleSyntax
- **Comments**: Only add non-JSDoc comments when the code is doing something surprising or non-obvious

## Changelog Formatting
- Use `## Unreleased` as the heading for unreleased changes (not `## HEAD`)
Expand Down
40 changes: 0 additions & 40 deletions demos/bookstore/app/public.ts

This file was deleted.

13 changes: 10 additions & 3 deletions demos/bookstore/app/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { asyncContext } from '@remix-run/fetch-router/async-context-middleware'
import { formData } from '@remix-run/fetch-router/form-data-middleware'
import { logger } from '@remix-run/fetch-router/logger-middleware'
import { methodOverride } from '@remix-run/fetch-router/method-override-middleware'
import { staticFiles } from '@remix-run/fetch-router/static-middleware'

import { routes } from '../routes.ts'
import { uploadHandler } from './utils/uploads.ts'
Expand All @@ -14,7 +15,6 @@ import booksHandlers from './books.tsx'
import cartHandlers from './cart.tsx'
import checkoutHandlers from './checkout.tsx'
import fragmentsHandlers from './fragments.tsx'
import * as publicHandlers from './public.ts'
import * as marketingHandlers from './marketing.tsx'
import { uploadsHandler } from './uploads.tsx'

Expand All @@ -28,10 +28,17 @@ middleware.push(formData({ uploadHandler }))
middleware.push(methodOverride())
middleware.push(asyncContext())

middleware.push(
staticFiles('./public', {
cacheControl: 'no-store, must-revalidate',
etag: false,
lastModified: false,
acceptRanges: false,
}),
)

export let router = createRouter({ middleware })

router.get(routes.assets, publicHandlers.assets)
router.get(routes.images, publicHandlers.images)
router.get(routes.uploads, uploadsHandler)

router.map(routes.home, marketingHandlers.home)
Expand Down
Binary file added demos/bookstore/public/favicon.ico
Binary file not shown.
1 change: 0 additions & 1 deletion demos/bookstore/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { route, formAction, resources } from '@remix-run/fetch-router'

export let routes = route({
assets: '/assets/*path',
images: '/images/*path',
uploads: '/uploads/*key',

// Simple static routes
Expand Down
148 changes: 148 additions & 0 deletions packages/fetch-router/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -605,6 +605,7 @@ router.get('/posts/:id', ({ request, url, params, storage }) => {

The router provides a few response helpers that make it easy to return responses with common formats. They are available in the `@remix-run/fetch-router/response-helpers` export.

- `file(file, request, init?)` - returns a `Response` for a file with full HTTP semantics (ETags, Range requests, etc.) — see [Working with Files](#working-with-files)
- `html(body, init?)` - returns a `Response` with `Content-Type: text/html`
- `json(data, init?)` - returns a `Response` with `Content-Type: application/json`
- `redirect(location, init?)` - returns a `Response` with `Location` header
Expand Down Expand Up @@ -664,6 +665,153 @@ let button = html`<button>${icon} Click me</button>` // icon is not escaped

See the [`@remix-run/html-template` documentation](https://github.com/remix-run/remix/tree/main/packages/html-template#readme) for more details.

### Working with Files

The router provides a couple of tools for serving files:

- **`file()` response helper** - The primitive for returning file responses with full HTTP semantics
- **`staticFiles()` middleware** - Convenience middleware for serving files from a directory

#### The `file()` Response Helper

The `file()` response helper returns a `Response` for a file with full HTTP semantics, including:

- **Content-Type** and **Content-Length** headers
- **ETag** generation (weak or strong)
- **Last-Modified** headers
- **Cache-Control** headers
- **Conditional requests** (`If-None-Match`, `If-Modified-Since`, `If-Match`, `If-Unmodified-Since`)
- **Range requests** for partial content (`206 Partial Content`)
- **HEAD** request support

```ts
import * as res from '@remix-run/fetch-router/response-helpers'
import { openFile } from '@remix-run/lazy-file/fs'

router.get('/assets/:filename', async (context) => {
let file = await openFile(`./assets/${context.params.filename}`)

return res.file(file, context.request)
})
```

##### Options

The `file()` helper accepts an optional third argument with configuration options:

```ts
return res.file(file, request, {
// Cache-Control header value.
// Defaults to `undefined` (no Cache-Control header).
cacheControl: 'public, max-age=3600',

// ETag generation strategy:
// - 'weak': Generates weak ETags based on file size and mtime
// - 'strong': Generates strong ETags by hashing file content
// - false: Disables ETag generation
// Defaults to 'weak'.
etag: 'weak',

// Whether to generate Last-Modified headers.
// Defaults to `true`.
lastModified: true,

// Whether to support HTTP Range requests for partial content.
// Defaults to `true`.
acceptRanges: true,
})
```

##### Strong ETags and Content Hashing

For assets that require strong validation (e.g., to support [`If-Match`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Match) preconditions or [`If-Range`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Range) with [`Range` requests](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range)), configure strong ETag generation:

```ts
return res.file(file, request, {
etag: 'strong',
})
```

By default, strong ETags are generated using [`SubtleCrypto.digest()`](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest) with the `'SHA-256'` algorithm. You can customize this:

```ts
return res.file(file, request, {
etag: 'strong',

// Specify a different SubtleCrypto.digest() algorithm
digest: 'SHA-512',
})
```

Or provide a custom digest function:

```ts
return res.file(file, request, {
etag: 'strong',

// Custom digest function
digest: async (file) => {
let buffer = await file.arrayBuffer()
return customHash(buffer)
},
})
```

#### Using `findFile()` with `file()`

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:

```ts
import * as res from '@remix-run/fetch-router/response-helpers'
import { findFile } from '@remix-run/lazy-file/fs'

router.get('/assets/*path', async ({ request, params }) => {
let file = await findFile('./public/assets', params.path)

if (!file) {
return new Response('Not Found', { status: 404 })
}

return res.file(file, request, {
cacheControl: 'public, max-age=3600',
})
})
```

#### The `staticFiles()` Middleware

For convenience, the `staticFiles()` middleware combines `findFile()` and `file()` into a single middleware, resolving files based on the request pathname:

```ts
import { createRouter } from '@remix-run/fetch-router'
import { staticFiles } from '@remix-run/fetch-router/static-middleware'

let router = createRouter({
middleware: [staticFiles('./public')],
})
```

The middleware accepts the same options as the `file()` response helper:

```ts
let router = createRouter({
middleware: [
staticFiles('./public', {
cacheControl: 'public, max-age=3600',
etag: 'strong',
}),
],
})
```

You can provide a `filter` function to determine which files to serve:

```ts
staticFiles('./images', {
filter: (path) => /\.(png|jpg|gif|svg)$/i.test(path),
})
```

### Testing

Testing is straightforward because `fetch-router` uses the standard `fetch()` API:
Expand Down
7 changes: 7 additions & 0 deletions packages/fetch-router/global.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// See https://github.com/DefinitelyTyped/DefinitelyTyped/discussions/62651

interface ReadableStream<R = any> {
values(options?: { preventCancel?: boolean }): AsyncIterableIterator<R>
[Symbol.asyncIterator](): AsyncIterableIterator<R>
}

6 changes: 6 additions & 0 deletions packages/fetch-router/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"./method-override-middleware": "./src/method-override-middleware.ts",
"./response-helpers": "./src/response-helpers.ts",
"./session-middleware": "./src/session-middleware.ts",
"./static-middleware": "./src/static-middleware.ts",
"./package.json": "./package.json"
},
"publishConfig": {
Expand Down Expand Up @@ -59,6 +60,10 @@
"types": "./dist/session-middleware.d.ts",
"default": "./dist/session-middleware.js"
},
"./static-middleware": {
"types": "./dist/static-middleware.d.ts",
"default": "./dist/static-middleware.js"
},
"./package.json": "./package.json"
}
},
Expand All @@ -73,6 +78,7 @@
"@remix-run/form-data-parser": "workspace:^",
"@remix-run/headers": "workspace:^",
"@remix-run/html-template": "workspace:^",
"@remix-run/lazy-file": "workspace:^",
"@remix-run/route-pattern": "workspace:^",
"@remix-run/session": "workspace:^"
},
Expand Down
Loading