From 7b1b1271e887c39696cb658552553528912afe5e Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Fri, 31 Oct 2025 23:57:58 +1100 Subject: [PATCH 01/19] Add static-middleware to fetch-router --- AGENTS.md | 2 + demos/bookstore/app/public.ts | 40 - demos/bookstore/app/router.ts | 49 +- demos/bookstore/public/root/favicon.ico | Bin 0 -> 16958 bytes packages/fetch-router/README.md | 57 +- packages/fetch-router/package.json | 23 +- packages/fetch-router/src/file-handler.ts | 3 + packages/fetch-router/src/fs-file-resolver.ts | 3 + .../fetch-router/src/lib/file-handler.test.ts | 1162 +++++++++++++++++ packages/fetch-router/src/lib/file-handler.ts | 406 ++++++ .../src/lib/fs-file-resolver.test.ts | 191 +++ .../fetch-router/src/lib/fs-file-resolver.ts | 67 + .../src/lib/middleware/static.test.ts | 451 +++++++ .../fetch-router/src/lib/middleware/static.ts | 72 + packages/fetch-router/src/lib/router.ts | 89 +- .../fetch-router/src/static-middleware.ts | 2 + packages/headers/README.md | 28 + .../headers/src/lib/content-range.test.ts | 153 +++ packages/headers/src/lib/content-range.ts | 66 + .../headers/src/lib/super-headers.test.ts | 43 + packages/headers/src/lib/super-headers.ts | 26 +- pnpm-lock.yaml | 3 + 22 files changed, 2852 insertions(+), 84 deletions(-) delete mode 100644 demos/bookstore/app/public.ts create mode 100644 demos/bookstore/public/root/favicon.ico create mode 100644 packages/fetch-router/src/file-handler.ts create mode 100644 packages/fetch-router/src/fs-file-resolver.ts create mode 100644 packages/fetch-router/src/lib/file-handler.test.ts create mode 100644 packages/fetch-router/src/lib/file-handler.ts create mode 100644 packages/fetch-router/src/lib/fs-file-resolver.test.ts create mode 100644 packages/fetch-router/src/lib/fs-file-resolver.ts create mode 100644 packages/fetch-router/src/lib/middleware/static.test.ts create mode 100644 packages/fetch-router/src/lib/middleware/static.ts create mode 100644 packages/fetch-router/src/static-middleware.ts create mode 100644 packages/headers/src/lib/content-range.test.ts create mode 100644 packages/headers/src/lib/content-range.ts diff --git a/AGENTS.md b/AGENTS.md index 60e0d0b1d37..7779397daa9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -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`) diff --git a/demos/bookstore/app/public.ts b/demos/bookstore/app/public.ts deleted file mode 100644 index d75b5768dbe..00000000000 --- a/demos/bookstore/app/public.ts +++ /dev/null @@ -1,40 +0,0 @@ -import * as path from 'node:path' -import type { InferRouteHandler } from '@remix-run/fetch-router' -import { openFile } from '@remix-run/lazy-file/fs' - -import { routes } from '../routes.ts' - -const publicDir = path.join(import.meta.dirname, '..', 'public') -const publicAssetsDir = path.join(publicDir, 'assets') -const publicImagesDir = path.join(publicDir, 'images') - -export let assets: InferRouteHandler = async ({ params }) => { - return serveFile(path.join(publicAssetsDir, params.path)) -} - -export let images: InferRouteHandler = async ({ params }) => { - return serveFile(path.join(publicImagesDir, params.path)) -} - -function serveFile(filename: string): Response { - try { - let file = openFile(filename) - - return new Response(file, { - headers: { - 'Cache-Control': 'no-store, must-revalidate', - 'Content-Type': file.type, - }, - }) - } catch (error) { - if (isNoEntityError(error)) { - return new Response('Not found', { status: 404 }) - } - - throw error - } -} - -function isNoEntityError(error: unknown): error is NodeJS.ErrnoException & { code: 'ENOENT' } { - return error instanceof Error && 'code' in error && error.code === 'ENOENT' -} diff --git a/demos/bookstore/app/router.ts b/demos/bookstore/app/router.ts index bcbd3fd99ad..38c2fcf5c1b 100644 --- a/demos/bookstore/app/router.ts +++ b/demos/bookstore/app/router.ts @@ -1,5 +1,6 @@ import { createRouter } from '@remix-run/fetch-router' import { logger } from '@remix-run/fetch-router/logger-middleware' +import { staticFiles } from '@remix-run/fetch-router/static-middleware' import { routes } from '../routes.ts' import { storeContext } from './middleware/context.ts' @@ -12,7 +13,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' @@ -24,8 +24,45 @@ if (process.env.NODE_ENV === 'development') { router.use(logger()) } -router.get(routes.assets, publicHandlers.assets) -router.get(routes.images, publicHandlers.images) +router.use( + staticFiles('./public/root', { + cacheControl: 'no-store, must-revalidate', + etag: false, + lastModified: false, + acceptRanges: false, + }), +) + +router.get(routes.assets, { + use: [ + staticFiles('./public/assets', { + path: ({ params }) => params.path, + cacheControl: 'no-store, must-revalidate', + etag: false, + lastModified: false, + acceptRanges: false, + }), + ], + handler() { + return new Response('Not Found', { status: 404 }) + }, +}) + +router.get(routes.images, { + use: [ + staticFiles('./public/images', { + path: ({ params }) => params.path, + cacheControl: 'no-store, must-revalidate', + etag: false, + lastModified: false, + acceptRanges: false, + }), + ], + handler() { + return new Response('Not Found', { status: 404 }) + }, +}) + router.get(routes.uploads, uploadsHandler) router.map(routes.home, marketingHandlers.home) @@ -41,3 +78,9 @@ router.map(routes.cart, cartHandlers) router.map(routes.account, accountHandlers) router.map(routes.checkout, checkoutHandlers) router.map(routes.admin, adminHandlers) + +// NOTE: This is needed for the root static file middleware to work. This won't +// be needed if middleware is run against fetch-router's default handler. +router.get('/*', () => { + return new Response('Not Found', { status: 404 }) +}) diff --git a/demos/bookstore/public/root/favicon.ico b/demos/bookstore/public/root/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..8830cf6821b354114848e6354889b8ecf6d2bc61 GIT binary patch literal 16958 zcmeI3+jCXb9mnJN2h^uNlXH@jlam{_a8F3W{T}Wih>9YJpaf7TUbu)A5fv|h7OMfR zR;q$lr&D!wv|c)`wcw1?>4QT1(&|jdsrI2h`Rn)dTW5t$8pz=s3_5L?#oBxAowe8R z_WfPfN?F+@`q$D@rvC?(W!uWieppskmQ~YG*>*L?{img@tWpnYXZslxeh#TSUS3{q z1Ju6JcfQSbQuORq69@YK(X-3c9vC2c2a2z~zw=F=50@pm0PUiCAm!bAT?2jpM`(^b zC|2&Ngngt^<>oCv#?P(AZ`5_84x#QBPulix)TpkIAUp=(KgGo4CVS~Sxt zVoR4>r5g9%bDh7hi0|v$={zr>CHd`?-l4^Ld(Z9PNz9piFY+llUw_x4ou7Vf-q%$g z)&)J4>6Ft~RZ(uV>dJD|`nxI1^x{X@Z5S<=vf;V3w_(*O-7}W<=e$=}CB9_R;)m9)d7`d_xx+nl^Bg|%ew=?uoKO8w zeQU7h;~8s!@9-k>7Cx}1SDQ7m(&miH zs8!l*wOJ!GHbdh)pD--&W3+w`9YJ=;m^FtMY=`mTq8pyV!-@L6smwp3(q?G>=_4v^ zn(ikLue7!y70#2uhqUVpb7fp!=xu2{aM^1P^pts#+feZv8d~)2sf`sjXLQCEj;pdI z%~f`JOO;*KnziMv^i_6+?mL?^wrE_&=IT9o1i!}Sd4Sx4O@w~1bi1)8(sXvYR-1?7~Zr<=SJ1Cw!i~yfi=4h6o3O~(-Sb2Ilwq%g$+V` z>(C&N1!FV5rWF&iwt8~b)=jIn4b!XbrWrZgIHTISrdHcpjjx=TwJXI7_%Ks4oFLl9 zNT;!%!P4~xH85njXdfqgnIxIFOOKW`W$fxU%{{5wZkVF^G=JB$oUNU5dQSL&ZnR1s z*ckJ$R`eCUJsWL>j6*+|2S1TL_J|Fl&kt=~XZF=+=iT0Xq1*KU-NuH%NAQff$LJp3 zU_*a;@7I0K{mqwux87~vwsp<}@P>KNDb}3U+6$rcZ114|QTMUSk+rhPA(b{$>pQTc zIQri{+U>GMzsCy0Mo4BfWXJlkk;RhfpWpAB{=Rtr*d1MNC+H3Oi5+3D$gUI&AjV-1 z=0ZOox+bGyHe=yk-yu%=+{~&46C$ut^ZN+ysx$NH}*F43)3bKkMsxGyIl#>7Yb8W zO{}&LUO8Ow{7>!bvSq?X{15&Y|4}0w2=o_^0ZzYgB+4HhZ4>s*mW&?RQ6&AY|CPcx z$*LjftNS|H)ePYnIKNg{ck*|y7EJ&Co0ho0K`!{ENPkASeKy-JWE}dF_%}j)Z5a&q zXAI2gPu6`s-@baW=*+keiE$ALIs5G6_X_6kgKK8n3jH2-H9`6bo)Qn1 zZ2x)xPt1=`9V|bE4*;j9$X20+xQCc$rEK|9OwH-O+Q*k`ZNw}K##SkY z3u}aCV%V|j@!gL5(*5fuWo>JFjeU9Qqk`$bdwH8(qZovE2tA7WUpoCE=VKm^eZ|vZ z(k<+j*mGJVah>8CkAsMD6#I$RtF;#57Wi`c_^k5?+KCmX$;Ky2*6|Q^bJ8+s%2MB}OH-g$Ev^ zO3uqfGjuN%CZiu<`aCuKCh{kK!dDZ+CcwgIeU2dsDfz+V>V3BDb~)~ zO!2l!_)m;ZepR~sL+-~sHS7;5ZB|~uUM&&5vDda2b z)CW8S6GI*oF><|ZeY5D^+Mcsri)!tmrM33qvwI4r9o@(GlW!u2R>>sB|E#%W`c*@5 z|0iA|`{6aA7D4Q?vc1{vT-#yytn07`H!QIO^1+X7?zG3%y0gPdIPUJ#s*DNAwd}m1_IMN1^T&be~+E z_z%1W^9~dl|Me9U6+3oNyuMDkF*z_;dOG(Baa*yq;TRiw{EO~O_S6>e*L(+Cdu(TM z@o%xTCV%hi&p)x3_inIF!b|W4|AF5p?y1j)cr9RG@v%QVaN8&LaorC-kJz_ExfVHB za!mtuee#Vb?dh&bwrfGHYAiX&&|v$}U*UBM;#F!N=x>x|G5s0zOa9{(`=k4v^6iK3 z8d&=O@xhDs{;v7JQ%eO;!Bt`&*MH&d zp^K#dkq;jnJz%%bsqwlaKA5?fy zS5JDbO#BgSAdi8NM zDo2SifX6^Z;vn>cBh-?~r_n9qYvP|3ihrnqq6deS-#>l#dV4mX|G%L8|EL;$U+w69 z;rTK3FW$ewUfH|R-Z;3;jvpfiDm?Fvyu9PeR>wi|E8>&j2Z@2h`U}|$>2d`BPV3pz#ViIzH8v6pP^L-p!GbLv<;(p>}_6u&E6XO5- zJ8JEvJ1)0>{iSd|kOQn#?0rTYL=KSmgMHCf$Qbm;7|8d(goD&T-~oCDuZf57iP#_Y zmxaoOSjQsm*^u+m$L9AMqwi=6bpdiAY6k3akjGN{xOZ`_J<~Puyzpi7yhhKrLmXV; z@ftONPy;Uw1F#{_fyGbk04yLE01v=i_5`RqQP+SUH0nb=O?l!J)qCSTdsbmjFJrTm zx4^ef@qt{B+TV_OHOhtR?XT}1Etm(f21;#qyyW6FpnM+S7*M1iME?9fe8d-`Q#InN z?^y{C_|8bxgUE@!o+Z72C)BrS&5D`gb-X8kq*1G7Uld-z19V}HY~mK#!o9MC-*#^+ znEsdc-|jj0+%cgBMy(cEkq4IQ1D*b;17Lyp>Utnsz%LRTfjQKL*vo(yJxwtw^)l|! z7jhIDdtLB}mpkOIG&4@F+9cYkS5r%%jz}I0R#F4oBMf-|Jmmk* zk^OEzF%}%5{a~kGYbFjV1n>HKC+a`;&-n*v_kD2DPP~n5(QE3C;30L<32GB*qV2z$ zWR1Kh=^1-q)P37WS6YWKlUSDe=eD^u_CV+P)q!3^{=$#b^auGS7m8zFfFS<>(e~)TG z&uwWhSoetoe!1^%)O}=6{SUcw-UQmw+i8lokRASPsbT=H|4D|( zk^P7>TUEFho!3qXSWn$m2{lHXw zD>eN6-;wwq9(?@f^F4L2Ny5_6!d~iiA^s~(|B*lbZir-$&%)l>%Q(36yOIAu|326K ztmBWz|MLA{Kj(H_{w2gd*nZ6a@ma(w==~EHIscEk|C=NGJa%Ruh4_+~f|%rt{I5v* zIX@F?|KJID56-ivb+PLo(9hn_CdK{irOcL15>JNQFY112^$+}JPyI{uQ~$&E*=ri; z`d^fH?4f=8vKHT4!p9O*fX(brB75Y9?e>T9=X#Fc@V#%@5^)~#zu5I(=>LQA-EGTS zecy*#6gG+8lapch#Hh%vl(+}J;Q!hC1OKoo;#h3#V%5Js)tQ)|>pTT@1ojd+F9Gey zg`B)zm`|Mo%tH31s4=<+`Pu|B3orXwNyIcNN>;fBkIj^X8P}RXhF= zXQK1u5RLN7k#_Q(KznJrALtMM13!vhfr025ar?@-%{l|uWt@NEd<$~n>RQL{ z+o;->n)+~0tt(u|o_9h!T`%M8%)w2awpV9b*xz9Pl-daUJm3y-HT%xg`^mFd6LBeL z!0~s;zEr)Bn9x)I(wx`;JVwvRcc^io2XX(Nn3vr3dgbrr@YJ?K3w18P*52^ieBCQP z=Up1V$N2~5ppJHRTeY8QfM(7Yv&RG7oWJAyv?c3g(29)P)u;_o&w|&)HGDIinXT~p z3;S|e$=&Tek9Wn!`cdY+d-w@o`37}x{(hl>ykB|%9yB$CGdIcl7Z?d&lJ%}QHck77 zJPR%C+s2w1_Dl_pxu6$Zi!`HmoD-%7OD@7%lKLL^Ixd9VlRSW*o&$^iQ2z+}hTgH) z#91TO#+jH<`w4L}XWOt(`gqM*uTUcky`O(mEyU|4dJoy6*UZJ7%*}ajuos%~>&P2j zk23f5<@GeV?(?`l=ih+D8t`d72xrUjv0wsg;%s1@*2p?TQ;n2$pV7h?_T%sL>iL@w zZ{lmc<|B7!e&o!zs6RW+u8+aDyUdG>ZS(v&rT$QVymB7sEC@VsK1dg^3F@K90-wYB zX!we79qx`(6LA>F$~{{xE8-3Wzyfe`+Lsce(?uj{k@lb97YTJt#>l*Z&LyKX@zjmu?UJC9w~;|NsB{%7G}y*uNDBxirfC EKbET!0{{R3 literal 0 HcmV?d00001 diff --git a/packages/fetch-router/README.md b/packages/fetch-router/README.md index 5fd6b946988..7c59c901ab1 100644 --- a/packages/fetch-router/README.md +++ b/packages/fetch-router/README.md @@ -308,6 +308,58 @@ router.map(routes.admin, [authenticate, requireAdmin], { Middleware defined in `router.map()` cascades to all nested routes, giving you fine-grained control over which routes get which middleware. +### Serving Static Files with `staticFiles()` Middleware + +The `staticFiles()` middleware serves static files from the filesystem. The middleware always falls through to the handler if the file is not found, allowing you to customize the 404 response. + +```ts +router.get('/*', { + use: [staticFiles('./public')], + handler() { + return new Response('Not Found', { status: 404 }) + }, +}) +``` + +By default, this middleware uses the full request pathname to resolve files. You can customize this by providing a path resolver function which is passed the request context. For example, to resolve files based on a route param: + +```ts +router.get('/assets/*path', { + use: [ + staticFiles('./public', { + path: ({ params }) => params.path, + }), + ], + handler() { + return new Response('Not Found', { status: 404 }) + }, +}) +``` + +You can further customize the behavior of this middleware by providing additional options: + +```ts +router.get('/*', { + use: [ + staticFiles('./public', { + cacheControl: 'public, max-age=3600', + // Whether to support HTTP Range requests for partial content. + // Defaults to `true`. + acceptRanges: false, + // Whether to generate a weak ETag header for the response. + // Defaults to `true`. + etag: false, + // Whether to generate a `Last-Modified` header for the response. + // Defaults to `true`. + lastModified: false, + }), + ], + handler() { + return new Response('Not Found', { status: 404 }) + }, +}) +``` + ### Nested Routers Compose routers to organize large applications: @@ -439,7 +491,7 @@ No special test harness or mocking required - just use `fetch()` like you would Here's a complete example showing many features working together: -```ts +````ts import { createRoutes, createRouter, html } from '@remix-run/fetch-router' // Define all routes upfront @@ -574,8 +626,7 @@ export { router } // import { router } from "./router" // export default async function handler(req: Request) { return router.fetch(req) } // ``` -``` - +```` ## Related Work diff --git a/packages/fetch-router/package.json b/packages/fetch-router/package.json index 5ebea581801..70a3f79f979 100644 --- a/packages/fetch-router/package.json +++ b/packages/fetch-router/package.json @@ -21,7 +21,10 @@ "type": "module", "exports": { ".": "./src/index.ts", + "./file-handler": "./src/file-handler.ts", + "./fs-file-resolver": "./src/fs-file-resolver.ts", "./logger-middleware": "./src/logger-middleware.ts", + "./static-middleware": "./src/static-middleware.ts", "./package.json": "./package.json" }, "publishConfig": { @@ -30,10 +33,22 @@ "types": "./dist/index.d.ts", "default": "./dist/index.js" }, + "./file-handler": { + "types": "./dist/file-handler.d.ts", + "default": "./dist/file-handler.js" + }, + "./fs-file-resolver": { + "types": "./dist/fs-file-resolver.d.ts", + "default": "./dist/fs-file-resolver.js" + }, "./logger-middleware": { "types": "./dist/logger-middleware.d.ts", "default": "./dist/logger-middleware.js" }, + "./static-middleware": { + "types": "./dist/static-middleware.d.ts", + "default": "./dist/static-middleware.js" + }, "./package.json": "./package.json" } }, @@ -44,12 +59,16 @@ "dependencies": { "@remix-run/form-data-parser": "workspace:*", "@remix-run/route-pattern": "workspace:*", - "@remix-run/headers": "workspace:*" + "@remix-run/headers": "workspace:*", + "@remix-run/lazy-file": "workspace:*" }, "scripts": { - "build": "pnpm run clean && pnpm run build:types && pnpm run build:index && pnpm run build:logger-middleware", + "build": "pnpm run clean && pnpm run build:types && pnpm run build:index && pnpm run build:file-handler && pnpm run build:fs-file-resolver && pnpm run build:logger-middleware && pnpm run build:static-middleware", "build:index": "esbuild src/index.ts --bundle --outfile=dist/index.js --format=esm --platform=neutral --sourcemap", + "build:file-handler": "esbuild src/file-handler.ts --bundle --outfile=dist/file-handler.js --format=esm --platform=neutral --sourcemap", + "build:fs-file-resolver": "esbuild src/fs-file-resolver.ts --bundle --outfile=dist/fs-file-resolver.js --format=esm --platform=node --sourcemap", "build:logger-middleware": "esbuild src/logger-middleware.ts --bundle --outfile=dist/logger-middleware.js --format=esm --platform=neutral --sourcemap", + "build:static-middleware": "esbuild src/static-middleware.ts --bundle --outfile=dist/static-middleware.js --format=esm --platform=node --sourcemap", "build:types": "tsc --project tsconfig.build.json", "clean": "rm -rf dist", "prepublishOnly": "pnpm run build", diff --git a/packages/fetch-router/src/file-handler.ts b/packages/fetch-router/src/file-handler.ts new file mode 100644 index 00000000000..26b52a91f34 --- /dev/null +++ b/packages/fetch-router/src/file-handler.ts @@ -0,0 +1,3 @@ +export type { FileResolver, FileHandlerOptions } from './lib/file-handler.ts' +export { createFileHandler } from './lib/file-handler.ts' + diff --git a/packages/fetch-router/src/fs-file-resolver.ts b/packages/fetch-router/src/fs-file-resolver.ts new file mode 100644 index 00000000000..5bfcee518f6 --- /dev/null +++ b/packages/fetch-router/src/fs-file-resolver.ts @@ -0,0 +1,3 @@ +export type { PathResolver } from './lib/fs-file-resolver.ts' +export { createFsFileResolver } from './lib/fs-file-resolver.ts' + diff --git a/packages/fetch-router/src/lib/file-handler.test.ts b/packages/fetch-router/src/lib/file-handler.test.ts new file mode 100644 index 00000000000..8085a599805 --- /dev/null +++ b/packages/fetch-router/src/lib/file-handler.test.ts @@ -0,0 +1,1162 @@ +import * as assert from 'node:assert/strict' +import { describe, it, mock } from 'node:test' +import SuperHeaders, { type SuperHeadersInit } from '@remix-run/headers' + +import { createFileHandler } from './file-handler.ts' +import type { RequestContext } from './request-context.ts' +import type { RequestMethod } from './request-methods.ts' +import { AppStorage } from './app-storage.ts' + +describe('createFileHandler', () => { + function createMockFile( + content: string, + options: { + type?: string + lastModified?: number + } = {}, + ): File { + return new File([content], 'mock.txt', { + type: options.type || 'text/plain', + lastModified: options.lastModified || Date.now(), + }) + } + + function createContext( + url: string, + options: { + method?: RequestMethod + headers?: SuperHeadersInit + } = {}, + ): RequestContext<'GET', {}> { + let headers = new SuperHeaders(options.headers ?? {}) + return { + formData: undefined, + storage: new AppStorage(), + url: new URL(url), + files: null, + method: 'GET', + request: new Request(url, { + method: options.method || 'GET', + headers, + }), + params: {}, + headers, + } + } + + describe('basic functionality', () => { + it('serves a file', async () => { + let file = createMockFile('Hello, World!') + let handler = createFileHandler(() => file) + + let response = await handler(createContext('http://localhost/test.txt')) + + assert.equal(response.status, 200) + assert.equal(await response.text(), 'Hello, World!') + assert.equal(response.headers.get('Content-Type'), 'text/plain') + assert.equal(response.headers.get('Content-Length'), '13') + }) + + it('serves a file with HEAD request', async () => { + let file = createMockFile('Hello, World!') + let handler = createFileHandler(() => file) + + let response = await handler(createContext('http://localhost/test.txt', { method: 'HEAD' })) + + assert.equal(response.status, 200) + assert.equal(await response.text(), '') + assert.equal(response.headers.get('Content-Type'), 'text/plain') + assert.equal(response.headers.get('Content-Length'), '13') + }) + + it('returns 404 when file resolver returns null', async () => { + let handler = createFileHandler(() => null) + + let response = await handler(createContext('http://localhost/test.txt')) + + assert.equal(response.status, 404) + assert.equal(await response.text(), 'Not Found') + }) + + it('returns 405 for unsupported methods', async () => { + let file = createMockFile('Hello, World!') + let handler = createFileHandler(() => file) + + let response = await handler(createContext('http://localhost/test.txt', { method: 'POST' })) + + assert.equal(response.status, 405) + assert.equal(await response.text(), 'Method Not Allowed') + }) + + it('passes request context to file resolver', async () => { + let file = createMockFile('Hello, World!') + let mockFileResolver = mock.fn((_context: RequestContext<'GET', {}>) => file) + let handler = createFileHandler(mockFileResolver) + + let response = await handler(createContext('http://localhost/test.txt', { method: 'GET' })) + + assert.equal(response.status, 200) + assert.equal(await response.text(), 'Hello, World!') + assert.equal(mockFileResolver.mock.callCount(), 1) + assert.equal(mockFileResolver.mock.calls[0].arguments[0].method, 'GET') + assert.equal( + mockFileResolver.mock.calls[0].arguments[0].request.url, + 'http://localhost/test.txt', + ) + }) + }) + + describe('ETag support', () => { + for (let method of ['GET', 'HEAD'] as const) { + describe(method, () => { + it('includes ETag header', async () => { + let file = createMockFile('Hello, World!', { lastModified: 1000000 }) + let handler = createFileHandler(() => file) + + let response = await handler(createContext('http://localhost/test.txt', { method })) + + let etag = response.headers.get('ETag') + assert.equal(response.status, 200) + assert.ok(etag) + assert.match(etag, /^W\/"[\d]+-[\d]+\.?[\d]*"$/) + }) + + it('does not include ETag when etag=false', async () => { + let file = createMockFile('Hello, World!') + let handler = createFileHandler(() => file, { etag: false }) + + let response = await handler(createContext('http://localhost/test.txt', { method })) + + assert.equal(response.status, 200) + assert.equal(response.headers.get('ETag'), null) + }) + }) + } + }) + + describe('If-None-Match support', () => { + for (let method of ['GET', 'HEAD'] as const) { + describe(method, () => { + it('returns 304 (Not Modified) when If-None-Match matches ETag', async () => { + let file = createMockFile('Hello, World!', { lastModified: 1000000 }) + let handler = createFileHandler(() => file) + + let response1 = await handler(createContext('http://localhost/test.txt', { method })) + let etag = response1.headers.get('ETag') + assert.ok(etag) + + let response2 = await handler( + createContext('http://localhost/test.txt', { + method, + headers: { 'If-None-Match': etag }, + }), + ) + + assert.equal(response2.status, 304) + assert.equal(await response2.text(), '') + }) + + it('returns 304 (Not Modified) when If-None-Match is *', async () => { + let file = createMockFile('Hello, World!') + let handler = createFileHandler(() => file) + + let response = await handler( + createContext('http://localhost/test.txt', { + method, + headers: { 'If-None-Match': '*' }, + }), + ) + + assert.equal(response.status, 304) + }) + + it('returns 200 (OK) when If-None-Match does not match', async () => { + let file = createMockFile('Hello, World!') + let handler = createFileHandler(() => file) + + let response = await handler( + createContext('http://localhost/test.txt', { + method, + headers: { 'If-None-Match': 'W/"wrong-etag"' }, + }), + ) + + assert.equal(response.status, 200) + assert.equal(await response.text(), method === 'HEAD' ? '' : 'Hello, World!') + }) + + it('handles multiple ETags in If-None-Match', async () => { + let file = createMockFile('Hello, World!', { lastModified: 1000000 }) + let handler = createFileHandler(() => file) + + let response1 = await handler(createContext('http://localhost/test.txt', { method })) + let etag = response1.headers.get('ETag') + assert.ok(etag) + + let response2 = await handler( + createContext('http://localhost/test.txt', { + method, + headers: { 'If-None-Match': `W/"wrong-1", ${etag}, W/"wrong-2"` }, + }), + ) + + assert.equal(response2.status, 304) + }) + }) + } + }) + + describe('If-Match support', () => { + for (let method of ['GET', 'HEAD'] as const) { + describe(method, () => { + it('returns 200 (OK) when If-Match matches ETag', async () => { + let file = createMockFile('Hello, World!', { lastModified: 1000000 }) + let handler = createFileHandler(() => file) + + let response1 = await handler(createContext('http://localhost/test.txt', { method })) + let etag = response1.headers.get('ETag') + assert.ok(etag) + + let response2 = await handler( + createContext('http://localhost/test.txt', { + method, + headers: { 'If-Match': etag }, + }), + ) + + assert.equal(response2.status, 200) + assert.equal(await response2.text(), method === 'HEAD' ? '' : 'Hello, World!') + }) + + it('returns 412 (Precondition Failed) when If-Match does not match', async () => { + let file = createMockFile('Hello, World!') + let handler = createFileHandler(() => file) + + let response = await handler( + createContext('http://localhost/test.txt', { + method, + headers: { 'If-Match': 'W/"wrong-etag"' }, + }), + ) + + assert.equal(response.status, 412) + }) + + it('returns 200 (OK) when If-Match is *', async () => { + let file = createMockFile('Hello, World!') + let handler = createFileHandler(() => file) + + let response = await handler( + createContext('http://localhost/test.txt', { + method, + headers: { 'If-Match': '*' }, + }), + ) + + assert.equal(response.status, 200) + assert.equal(await response.text(), method === 'HEAD' ? '' : 'Hello, World!') + }) + + it('returns 200 (OK) when If-Match contains multiple ETags and one matches', async () => { + let file = createMockFile('Hello, World!', { lastModified: 1000000 }) + let handler = createFileHandler(() => file) + + let response1 = await handler(createContext('http://localhost/test.txt', { method })) + let etag = response1.headers.get('ETag') + assert.ok(etag) + + let response2 = await handler( + createContext('http://localhost/test.txt', { + method, + headers: { 'If-Match': `W/"wrong-1", ${etag}, W/"wrong-2"` }, + }), + ) + + assert.equal(response2.status, 200) + assert.equal(await response2.text(), method === 'HEAD' ? '' : 'Hello, World!') + }) + + it('returns 412 (Precondition Failed) when If-Match contains multiple ETags and none match', async () => { + let file = createMockFile('Hello, World!') + let handler = createFileHandler(() => file) + + let response = await handler( + createContext('http://localhost/test.txt', { + method, + headers: { 'If-Match': 'W/"wrong-1", W/"wrong-2"' }, + }), + ) + + assert.equal(response.status, 412) + }) + + it('returns 412 (Precondition Failed) when If-Match fails, even if If-None-Match would match', async () => { + let file = createMockFile('Hello, World!', { lastModified: 1000000 }) + let handler = createFileHandler(() => file) + + let response1 = await handler(createContext('http://localhost/test.txt', { method })) + let etag = response1.headers.get('ETag') + assert.ok(etag) + + let response2 = await handler( + createContext('http://localhost/test.txt', { + method, + headers: { + 'If-Match': 'W/"wrong-etag"', + 'If-None-Match': etag, + }, + }), + ) + + assert.equal(response2.status, 412) + }) + + it('ignores If-Match when etag is disabled', async () => { + let file = createMockFile('Hello, World!') + let handler = createFileHandler(() => file, { etag: false }) + + let response = await handler( + createContext('http://localhost/test.txt', { + method, + headers: { 'If-Match': 'W/"any-etag"' }, + }), + ) + + assert.equal(response.status, 200) + assert.equal(await response.text(), method === 'HEAD' ? '' : 'Hello, World!') + }) + }) + } + }) + + describe('If-Unmodified-Since support', () => { + for (let method of ['GET', 'HEAD'] as const) { + describe(method, () => { + it('returns 200 (OK) when If-Unmodified-Since is after Last-Modified', async () => { + let fileDate = new Date('2025-01-01') + let futureDate = new Date('2026-01-01') + let file = createMockFile('Hello, World!', { lastModified: fileDate.getTime() }) + let handler = createFileHandler(() => file) + + let response = await handler( + createContext('http://localhost/test.txt', { + method, + headers: { 'If-Unmodified-Since': futureDate.toUTCString() }, + }), + ) + + assert.equal(response.status, 200) + assert.equal(await response.text(), method === 'HEAD' ? '' : 'Hello, World!') + }) + + it('returns 200 (OK) when If-Unmodified-Since matches Last-Modified', async () => { + let fileDate = new Date('2025-01-01') + let file = createMockFile('Hello, World!', { lastModified: fileDate.getTime() }) + let handler = createFileHandler(() => file) + + let response = await handler( + createContext('http://localhost/test.txt', { + method, + headers: { 'If-Unmodified-Since': fileDate.toUTCString() }, + }), + ) + + assert.equal(response.status, 200) + assert.equal(await response.text(), method === 'HEAD' ? '' : 'Hello, World!') + }) + + it('returns 412 (Precondition Failed) when If-Unmodified-Since is before Last-Modified', async () => { + let fileDate = new Date('2025-01-01') + let pastDate = new Date('2024-01-01') + let file = createMockFile('Hello, World!', { lastModified: fileDate.getTime() }) + let handler = createFileHandler(() => file) + + let response = await handler( + createContext('http://localhost/test.txt', { + method, + headers: { 'If-Unmodified-Since': pastDate.toUTCString() }, + }), + ) + + assert.equal(response.status, 412) + }) + + it('ignores If-Unmodified-Since when If-Match is present', async () => { + let fileDate = new Date('2025-01-01') + let pastDate = new Date('2024-01-01') + let file = createMockFile('Hello, World!', { lastModified: fileDate.getTime() }) + let handler = createFileHandler(() => file) + + let response1 = await handler(createContext('http://localhost/test.txt', { method })) + let etag = response1.headers.get('ETag') + assert.ok(etag) + + let response2 = await handler( + createContext('http://localhost/test.txt', { + method, + headers: { + 'If-Match': etag, + 'If-Unmodified-Since': pastDate.toUTCString(), + }, + }), + ) + + assert.equal(response2.status, 200) + assert.equal(await response2.text(), method === 'HEAD' ? '' : 'Hello, World!') + }) + + it('returns 412 (Precondition Failed) when If-Match fails, even if If-Unmodified-Since would pass', async () => { + let fileDate = new Date('2025-01-01') + let futureDate = new Date('2026-01-01') + let file = createMockFile('Hello, World!', { lastModified: fileDate.getTime() }) + let handler = createFileHandler(() => file) + + let response = await handler( + createContext('http://localhost/test.txt', { + method, + headers: { + 'If-Match': 'W/"wrong-etag"', + 'If-Unmodified-Since': futureDate.toUTCString(), + }, + }), + ) + + assert.equal(response.status, 412) + }) + + it('ignores If-Unmodified-Since when lastModified is disabled', async () => { + let pastDate = new Date('2024-01-01') + let file = createMockFile('Hello, World!') + let handler = createFileHandler(() => file, { lastModified: false }) + + let response = await handler( + createContext('http://localhost/test.txt', { + method, + headers: { 'If-Unmodified-Since': pastDate.toUTCString() }, + }), + ) + + assert.equal(response.status, 200) + assert.equal(await response.text(), method === 'HEAD' ? '' : 'Hello, World!') + }) + + it('ignores malformed If-Unmodified-Since', async () => { + let fileDate = new Date('2025-01-01') + let file = createMockFile('Hello, World!', { lastModified: fileDate.getTime() }) + let handler = createFileHandler(() => file) + + let response = await handler( + createContext('http://localhost/test.txt', { + method, + headers: { 'If-Unmodified-Since': 'invalid-date' }, + }), + ) + + assert.equal(response.status, 200) + assert.equal(await response.text(), method === 'HEAD' ? '' : 'Hello, World!') + }) + }) + } + }) + + describe('Last-Modified support', () => { + for (let method of ['GET', 'HEAD'] as const) { + describe(method, () => { + it('includes Last-Modified header', async () => { + let fileDate = new Date('2025-01-01') + let file = createMockFile('Hello, World!', { lastModified: fileDate.getTime() }) + let handler = createFileHandler(() => file) + + let response = await handler(createContext('http://localhost/test.txt', { method })) + + assert.equal(response.status, 200) + assert.equal(response.headers.get('Last-Modified'), fileDate.toUTCString()) + }) + + it('does not include Last-Modified when lastModified=false', async () => { + let file = createMockFile('Hello, World!') + let handler = createFileHandler(() => file, { lastModified: false }) + + let response = await handler(createContext('http://localhost/test.txt', { method })) + + assert.equal(response.status, 200) + assert.equal(response.headers.get('Last-Modified'), null) + }) + + it('returns 304 (Not Modified) when If-Modified-Since matches Last-Modified', async () => { + let fileDate = new Date('2025-01-01') + let file = createMockFile('Hello, World!', { lastModified: fileDate.getTime() }) + let handler = createFileHandler(() => file) + + let response = await handler( + createContext('http://localhost/test.txt', { + method, + headers: { 'If-Modified-Since': fileDate.toUTCString() }, + }), + ) + + assert.equal(response.status, 304) + assert.equal(await response.text(), '') + }) + + it('returns 304 (Not Modified) when If-Modified-Since is after Last-Modified', async () => { + let fileDate = new Date('2025-01-01') + let futureDate = new Date('2026-01-01') + let file = createMockFile('Hello, World!', { lastModified: fileDate.getTime() }) + let handler = createFileHandler(() => file) + + let response = await handler( + createContext('http://localhost/test.txt', { + method, + headers: { 'If-Modified-Since': futureDate.toUTCString() }, + }), + ) + + assert.equal(response.status, 304) + }) + + it('returns 200 (OK) when If-Modified-Since is before Last-Modified', async () => { + let fileDate = new Date('2025-01-01') + let pastDate = new Date('2024-01-01') + let file = createMockFile('Hello, World!', { lastModified: fileDate.getTime() }) + let handler = createFileHandler(() => file) + + let response = await handler( + createContext('http://localhost/test.txt', { + method, + headers: { 'If-Modified-Since': pastDate.toUTCString() }, + }), + ) + + assert.equal(response.status, 200) + assert.equal(await response.text(), method === 'HEAD' ? '' : 'Hello, World!') + }) + + it('prioritizes ETag over If-Modified-Since when both are present', async () => { + let fileDate = new Date('2025-01-01') + let file = createMockFile('Hello, World!', { lastModified: fileDate.getTime() }) + let handler = createFileHandler(() => file) + + let response1 = await handler(createContext('http://localhost/test.txt', { method })) + let etag = response1.headers.get('ETag') + + let response2 = await handler( + createContext('http://localhost/test.txt', { + method, + headers: { + 'If-None-Match': 'W/"wrong-etag"', + 'If-Modified-Since': fileDate.toUTCString(), + }, + }), + ) + + assert.equal(response2.status, 200) + }) + }) + } + }) + + describe('Range requests (GET only)', () => { + it('includes Accept-Ranges header', async () => { + let file = createMockFile('Hello') + let handler = createFileHandler(() => file) + + let response = await handler(createContext('http://localhost/test.txt')) + + assert.equal(response.headers.get('Accept-Ranges'), 'bytes') + }) + + it('omits Accept-Ranges header when acceptRanges=false', async () => { + let file = createMockFile('Hello') + let handler = createFileHandler(() => file, { acceptRanges: false }) + + let response = await handler(createContext('http://localhost/test.txt')) + + assert.equal(response.headers.get('Accept-Ranges'), null) + }) + + it('handles simple range request', async () => { + let file = createMockFile('0123456789') + let handler = createFileHandler(() => file) + + let response = await handler( + createContext('http://localhost/test.txt', { + headers: { Range: 'bytes=0-4' }, + }), + ) + + assert.equal(response.status, 206) + assert.equal(await response.text(), '01234') + assert.equal(response.headers.get('Content-Range'), 'bytes 0-4/10') + assert.equal(response.headers.get('Content-Length'), '5') + }) + + it('handles range with only start', async () => { + let file = createMockFile('0123456789') + let handler = createFileHandler(() => file) + + let response = await handler( + createContext('http://localhost/test.txt', { + headers: { Range: 'bytes=5-' }, + }), + ) + + assert.equal(response.status, 206) + assert.equal(await response.text(), '56789') + assert.equal(response.headers.get('Content-Range'), 'bytes 5-9/10') + }) + + it('handles suffix range (last N bytes)', async () => { + let file = createMockFile('0123456789') + let handler = createFileHandler(() => file) + + let response = await handler( + createContext('http://localhost/test.txt', { + headers: { Range: 'bytes=-3' }, + }), + ) + + assert.equal(response.status, 206) + assert.equal(await response.text(), '789') + assert.equal(response.headers.get('Content-Range'), 'bytes 7-9/10') + }) + + it('clamps end byte to file size when it exceeds', async () => { + let file = createMockFile('0123456789') + let handler = createFileHandler(() => file) + + let response = await handler( + createContext('http://localhost/test.txt', { + headers: { Range: 'bytes=0-999' }, + }), + ) + + assert.equal(response.status, 206) + assert.equal(await response.text(), '0123456789') + assert.equal(response.headers.get('Content-Range'), 'bytes 0-9/10') + assert.equal(response.headers.get('Content-Length'), '10') + }) + + it('returns 416 (Range Not Satisfiable) for unsatisfiable range', async () => { + let file = createMockFile('0123456789') + let handler = createFileHandler(() => file) + + let response = await handler( + createContext('http://localhost/test.txt', { + headers: { Range: 'bytes=20-30' }, + }), + ) + + assert.equal(response.status, 416) + assert.equal(response.headers.get('Content-Range'), 'bytes */10') + }) + + it('returns 416 (Range Not Satisfiable) for multipart ranges (not supported)', async () => { + let file = createMockFile('0123456789') + let handler = createFileHandler(() => file) + + let response = await handler( + createContext('http://localhost/test.txt', { + headers: { Range: 'bytes=0-2,5-7' }, + }), + ) + + assert.equal(response.status, 416) + assert.equal(response.headers.get('Content-Range'), 'bytes */10') + }) + + it('returns 400 (Bad Request) for malformed multipart range syntax', async () => { + let file = createMockFile('0123456789') + let handler = createFileHandler(() => file) + + let response = await handler( + createContext('http://localhost/test.txt', { + headers: { Range: 'bytes=0-2,garbage' }, + }), + ) + + assert.equal(response.status, 400) + assert.equal(await response.text(), 'Bad Request') + }) + + it('returns 400 (Bad Request) for start > end', async () => { + let file = createMockFile('0123456789') + let handler = createFileHandler(() => file) + + let response = await handler( + createContext('http://localhost/test.txt', { + headers: { Range: 'bytes=5-2' }, + }), + ) + + assert.equal(response.status, 400) + assert.equal(await response.text(), 'Bad Request') + }) + + it('returns 400 (Bad Request) for malformed range', async () => { + let file = createMockFile('0123456789') + let handler = createFileHandler(() => file) + + let response = await handler( + createContext('http://localhost/test.txt', { + headers: { Range: 'invalid' }, + }), + ) + + assert.equal(response.status, 400) + assert.equal(await response.text(), 'Bad Request') + }) + + it('returns 400 (Bad Request) for "bytes=" with no range', async () => { + let file = createMockFile('0123456789') + let handler = createFileHandler(() => file) + + let response = await handler( + createContext('http://localhost/test.txt', { + headers: { Range: 'bytes=' }, + }), + ) + + assert.equal(response.status, 400) + assert.equal(await response.text(), 'Bad Request') + }) + + it('returns full file when acceptRanges=false', async () => { + let file = createMockFile('0123456789') + let handler = createFileHandler(() => file, { acceptRanges: false }) + + let response = await handler( + createContext('http://localhost/test.txt', { + headers: { Range: 'bytes=0-4' }, + }), + ) + + assert.equal(response.status, 200) + assert.equal(await response.text(), '0123456789') + assert.equal(response.headers.get('Content-Range'), null) + }) + + it('returns 206 (Partial Content) when If-Match succeeds with Range request', async () => { + let file = createMockFile('0123456789', { lastModified: 1000000 }) + let handler = createFileHandler(() => file) + + let response1 = await handler(createContext('http://localhost/test.txt')) + let etag = response1.headers.get('ETag') + assert.ok(etag) + + let response2 = await handler( + createContext('http://localhost/test.txt', { + headers: { + 'If-Match': etag, + Range: 'bytes=0-4', + }, + }), + ) + + assert.equal(response2.status, 206) + assert.equal(await response2.text(), '01234') + assert.equal(response2.headers.get('Content-Range'), 'bytes 0-4/10') + }) + + it('returns 412 (Precondition Failed) when If-Match fails before processing Range', async () => { + let file = createMockFile('0123456789') + let handler = createFileHandler(() => file) + + let response = await handler( + createContext('http://localhost/test.txt', { + headers: { + 'If-Match': 'W/"wrong-etag"', + Range: 'bytes=0-4', + }, + }), + ) + + assert.equal(response.status, 412) + assert.equal(response.headers.get('Content-Range'), null) + }) + + it('returns 206 (Partial Content) when If-Unmodified-Since passes with Range request', async () => { + let fileDate = new Date('2025-01-01') + let futureDate = new Date('2026-01-01') + let file = createMockFile('0123456789', { lastModified: fileDate.getTime() }) + let handler = createFileHandler(() => file) + + let response = await handler( + createContext('http://localhost/test.txt', { + headers: { + 'If-Unmodified-Since': futureDate.toUTCString(), + Range: 'bytes=0-4', + }, + }), + ) + + assert.equal(response.status, 206) + assert.equal(await response.text(), '01234') + assert.equal(response.headers.get('Content-Range'), 'bytes 0-4/10') + }) + + it('returns 412 (Precondition Failed) when If-Unmodified-Since fails before processing Range', async () => { + let fileDate = new Date('2025-01-01') + let pastDate = new Date('2024-01-01') + let file = createMockFile('0123456789', { lastModified: fileDate.getTime() }) + let handler = createFileHandler(() => file) + + let response = await handler( + createContext('http://localhost/test.txt', { + headers: { + 'If-Unmodified-Since': pastDate.toUTCString(), + Range: 'bytes=0-4', + }, + }), + ) + + assert.equal(response.status, 412) + assert.equal(response.headers.get('Content-Range'), null) + }) + + it('returns 206 (Partial Content) when If-Range matches Last-Modified date', async () => { + let file = createMockFile('0123456789') + let handler = createFileHandler(() => file) + + let response1 = await handler(createContext('http://localhost/test.txt')) + let lastModified = response1.headers.get('Last-Modified') + assert.ok(lastModified) + + let response2 = await handler( + createContext('http://localhost/test.txt', { + headers: { + Range: 'bytes=0-4', + 'If-Range': lastModified, + }, + }), + ) + + assert.equal(response2.status, 206) + assert.equal(await response2.text(), '01234') + assert.equal(response2.headers.get('Content-Range'), 'bytes 0-4/10') + }) + + it('returns 200 (OK, full file) when If-Range does not match Last-Modified date', async () => { + let file = createMockFile('0123456789') + let handler = createFileHandler(() => file) + + let response = await handler( + createContext('http://localhost/test.txt', { + headers: { + Range: 'bytes=0-4', + 'If-Range': 'Wed, 21 Oct 2015 07:28:00 GMT', + }, + }), + ) + + assert.equal(response.status, 200) + assert.equal(await response.text(), '0123456789') + assert.equal(response.headers.get('Content-Range'), null) + }) + + it('ignores If-Range when acceptRanges is disabled', async () => { + let file = createMockFile('0123456789') + let handler = createFileHandler(() => file, { acceptRanges: false }) + + let response = await handler( + createContext('http://localhost/test.txt', { + headers: { + Range: 'bytes=0-4', + 'If-Range': 'Wed, 21 Oct 2015 07:28:00 GMT', + }, + }), + ) + + assert.equal(response.status, 200) + assert.equal(await response.text(), '0123456789') + assert.equal(response.headers.get('Content-Range'), null) + }) + + it('ignores If-Range when lastModified is disabled', async () => { + let file = createMockFile('0123456789') + let handler = createFileHandler(() => file, { lastModified: false }) + + let response = await handler( + createContext('http://localhost/test.txt', { + headers: { + Range: 'bytes=0-4', + 'If-Range': 'Wed, 21 Oct 2015 07:28:00 GMT', + }, + }), + ) + + assert.equal(response.status, 200) + assert.equal(await response.text(), '0123456789') + }) + + it('ignores If-Range with weak ETag value (only Last-Modified date supported)', async () => { + let file = createMockFile('0123456789', { lastModified: 1000000 }) + let handler = createFileHandler(() => file) + + let response1 = await handler(createContext('http://localhost/test.txt')) + let etag = response1.headers.get('ETag') + assert.ok(etag) + + let response2 = await handler( + createContext('http://localhost/test.txt', { + headers: { + Range: 'bytes=0-4', + 'If-Range': etag, + }, + }), + ) + + assert.equal(response2.status, 200) + assert.equal(await response2.text(), '0123456789') + }) + + it('returns full file when If-Range has invalid date format', async () => { + let file = createMockFile('0123456789') + let handler = createFileHandler(() => file) + + let response = await handler( + createContext('http://localhost/test.txt', { + headers: { + Range: 'bytes=0-4', + 'If-Range': '2025-01-01', + }, + }), + ) + + assert.equal(response.status, 200) + assert.equal(await response.text(), '0123456789') + assert.equal(response.headers.get('Content-Range'), null) + }) + + it('returns full file when If-Range is malformed', async () => { + let file = createMockFile('0123456789') + let handler = createFileHandler(() => file) + + let response = await handler( + createContext('http://localhost/test.txt', { + headers: { + Range: 'bytes=0-4', + 'If-Range': 'not-a-valid-value', + }, + }), + ) + + assert.equal(response.status, 200) + assert.equal(await response.text(), '0123456789') + assert.equal(response.headers.get('Content-Range'), null) + }) + + it('returns 304 (Not Modified) when If-None-Match matches etag', async () => { + let file = createMockFile('0123456789', { lastModified: 1000000 }) + let handler = createFileHandler(() => file) + + let response1 = await handler(createContext('http://localhost/test.txt')) + let etag = response1.headers.get('ETag') + assert.ok(etag) + + let response2 = await handler( + createContext('http://localhost/test.txt', { + headers: { + 'If-None-Match': etag, + Range: 'bytes=0-4', + }, + }), + ) + + assert.equal(response2.status, 304) + assert.equal(response2.headers.get('Content-Range'), null) + }) + + it('returns 206 (Partial Content) when If-None-Match does not match', async () => { + let file = createMockFile('0123456789') + let handler = createFileHandler(() => file) + + let response = await handler( + createContext('http://localhost/test.txt', { + headers: { + 'If-None-Match': '"wrong-etag"', + Range: 'bytes=0-4', + }, + }), + ) + + assert.equal(response.status, 206) + assert.equal(await response.text(), '01234') + assert.equal(response.headers.get('Content-Range'), 'bytes 0-4/10') + }) + + it('returns 304 (Not Modified) when If-Modified-Since matches', async () => { + let fileDate = new Date('2025-01-01') + let file = createMockFile('0123456789', { lastModified: fileDate.getTime() }) + let handler = createFileHandler(() => file) + + let response1 = await handler(createContext('http://localhost/test.txt')) + let lastModified = response1.headers.get('Last-Modified') + assert.ok(lastModified) + + let response2 = await handler( + createContext('http://localhost/test.txt', { + headers: { + 'If-Modified-Since': lastModified, + Range: 'bytes=0-4', + }, + }), + ) + + assert.equal(response2.status, 304) + assert.equal(response2.headers.get('Content-Range'), null) + }) + + it('returns 206 (Partial Content) when If-Modified-Since does not match', async () => { + let fileDate = new Date('2025-01-01') + let pastDate = new Date('2024-01-01') + let file = createMockFile('0123456789', { lastModified: fileDate.getTime() }) + let handler = createFileHandler(() => file) + + let response = await handler( + createContext('http://localhost/test.txt', { + headers: { + 'If-Modified-Since': pastDate.toUTCString(), + Range: 'bytes=0-4', + }, + }), + ) + + assert.equal(response.status, 206) + assert.equal(await response.text(), '01234') + assert.equal(response.headers.get('Content-Range'), 'bytes 0-4/10') + }) + + it('returns 304 (Not Modified) with If-None-Match + If-Range when If-None-Match matches', async () => { + let fileDate = new Date('2025-01-01') + let file = createMockFile('0123456789', { lastModified: fileDate.getTime() }) + let handler = createFileHandler(() => file) + + let response1 = await handler(createContext('http://localhost/test.txt')) + let etag = response1.headers.get('ETag') + assert.ok(etag) + let lastModified = response1.headers.get('Last-Modified') + assert.ok(lastModified) + + let response2 = await handler( + createContext('http://localhost/test.txt', { + headers: { + 'If-None-Match': etag, + 'If-Range': lastModified, + Range: 'bytes=0-4', + }, + }), + ) + + assert.equal(response2.status, 304) + assert.equal(response2.headers.get('Content-Range'), null) + }) + + it('returns 206 (Partial Content) with If-None-Match + If-Range when If-Range matches and If-None-Match does not match', async () => { + let fileDate = new Date('2025-01-01') + let file = createMockFile('0123456789', { lastModified: fileDate.getTime() }) + let handler = createFileHandler(() => file) + + let response1 = await handler(createContext('http://localhost/test.txt')) + let lastModified = response1.headers.get('Last-Modified') + assert.ok(lastModified) + + let response2 = await handler( + createContext('http://localhost/test.txt', { + headers: { + 'If-None-Match': '"wrong-etag"', + 'If-Range': lastModified, + Range: 'bytes=0-4', + }, + }), + ) + + assert.equal(response2.status, 206) + assert.equal(await response2.text(), '01234') + assert.equal(response2.headers.get('Content-Range'), 'bytes 0-4/10') + }) + + it('returns 200 (OK) with If-None-Match + If-Range when both If-None-Match and If-Range do not match', async () => { + let fileDate = new Date('2025-01-01') + let pastDate = new Date('2024-01-01') + let file = createMockFile('0123456789', { lastModified: fileDate.getTime() }) + let handler = createFileHandler(() => file) + + let response = await handler( + createContext('http://localhost/test.txt', { + headers: { + 'If-None-Match': '"wrong-etag"', + 'If-Range': pastDate.toUTCString(), + Range: 'bytes=0-4', + }, + }), + ) + + assert.equal(response.status, 200) + assert.equal(await response.text(), '0123456789') + assert.equal(response.headers.get('Content-Range'), null) + }) + + it('ignores Range header for HEAD requests', async () => { + let file = createMockFile('0123456789') + let handler = createFileHandler(() => file) + + let response = await handler( + createContext('http://localhost/test.txt', { + method: 'HEAD', + headers: { Range: 'bytes=0-4' }, + }), + ) + + assert.equal(response.status, 200) + assert.equal(response.headers.get('Accept-Ranges'), 'bytes') + assert.equal(response.headers.get('Content-Range'), null) + assert.equal(await response.text(), '') + }) + }) + + describe('Cache-Control', () => { + it('does not include Cache-Control header by default', async () => { + let file = createMockFile('Hello') + let handler = createFileHandler(() => file) + + let response = await handler(createContext('http://localhost/test.txt')) + + assert.equal(response.headers.get('Cache-Control'), null) + }) + + it('uses custom Cache-Control header', async () => { + let file = createMockFile('Hello') + let handler = createFileHandler(() => file, { + cacheControl: 'no-cache', + }) + + let response = await handler(createContext('http://localhost/test.txt')) + + assert.equal(response.headers.get('Cache-Control'), 'no-cache') + }) + }) + + describe('Content-Type', () => { + it('sets correct Content-Type from file', async () => { + let testCases = [ + { file: 'test.html', type: 'text/html' }, + { file: 'test.css', type: 'text/css' }, + { file: 'test.js', type: 'text/javascript' }, + { file: 'test.json', type: 'application/json' }, + { file: 'test.png', type: 'image/png' }, + { file: 'test.jpg', type: 'image/jpeg' }, + { file: 'test.svg', type: 'image/svg+xml' }, + ] + + for (let { file, type } of testCases) { + let file = createMockFile('test content', { type }) + let handler = createFileHandler(() => file) + + let response = await handler(createContext(`http://localhost/${file}`)) + assert.equal(response.status, 200) + assert.equal(response.headers.get('Content-Type'), type) + } + }) + }) +}) diff --git a/packages/fetch-router/src/lib/file-handler.ts b/packages/fetch-router/src/lib/file-handler.ts new file mode 100644 index 00000000000..4ee174c08e4 --- /dev/null +++ b/packages/fetch-router/src/lib/file-handler.ts @@ -0,0 +1,406 @@ +import SuperHeaders from '@remix-run/headers' + +import type { RequestContext } from './request-context.ts' +import type { RequestHandler } from './request-handler.ts' +import type { RequestMethod } from './request-methods.ts' + +export type FileResolver< + Method extends RequestMethod | 'ANY' = RequestMethod | 'ANY', + Params extends Record = {}, +> = (context: RequestContext) => File | null | Promise + +export interface FileHandlerOptions { + /** + * Cache-Control header value. If not provided, no Cache-Control header will be set. + * + * @example 'public, max-age=31536000, immutable' // for hashed assets + * @example 'public, max-age=3600' // 1 hour + * @example 'no-cache' // always revalidate + */ + cacheControl?: string + + /** + * Whether to generate ETags for files. + * + * ETags are generated using a weak tag format based on file size and last modified time: + * `W/"-"` + * + * @default true + */ + etag?: boolean + + /** + * Whether to include Last-Modified headers. + * + * @default true + */ + lastModified?: boolean + + /** + * Whether to support HTTP Range requests for partial content. + * + * When enabled, includes Accept-Ranges header and handles Range requests + * with 206 Partial Content responses. + * + * @default true + */ + acceptRanges?: boolean +} + +/** + * Creates a file handler that implements HTTP semantics for serving files. + * + * The handler can be used directly as a route handler, or wrapped in middleware + * that intercepts 404 responses to fall through to other handlers. + * + * @param resolveFile - Function that resolves the file for a given request + * @param options - Optional configuration for HTTP headers and features + * @returns A route handler function + * + * @example + * // Use directly as a route handler + * let fileHandler = createFileHandler( + * async (context) => { + * let filePath = path.join('/files', context.params.path) + * try { + * return openFile(filePath) + * } catch { + * return null // -> 404 + * } + * }, + * { + * etag: true, + * acceptRanges: true + * } + * ) + * + * router.get('/files/*path', fileHandler) + * + * @example + * // Wrap in custom middleware + * router.get('/files/*path', async (context) => { + * let response = await fileHandler(context) + * if (response.status === 404) { + * return new Response('Custom 404', { status: 404 }) + * } + * return response + * }) + */ +export function createFileHandler< + Method extends RequestMethod | 'ANY' = RequestMethod | 'ANY', + Params extends Record = {}, +>( + resolveFile: FileResolver, + options: FileHandlerOptions = {}, +): RequestHandler { + let { + cacheControl, + etag: etagEnabled = true, + lastModified: lastModifiedEnabled = true, + acceptRanges: acceptRangesEnabled = true, + } = options + + return async (context: RequestContext): Promise => { + let { request } = context + + // Only support GET and HEAD methods + if (request.method !== 'GET' && request.method !== 'HEAD') { + return new Response('Method Not Allowed', { + status: 405, + headers: { + Allow: 'GET, HEAD', + }, + }) + } + + // Resolve the file + let file = await resolveFile(context) + + if (!file) { + return new Response('Not Found', { status: 404 }) + } + + let contentType = file.type + let contentLength = file.size + + let etag: string | undefined + if (etagEnabled) { + etag = generateWeakETag(file) + } + + let lastModified: number | undefined + if (lastModifiedEnabled) { + lastModified = file.lastModified + } + + let acceptRanges: 'bytes' | undefined + if (acceptRangesEnabled) { + acceptRanges = 'bytes' + } + + // If-Match support: https://httpwg.org/specs/rfc9110.html#field.if-match + let ifMatch = request.headers.get('If-Match') + if (etag && ifMatch != null && !matchesETag(ifMatch, etag)) { + return new Response('Precondition Failed', { + status: 412, + headers: new SuperHeaders({ + etag, + lastModified, + acceptRanges, + }), + }) + } + + // If-Unmodified-Since support: https://httpwg.org/specs/rfc9110.html#field.if-unmodified-since + if (lastModified && ifMatch == null) { + let ifUnmodifiedSinceDate = context.headers.ifUnmodifiedSince + if (ifUnmodifiedSinceDate != null) { + let ifUnmodifiedSinceTime = ifUnmodifiedSinceDate.getTime() + if (roundToSecond(lastModified) > roundToSecond(ifUnmodifiedSinceTime)) { + return new Response('Precondition Failed', { + status: 412, + headers: new SuperHeaders({ + etag, + lastModified, + acceptRanges, + }), + }) + } + } + } + + // If-None-Match support: https://httpwg.org/specs/rfc9110.html#field.if-none-match + // If-Modified-Since support: https://httpwg.org/specs/rfc9110.html#field.if-modified-since + if (etag || lastModified) { + let shouldReturnNotModified = false + + let ifNoneMatch = context.headers.ifNoneMatch + let ifModifiedSinceDate = context.headers.ifModifiedSince + + if (ifNoneMatch.tags.length > 0) { + if (etag && ifNoneMatch.matches(etag)) { + shouldReturnNotModified = true + } + } else if (ifModifiedSinceDate != null && lastModified) { + let ifModifiedSinceTime = ifModifiedSinceDate.getTime() + if (roundToSecond(lastModified) <= roundToSecond(ifModifiedSinceTime)) { + shouldReturnNotModified = true + } + } + + if (shouldReturnNotModified) { + return new Response(null, { + status: 304, + headers: new SuperHeaders({ + etag, + lastModified, + acceptRanges, + }), + }) + } + } + + // Range support: https://httpwg.org/specs/rfc9110.html#field.range + // If-Range support: https://httpwg.org/specs/rfc9110.html#field.if-range + if (acceptRanges && request.method === 'GET') { + let range = request.headers.get('Range') + if (range) { + let shouldProcessRange = true + + let ifRange = request.headers.get('If-Range') + if (ifRange != null) { + // Since we only use weak ETags, we can only compare Last-Modified timestamps + let ifRangeTime = parseHttpDate(ifRange) + shouldProcessRange = Boolean( + lastModified && + ifRangeTime && + roundToSecond(lastModified) === roundToSecond(ifRangeTime), + ) + } + + if (shouldProcessRange) { + let rangeResult = parseRangeHeader(range, file.size) + + if (rangeResult.type === 'malformed') { + return new Response('Bad Request', { + status: 400, + }) + } + + if (rangeResult.type === 'unsatisfiable') { + return new Response('Range Not Satisfiable', { + status: 416, + headers: { + 'Content-Range': `bytes */${file.size}`, + }, + }) + } + + let { start, end } = rangeResult + let { size } = file + + return new Response(file.slice(start, end + 1), { + status: 206, + headers: new SuperHeaders({ + contentType, + contentLength: end - start + 1, + contentRange: { unit: 'bytes', start, end, size }, + etag, + lastModified, + cacheControl, + acceptRanges, + }), + }) + } + } + } + + return new Response(request.method === 'HEAD' ? null : file, { + status: 200, + headers: new SuperHeaders({ + contentType, + contentLength, + etag, + lastModified, + cacheControl, + acceptRanges, + }), + }) + } +} + +function generateWeakETag(file: File): string { + return `W/"${file.size}-${file.lastModified}"` +} + +function matchesETag(ifNoneMatch: string, etag: string): boolean { + let tags = ifNoneMatch.split(',').map((tag) => tag.trim()) + return tags.includes(etag) || tags.includes('*') +} + +/** + * Rounds a timestamp to second precision. + * HTTP Last-Modified headers only have second precision, so this is used + * when comparing dates for conditional requests. + */ +function roundToSecond(timestamp: number): number { + return Math.floor(timestamp / 1000) +} + +const imfFixdatePattern = + /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), (\d{2}) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (\d{4}) (\d{2}):(\d{2}):(\d{2}) GMT$/ + +/** + * Parses an HTTP date header value. + * HTTP dates must follow RFC 7231 IMF-fixdate format: + * "Day, DD Mon YYYY HH:MM:SS GMT" (e.g., "Wed, 21 Oct 2015 07:28:00 GMT") + * Returns the timestamp in milliseconds, or null if invalid. + */ +function parseHttpDate(dateString: string): number | null { + if (!imfFixdatePattern.test(dateString)) { + return null + } + + let timestamp = Date.parse(dateString) + if (isNaN(timestamp)) { + return null + } + + return timestamp +} + +const rangeHeaderPattern = /^bytes=(.+)$/ +const rangeHeaderPartPattern = /^(\d*)-(\d*)$/ + +type ParseRangeResult = + | { type: 'success'; start: number; end: number } + | { type: 'malformed' } + | { type: 'unsatisfiable' } + +/** + * Parses a single range header part (e.g., "0-99", "100-", "-500"). Returns a + * result indicating success with normalized bounds, or malformed/unsatisfiable. + */ +function parseRangeHeaderPart(rangeHeaderPart: string, fileSize: number): ParseRangeResult { + let match = rangeHeaderPart.trim().match(rangeHeaderPartPattern) + if (!match) { + return { type: 'malformed' } + } + + let [, startStr, endStr] = match + + // At least one bound must be specified + if (!startStr && !endStr) { + return { type: 'malformed' } + } + + let start = startStr ? parseInt(startStr, 10) : null + let end = endStr ? parseInt(endStr, 10) : null + + // Normalize the range based on what's specified + if (start != null && end != null) { + // Both bounds specified (e.g., "0-99") + if (start > end) { + return { type: 'malformed' } + } + + // Clamp end to file size + if (end >= fileSize) { + end = fileSize - 1 + } + } else if (start != null) { + // Only start specified (e.g., "100-") + end = fileSize - 1 + } else { + // Only end specified (e.g., "-500" means last 500 bytes) + let suffix = end! + start = Math.max(0, fileSize - suffix) + end = fileSize - 1 + } + + if (start >= fileSize) { + return { type: 'unsatisfiable' } + } + + return { type: 'success', start, end } +} + +/** + * Parses a Range header value. Returns a result object with a type of + * 'success', 'malformed', or 'unsatisfiable'. The `start` and `end` values are + * only present if the type is 'success'. Multipart ranges are not supported. + */ +function parseRangeHeader(range: string, fileSize: number): ParseRangeResult { + // Extract the bytes= portion + let bytesMatch = range.trim().match(rangeHeaderPattern) + + if (!bytesMatch) { + return { type: 'malformed' } + } + + let rangeParts = bytesMatch[1].split(',') + + let firstRangeResult: ParseRangeResult | undefined + for (let rangePart of rangeParts) { + let rangePartResult = parseRangeHeaderPart(rangePart, fileSize) + if (!firstRangeResult) { + firstRangeResult = rangePartResult + } + if (rangePartResult.type === 'malformed') { + return { type: 'malformed' } + } + } + + if (!firstRangeResult) { + return { type: 'malformed' } + } + + if (rangeParts.length > 1) { + // If we're here, the client sent valid multipart ranges, so we want to + // communicate that their request is syntactically valid but unsatisfiable. + // This is to keep it distinct from a malformed range header. + return { type: 'unsatisfiable' } + } + + return firstRangeResult +} diff --git a/packages/fetch-router/src/lib/fs-file-resolver.test.ts b/packages/fetch-router/src/lib/fs-file-resolver.test.ts new file mode 100644 index 00000000000..ab6c7640def --- /dev/null +++ b/packages/fetch-router/src/lib/fs-file-resolver.test.ts @@ -0,0 +1,191 @@ +import * as assert from 'node:assert/strict' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import { describe, it, beforeEach, afterEach } from 'node:test' +import SuperHeaders, { type SuperHeadersInit } from '@remix-run/headers' + +import { createFsFileResolver } from './fs-file-resolver.ts' +import type { RequestContext } from './request-context.ts' +import { AppStorage } from './app-storage.ts' +import type { RequestMethod } from './request-methods.ts' + +describe('createFsFileResolver', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fs-file-resolver-test-')) + }) + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + function createTestFile(filename: string, content: string) { + let filePath = path.join(tmpDir, filename) + let dir = path.dirname(filePath) + + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }) + } + + fs.writeFileSync(filePath, content) + + return filePath + } + + function createContext( + path: string, + options: { + method?: RequestMethod + headers?: SuperHeadersInit + } = {}, + ): RequestContext { + let url = new URL(`http://localhost/${path}`) + let headers = new SuperHeaders(options.headers ?? {}) + return { + formData: undefined, + storage: new AppStorage(), + url: new URL(url), + files: null, + method: options.method || 'GET', + request: new Request(url, { + method: options.method || 'GET', + headers, + }), + params: {}, + headers, + } + } + + function requestPathnameResolver(requestContext: RequestContext<'GET', {}>) { + return new URL(requestContext.request.url).pathname.replace(/^\//, '') + } + + describe('basic functionality', () => { + it('resolves a file from the filesystem', async () => { + createTestFile('test.txt', 'Hello, World!') + + let resolver = createFsFileResolver(tmpDir, requestPathnameResolver) + let file = await resolver(createContext('test.txt')) + + assert.ok(file instanceof File) + assert.equal(await file.text(), 'Hello, World!') + assert.equal(file.type, 'text/plain') + }) + + it('resolves files from nested directories', async () => { + createTestFile('dir/subdir/file.txt', 'Nested file') + + let resolver = createFsFileResolver(tmpDir, requestPathnameResolver) + let file = await resolver(createContext('dir/subdir/file.txt')) + + assert.ok(file instanceof File) + assert.equal(await file.text(), 'Nested file') + }) + + it('returns null for non-existent file', async () => { + let resolver = createFsFileResolver(tmpDir, requestPathnameResolver) + let file = await resolver(createContext('nonexistent.txt')) + + assert.equal(file, null) + }) + + it('returns null when directory is requested', async () => { + let dirPath = path.join(tmpDir, 'subdir') + fs.mkdirSync(dirPath) + + let resolver = createFsFileResolver(tmpDir, requestPathnameResolver) + let file = await resolver(createContext('subdir')) + + assert.equal(file, null) + }) + + it('returns null when path resolver returns null', async () => { + createTestFile('test.txt', 'Hello, World!') + + let resolver = createFsFileResolver(tmpDir, () => null) + let file = await resolver(createContext('test.txt')) + + assert.equal(file, null) + }) + }) + + describe('path resolution', () => { + it('resolves relative root paths', async () => { + let relativeTmpDir = path.relative(process.cwd(), tmpDir) + createTestFile('test.txt', 'Hello') + + let resolver = createFsFileResolver(relativeTmpDir, requestPathnameResolver) + let file = await resolver(createContext('test.txt')) + + assert.ok(file instanceof File) + assert.equal(await file.text(), 'Hello') + }) + + it('resolves absolute root paths', async () => { + let absoluteTmpDir = path.resolve(tmpDir) + createTestFile('test.txt', 'Hello') + + let resolver = createFsFileResolver(absoluteTmpDir, requestPathnameResolver) + let file = await resolver(createContext('test.txt')) + + assert.ok(file instanceof File) + assert.equal(await file.text(), 'Hello') + }) + }) + + describe('security', () => { + it('prevents path traversal with .. in pathname', async () => { + createTestFile('secret.txt', 'Secret content') + + let publicDirName = 'public' + createTestFile(`${publicDirName}/allowed.txt`, 'Allowed content') + + let publicDir = path.join(tmpDir, publicDirName) + let resolver = createFsFileResolver(publicDir, requestPathnameResolver) + + let allowedFile = await resolver(createContext('allowed.txt')) + assert.ok(allowedFile instanceof File) + assert.equal(await allowedFile.text(), 'Allowed content') + + let traversalFile = await resolver(createContext('../secret.txt')) + assert.equal(traversalFile, null) + }) + + it('does not support absolute paths in the resolved path', async () => { + let parentDir = path.dirname(tmpDir) + let secretFileName = 'secret-outside-root.txt' + let secretPath = path.join(parentDir, secretFileName) + fs.writeFileSync(secretPath, 'Secret content') + + let resolver = createFsFileResolver(tmpDir, () => secretPath) + + try { + let file = await resolver(createContext('anything')) + assert.equal(file, null) + } finally { + fs.unlinkSync(secretPath) + } + }) + }) + + describe('error handling', () => { + it('throws non-ENOENT errors', async () => { + // Create a resolver with a path that will trigger a permission error or similar + let resolver = createFsFileResolver(tmpDir, () => { + // Return a path with invalid characters that will cause an error other than ENOENT + return '\x00invalid' + }) + + await assert.rejects( + async () => { + await resolver(createContext('test')) + }, + (error: any) => { + return error.code !== 'ENOENT' + }, + ) + }) + }) +}) diff --git a/packages/fetch-router/src/lib/fs-file-resolver.ts b/packages/fetch-router/src/lib/fs-file-resolver.ts new file mode 100644 index 00000000000..a06cb5fe90e --- /dev/null +++ b/packages/fetch-router/src/lib/fs-file-resolver.ts @@ -0,0 +1,67 @@ +import * as path from 'node:path' + +import { openFile } from '@remix-run/lazy-file/fs' + +import type { FileResolver } from './file-handler.ts' +import type { RequestContext } from './request-context.ts' +import type { RequestMethod } from './request-methods.ts' + +export type PathResolver< + Method extends RequestMethod | 'ANY' = RequestMethod | 'ANY', + Params extends Record = {}, +> = (context: RequestContext) => string | null | Promise + +/** + * Creates a file resolver that resolves files from the filesystem using the lazy-file API. + * + * This resolver handles filesystem-specific concerns like opening files, + * handling ENOENT errors, and dealing with directories. + * + * @param root - The root directory to serve files from (absolute or relative to cwd) + * @param pathResolver - Function that resolves the relative path for a given request + * @returns A file resolver function that can be passed to `createFileHandler` + * + * @example + * import { createFileHandler } from '@remix-run/fetch-router/file-handler' + * import { createFsFileResolver } from '@remix-run/fetch-router/fs-file-resolver' + * + * let handler = createFileHandler( + * createFsFileResolver('/files', (context) => context.params.path) + * ) + * + * router.get('/files/*path', handler) + */ +export function createFsFileResolver< + Method extends RequestMethod | 'ANY', + Params extends Record = {}, +>(root: string, pathResolver: PathResolver): FileResolver { + // Ensure root is an absolute path + root = path.resolve(root) + + return async (context) => { + let relativePath = await pathResolver(context) + + if (relativePath === null) { + return null + } + + let filePath = path.join(root, relativePath) + + try { + return openFile(filePath) + } catch (error) { + if (isNoEntityError(error) || isNotAFileError(error)) { + return null + } + throw error + } + } +} + +function isNoEntityError(error: unknown): error is NodeJS.ErrnoException & { code: 'ENOENT' } { + return error instanceof Error && 'code' in error && error.code === 'ENOENT' +} + +function isNotAFileError(error: unknown): boolean { + return error instanceof Error && error.message.includes('is not a file') +} diff --git a/packages/fetch-router/src/lib/middleware/static.test.ts b/packages/fetch-router/src/lib/middleware/static.test.ts new file mode 100644 index 00000000000..10724bf7e14 --- /dev/null +++ b/packages/fetch-router/src/lib/middleware/static.test.ts @@ -0,0 +1,451 @@ +import * as assert from 'node:assert/strict' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import { describe, it, beforeEach, afterEach } from 'node:test' + +import { createRouter } from '../router.ts' +import { staticFiles } from './static.ts' + +describe('staticFiles middleware', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'static-middleware-test-')) + }) + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + function createTestFile(filename: string, content: string, date?: Date) { + let filePath = path.join(tmpDir, filename) + let dir = path.dirname(filePath) + + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }) + } + + fs.writeFileSync(filePath, content) + + if (date) { + fs.utimesSync(filePath, date, date) + } + + return filePath + } + + describe('basic functionality', () => { + it('serves a file', async () => { + createTestFile('test.txt', 'Hello, World!') + + let router = createRouter() + router.get('/*', { + use: [staticFiles(tmpDir)], + handler() { + return new Response('Fallback Handler', { status: 404 }) + }, + }) + + let response = await router.fetch('http://localhost/test.txt') + + assert.equal(response.status, 200) + assert.equal(await response.text(), 'Hello, World!') + assert.equal(response.headers.get('Content-Type'), 'text/plain') + }) + + it('serves a file with HEAD request', async () => { + createTestFile('test.txt', 'Hello, World!') + + let router = createRouter() + router.head('/*', { + use: [staticFiles(tmpDir)], + handler() { + return new Response('Fallback Handler', { status: 404 }) + }, + }) + + let response = await router.fetch('http://localhost/test.txt', { method: 'HEAD' }) + + assert.equal(response.status, 200) + assert.equal(await response.text(), '') + assert.equal(response.headers.get('Content-Type'), 'text/plain') + }) + + it('serves files from nested directories', async () => { + createTestFile('dir/subdir/file.txt', 'Nested file') + + let router = createRouter() + router.get('/*', { + use: [staticFiles(tmpDir)], + handler() { + return new Response('Fallback Handler', { status: 404 }) + }, + }) + + let response = await router.fetch('http://localhost/dir/subdir/file.txt') + + assert.equal(response.status, 200) + assert.equal(await response.text(), 'Nested file') + }) + + it('falls through to handler for non-existent file', async () => { + let router = createRouter() + router.get('/*', { + use: [staticFiles(tmpDir)], + handler() { + return new Response('Custom Fallback Handler', { status: 404 }) + }, + }) + + let response = await router.fetch('http://localhost/nonexistent.txt') + + assert.equal(response.status, 404) + assert.equal(await response.text(), 'Custom Fallback Handler') + }) + + it('supports custom path resolver using params', async () => { + createTestFile('custom/file.txt', 'Custom path content') + + let router = createRouter() + router.get('/assets/*path', { + use: [ + staticFiles(tmpDir, { + path: ({ params }) => `custom/${params.path}`, + }), + ], + handler() { + return new Response('Not Found', { status: 404 }) + }, + }) + + let response = await router.fetch('http://localhost/assets/file.txt') + + assert.equal(response.status, 200) + assert.equal(await response.text(), 'Custom path content') + }) + + it('supports custom path resolver using request URL', async () => { + createTestFile('file.txt', 'File content') + + let router = createRouter() + router.get('/*', { + use: [ + staticFiles(tmpDir, { + path: ({ request }) => { + return new URL(request.url).pathname.replace(/^\/prefix\//, '') + }, + }), + ], + handler() { + return new Response('Not Found', { status: 404 }) + }, + }) + + let response = await router.fetch('http://localhost/prefix/file.txt') + + assert.equal(response.status, 200) + assert.equal(await response.text(), 'File content') + }) + + it('enforces type safety for params in path resolver', async () => { + let router = createRouter() + router.get('/assets/*path', { + use: [ + staticFiles(tmpDir, { + // @ts-expect-error - 'nonexistent' does not exist on params + path: ({ params }) => params.nonexistent, + }), + ], + handler() { + return new Response('Not Found', { status: 404 }) + }, + }) + + // This is just a compile-time test, no runtime assertion needed + assert.ok(router) + }) + + it('falls through to handler when requesting a directory', async () => { + let dirPath = path.join(tmpDir, 'subdir') + fs.mkdirSync(dirPath) + + let router = createRouter() + router.get('/*', { + use: [staticFiles(tmpDir)], + handler() { + return new Response('Fallback Handler', { status: 404 }) + }, + }) + + let response = await router.fetch('http://localhost/subdir') + + assert.equal(response.status, 404) + assert.equal(await response.text(), 'Fallback Handler') + }) + }) + + it('supports etag by default', async () => { + let lastModified = new Date('2025-01-01') + createTestFile('test.txt', 'Hello, World!', lastModified) + let router = createRouter() + router.get('/*', { + use: [staticFiles(tmpDir)], + handler() { + return new Response('Fallback Handler', { status: 404 }) + }, + }) + + let response = await router.fetch('http://localhost/test.txt') + assert.equal(response.status, 200) + assert.equal(await response.text(), 'Hello, World!') + let etag = response.headers.get('ETag') + assert.ok(etag) + assert.equal(etag, 'W/"13-1735689600000"') + + let response2 = await router.fetch('http://localhost/test.txt', { + headers: { 'If-None-Match': etag }, + }) + assert.equal(response2.status, 304) + assert.equal(await response2.text(), '') + }) + + it('does not send etag if etag is disabled', async () => { + let lastModified = new Date('2025-01-01') + createTestFile('test.txt', 'Hello, World!', lastModified) + let router = createRouter() + router.get('/*', { + use: [staticFiles(tmpDir, { etag: false })], + handler() { + return new Response('Fallback Handler', { status: 404 }) + }, + }) + + let response = await router.fetch('http://localhost/test.txt') + assert.equal(response.status, 200) + assert.equal(await response.text(), 'Hello, World!') + assert.equal(response.headers.get('ETag'), null) + }) + + it('supports last-modified by default', async () => { + let lastModified = new Date('2025-01-01') + createTestFile('test.txt', 'Hello, World!', lastModified) + let router = createRouter() + router.get('/*', { + use: [staticFiles(tmpDir)], + handler() { + return new Response('Fallback Handler', { status: 404 }) + }, + }) + + let response = await router.fetch('http://localhost/test.txt') + assert.equal(response.status, 200) + assert.equal(await response.text(), 'Hello, World!') + assert.equal(response.headers.get('Last-Modified'), lastModified.toUTCString()) + }) + + it('does not send last-modified if lastModified is disabled', async () => { + let lastModified = new Date('2025-01-01') + createTestFile('test.txt', 'Hello, World!', lastModified) + let router = createRouter() + router.get('/*', { + use: [staticFiles(tmpDir, { lastModified: false })], + handler() { + return new Response('Fallback Handler', { status: 404 }) + }, + }) + + let response = await router.fetch('http://localhost/test.txt') + assert.equal(response.status, 200) + assert.equal(await response.text(), 'Hello, World!') + assert.equal(response.headers.get('Last-Modified'), null) + }) + + it('supports accept-ranges by default', async () => { + let lastModified = new Date('2025-01-01') + createTestFile('test.txt', 'Hello, World!', lastModified) + let router = createRouter() + router.get('/*', { + use: [staticFiles(tmpDir)], + handler() { + return new Response('Fallback Handler', { status: 404 }) + }, + }) + + let response = await router.fetch('http://localhost/test.txt') + assert.equal(response.status, 200) + assert.equal(await response.text(), 'Hello, World!') + assert.equal(response.headers.get('Accept-Ranges'), 'bytes') + + let response2 = await router.fetch('http://localhost/test.txt', { + headers: { Range: 'bytes=0-4' }, + }) + assert.equal(response2.status, 206) + assert.equal(await response2.text(), 'Hello') + assert.equal(response2.headers.get('Content-Range'), 'bytes 0-4/13') + assert.equal(response2.headers.get('Content-Length'), '5') + assert.equal(response2.headers.get('Accept-Ranges'), 'bytes') + }) + + it('does not support range requests if acceptRanges is disabled', async () => { + let lastModified = new Date('2025-01-01') + createTestFile('test.txt', 'Hello, World!', lastModified) + let router = createRouter() + router.get('/*', { + use: [staticFiles(tmpDir, { acceptRanges: false })], + handler() { + return new Response('Fallback Handler', { status: 404 }) + }, + }) + + let response = await router.fetch('http://localhost/test.txt') + assert.equal(response.status, 200) + assert.equal(await response.text(), 'Hello, World!') + assert.equal(response.headers.get('Accept-Ranges'), null) + + let response2 = await router.fetch('http://localhost/test.txt', { + headers: { Range: 'bytes=0-4' }, + }) + assert.equal(response2.status, 200) + assert.equal(await response2.text(), 'Hello, World!') + }) + + it('supports cache-control', async () => { + createTestFile('test.txt', 'Hello, World!') + let router = createRouter() + router.get('/*', { + use: [staticFiles(tmpDir, { cacheControl: 'public, max-age=3600' })], + handler() { + return new Response('Fallback Handler', { status: 404 }) + }, + }) + + let response = await router.fetch('http://localhost/test.txt') + assert.equal(response.headers.get('Cache-Control'), 'public, max-age=3600') + }) + + it('works with multiple static middleware instances', async () => { + let assetsDirName = 'assets' + let imagesDirName = 'images' + + createTestFile(`${assetsDirName}/style.css`, 'body {}') + createTestFile(`${imagesDirName}/logo.png`, 'PNG data') + + let assetsDir = path.join(tmpDir, assetsDirName) + let imagesDir = path.join(tmpDir, imagesDirName) + + let router = createRouter() + router.get('/assets/*path', { + use: [ + staticFiles(assetsDir, { + path: ({ params }) => params.path, + }), + ], + handler() { + return new Response('Fallback Handler', { status: 404 }) + }, + }) + router.get('/images/*path', { + use: [ + staticFiles(imagesDir, { + path: ({ params }) => params.path, + }), + ], + handler() { + return new Response('Fallback Handler', { status: 404 }) + }, + }) + + let response1 = await router.fetch('http://localhost/assets/style.css') + assert.equal(response1.status, 200) + assert.equal(await response1.text(), 'body {}') + + let response2 = await router.fetch('http://localhost/images/logo.png') + assert.equal(response2.status, 200) + assert.equal(await response2.text(), 'PNG data') + }) + + it('works as fallback middleware', async () => { + createTestFile('index.html', '

Fallback Handler

') + + let router = createRouter() + router.get('/api/users', () => { + return new Response('Users API') + }) + router.get('*path', { + use: [staticFiles(tmpDir)], + handler() { + return new Response('Fallback Handler', { status: 404 }) + }, + }) + + let response1 = await router.fetch('http://localhost/api/users') + assert.equal(await response1.text(), 'Users API') + + let response2 = await router.fetch('http://localhost/index.html') + assert.equal(await response2.text(), '

Fallback Handler

') + }) + + for (let method of ['POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'] as const) { + it(`ignores ${method} requests`, async () => { + createTestFile('test.txt', 'Hello, World!') + + let router = createRouter() + router.route(method, '/*path', { + use: [staticFiles(tmpDir)], + handler() { + return new Response('Fallback Handler', { status: 404 }) + }, + }) + + let response = await router.fetch('http://localhost/test.txt', { method }) + + assert.equal(response.status, 404) + assert.equal(await response.text(), 'Fallback Handler') + }) + } + + it('prevents path traversal with .. in pathname', async () => { + createTestFile('secret.txt', 'Secret content') + + let publicDirName = 'public' + createTestFile(`${publicDirName}/allowed.txt`, 'Allowed content') + + let router = createRouter() + router.get('/*', { + use: [staticFiles(path.join(tmpDir, publicDirName))], + handler() { + return new Response('Fallback Handler', { status: 404 }) + }, + }) + + let allowedResponse = await router.fetch('http://localhost/allowed.txt') + assert.equal(allowedResponse.status, 200) + assert.equal(await allowedResponse.text(), 'Allowed content') + + let traversalResponse = await router.fetch('http://localhost/../secret.txt') + assert.equal(traversalResponse.status, 404) + }) + + it('does not support absolute paths in the URL', async () => { + let parentDir = path.dirname(tmpDir) + let secretFileName = 'secret-outside-root.txt' + let secretPath = path.join(parentDir, secretFileName) + fs.writeFileSync(secretPath, 'Secret content') + + let router = createRouter() + router.get('*path', { + use: [staticFiles(tmpDir)], + handler() { + return new Response('Fallback Handler', { status: 404 }) + }, + }) + + try { + let response = await router.fetch(`http://localhost/${secretPath}`) + assert.equal(response.status, 404) + } finally { + fs.unlinkSync(secretPath) + } + }) +}) diff --git a/packages/fetch-router/src/lib/middleware/static.ts b/packages/fetch-router/src/lib/middleware/static.ts new file mode 100644 index 00000000000..836f82b2123 --- /dev/null +++ b/packages/fetch-router/src/lib/middleware/static.ts @@ -0,0 +1,72 @@ +import type { FileHandlerOptions } from '../file-handler.ts' +import { createFileHandler } from '../file-handler.ts' +import type { PathResolver } from '../fs-file-resolver.ts' +import { createFsFileResolver } from '../fs-file-resolver.ts' +import type { Middleware } from '../middleware.ts' +import type { RequestContext } from '../request-context.ts' +import type { RequestMethod } from '../request-methods.ts' + +export type StaticFilesOptions< + Method extends RequestMethod | 'ANY', + Params extends Record, +> = FileHandlerOptions & { + path?: PathResolver +} + +/** + * Resolver that extracts the pathname from the request context, without a + * leading slash so it's suitable for use as a relative file path. + * + * @example + * // Request to http://example.com/assets/style.css + * // Returns: "assets/style.css" + */ +function requestPathnameResolver(context: RequestContext): string { + return new URL(context.request.url).pathname.replace(/^\/+/, '') +} + +/** + * Creates a middleware that serves static files from the filesystem. + * + * By default, uses the URL pathname to resolve files. Optionally accepts a + * custom `path` resolver to customize file resolution (e.g., to use route params). + * The middleware always falls through to the handler if the file is not found or an error occurs. + * + * @param root - The root directory to serve files from (absolute or relative to cwd) + * @param options - Optional configuration + * + * @example + * // Use URL pathname (simple case) + * router.get('/*', { + * use: [staticFiles('./public')], + * handler() { return new Response('Not Found', { status: 404 }) } + * }) + * + * @example + * // Custom path resolver using route params + * router.get('/assets/*path', { + * use: [staticFiles('./assets', { path: ({ params }) => params.path })], + * handler() { return new Response('Not Found', { status: 404 }) } + * }) + */ +export function staticFiles< + Method extends RequestMethod | 'ANY', + Params extends Record, +>(root: string, options: StaticFilesOptions = {}): Middleware { + let { path: pathResolver = requestPathnameResolver, ...fileHandlerOptions } = options + + let handler = createFileHandler( + createFsFileResolver(root, pathResolver), + fileHandlerOptions, + ) + + return async (context, next) => { + let response = await handler(context) + + if (response.status === 404 || response.status === 405) { + return next() + } + + return response + } +} diff --git a/packages/fetch-router/src/lib/router.ts b/packages/fetch-router/src/lib/router.ts index 71c2ffb46ee..cfddeda7990 100644 --- a/packages/fetch-router/src/lib/router.ts +++ b/packages/fetch-router/src/lib/router.ts @@ -11,7 +11,7 @@ import type { RequestHandler } from './request-handler.ts' import { RequestBodyMethods } from './request-methods.ts' import type { RequestBodyMethod, RequestMethod } from './request-methods.ts' import { isRequestHandlerWithMiddleware, isRouteHandlersWithMiddleware } from './route-handlers.ts' -import type { RouteHandlers, InferRouteHandler } from './route-handlers.ts' +import type { RouteHandlers, RouteHandler } from './route-handlers.ts' import { Route } from './route-map.ts' import type { RouteMap } from './route-map.ts' @@ -254,9 +254,15 @@ export class Router { route( method: M, - pattern: P | RoutePattern

| Route, - handler: InferRouteHandler

, - ): void { + pattern: P | RoutePattern

, + handler: RouteHandler, + ): void + route( + method: M, + pattern: Route, + handler: RouteHandler, + ): void + route(method: any, pattern: any, handler: any): void { let routeMiddleware: Middleware[] | undefined let requestHandler: RequestHandler if (isRequestHandlerWithMiddleware(handler)) { @@ -273,9 +279,10 @@ export class Router { }) } + map

(route: P | RoutePattern

, handler: RouteHandler<'ANY', P>): void map( - route: P | RoutePattern

| Route, - handler: InferRouteHandler

, + route: Route, + handler: RouteHandler, ): void map(routes: T, handlers: RouteHandlers): void map(routeOrRoutes: any, handler: any): void { @@ -326,52 +333,66 @@ export class Router { // HTTP-method specific shorthand - get

( - pattern: P | RoutePattern

| Route<'GET' | 'ANY', P>, - handler: InferRouteHandler

, - ): void { + get

(pattern: P | RoutePattern

, handler: RouteHandler<'GET', P>): void + get( + pattern: Route, + handler: RouteHandler, + ): void + get(pattern: any, handler: any): void { this.route('GET', pattern, handler) } - head

( - pattern: P | RoutePattern

| Route<'HEAD' | 'ANY', P>, - handler: InferRouteHandler

, - ): void { + head

(pattern: P | RoutePattern

, handler: RouteHandler<'HEAD', P>): void + head( + pattern: Route, + handler: RouteHandler, + ): void + head(pattern: any, handler: any): void { this.route('HEAD', pattern, handler) } - post

( - pattern: P | RoutePattern

| Route<'POST' | 'ANY', P>, - handler: InferRouteHandler

, - ): void { + post

(pattern: P | RoutePattern

, handler: RouteHandler<'POST', P>): void + post( + pattern: Route, + handler: RouteHandler, + ): void + post(pattern: any, handler: any): void { this.route('POST', pattern, handler) } - put

( - pattern: P | RoutePattern

| Route<'PUT' | 'ANY', P>, - handler: InferRouteHandler

, - ): void { + put

(pattern: P | RoutePattern

, handler: RouteHandler<'PUT', P>): void + put( + pattern: Route, + handler: RouteHandler, + ): void + put(pattern: any, handler: any): void { this.route('PUT', pattern, handler) } - patch

( - pattern: P | RoutePattern

| Route<'PATCH' | 'ANY', P>, - handler: InferRouteHandler

, - ): void { + patch

(pattern: P | RoutePattern

, handler: RouteHandler<'PATCH', P>): void + patch( + pattern: Route, + handler: RouteHandler, + ): void + patch(pattern: any, handler: any): void { this.route('PATCH', pattern, handler) } - delete

( - pattern: P | RoutePattern

| Route<'DELETE' | 'ANY', P>, - handler: InferRouteHandler

, - ): void { + delete

(pattern: P | RoutePattern

, handler: RouteHandler<'DELETE', P>): void + delete( + pattern: Route, + handler: RouteHandler, + ): void + delete(pattern: any, handler: any): void { this.route('DELETE', pattern, handler) } - options

( - pattern: P | RoutePattern

| Route<'OPTIONS' | 'ANY', P>, - handler: InferRouteHandler

, - ): void { + options

(pattern: P | RoutePattern

, handler: RouteHandler<'OPTIONS', P>): void + options( + pattern: Route, + handler: RouteHandler, + ): void + options(pattern: any, handler: any): void { this.route('OPTIONS', pattern, handler) } } diff --git a/packages/fetch-router/src/static-middleware.ts b/packages/fetch-router/src/static-middleware.ts new file mode 100644 index 00000000000..20b160011fa --- /dev/null +++ b/packages/fetch-router/src/static-middleware.ts @@ -0,0 +1,2 @@ +export { staticFiles } from './lib/middleware/static.ts' +export type { StaticFilesOptions } from './lib/middleware/static.ts' diff --git a/packages/headers/README.md b/packages/headers/README.md index 5374accc093..833aeaab34d 100644 --- a/packages/headers/README.md +++ b/packages/headers/README.md @@ -369,6 +369,34 @@ let header = new ContentType({ }) ``` +### Content-Range + +```ts +import { ContentRange } from '@remix-run/headers' + +// Satisfied range +let header = new ContentRange('bytes 200-1000/67589') +header.unit // "bytes" +header.start // 200 +header.end // 1000 +header.size // 67589 + +// Unsatisfied range +let header = new ContentRange('bytes */67589') +header.unit // "bytes" +header.start // null +header.end // null +header.size // 67589 + +// Alternative init style +let header = new ContentRange({ + unit: 'bytes', + start: 200, + end: 1000, + size: 67589, +}) +``` + ### Cookie ```ts diff --git a/packages/headers/src/lib/content-range.test.ts b/packages/headers/src/lib/content-range.test.ts new file mode 100644 index 00000000000..51532c02703 --- /dev/null +++ b/packages/headers/src/lib/content-range.test.ts @@ -0,0 +1,153 @@ +import * as assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { ContentRange } from './content-range.ts' + +describe('ContentRange', () => { + it('initializes with an empty string', () => { + let contentRange = new ContentRange('') + assert.equal(contentRange.unit, '') + assert.equal(contentRange.start, null) + assert.equal(contentRange.end, null) + assert.equal(contentRange.size, '*') + }) + + it('initializes with a string (satisfied range)', () => { + let contentRange = new ContentRange('bytes 200-1000/67589') + assert.equal(contentRange.unit, 'bytes') + assert.equal(contentRange.start, 200) + assert.equal(contentRange.end, 1000) + assert.equal(contentRange.size, 67589) + }) + + it('initializes with a string (unsatisfied range)', () => { + let contentRange = new ContentRange('bytes */67589') + assert.equal(contentRange.unit, 'bytes') + assert.equal(contentRange.start, null) + assert.equal(contentRange.end, null) + assert.equal(contentRange.size, 67589) + }) + + it('initializes with a string (unknown size)', () => { + let contentRange = new ContentRange('bytes 0-999/*') + assert.equal(contentRange.unit, 'bytes') + assert.equal(contentRange.start, 0) + assert.equal(contentRange.end, 999) + assert.equal(contentRange.size, '*') + }) + + it('initializes with an object', () => { + let contentRange = new ContentRange({ + unit: 'bytes', + start: 200, + end: 1000, + size: 67589, + }) + assert.equal(contentRange.unit, 'bytes') + assert.equal(contentRange.start, 200) + assert.equal(contentRange.end, 1000) + assert.equal(contentRange.size, 67589) + }) + + it('initializes with another ContentRange', () => { + let contentRange1 = new ContentRange({ + unit: 'bytes', + start: 200, + end: 1000, + size: 67589, + }) + let contentRange2 = new ContentRange(contentRange1) + assert.equal(contentRange2.unit, 'bytes') + assert.equal(contentRange2.start, 200) + assert.equal(contentRange2.end, 1000) + assert.equal(contentRange2.size, 67589) + }) + + it('sets and gets unit', () => { + let contentRange = new ContentRange() + contentRange.unit = 'items' + assert.equal(contentRange.unit, 'items') + }) + + it('sets and gets start', () => { + let contentRange = new ContentRange() + contentRange.start = 100 + assert.equal(contentRange.start, 100) + }) + + it('sets and gets end', () => { + let contentRange = new ContentRange() + contentRange.end = 500 + assert.equal(contentRange.end, 500) + }) + + it('sets and gets size', () => { + let contentRange = new ContentRange() + contentRange.size = 1000 + assert.equal(contentRange.size, 1000) + }) + + it('converts to string correctly (satisfied range)', () => { + let contentRange = new ContentRange({ + unit: 'bytes', + start: 200, + end: 1000, + size: 67589, + }) + assert.equal(contentRange.toString(), 'bytes 200-1000/67589') + }) + + it('converts to string correctly (unsatisfied range)', () => { + let contentRange = new ContentRange({ + unit: 'bytes', + start: null, + end: null, + size: 67589, + }) + assert.equal(contentRange.toString(), 'bytes */67589') + }) + + it('converts to string correctly (unknown size)', () => { + let contentRange = new ContentRange({ + unit: 'bytes', + start: 0, + end: 999, + size: '*', + }) + assert.equal(contentRange.toString(), 'bytes 0-999/*') + }) + + it('converts to an empty string when unit is not set', () => { + let contentRange = new ContentRange() + contentRange.unit = '' + assert.equal(contentRange.toString(), '') + }) + + it('handles partial range with start only', () => { + let contentRange = new ContentRange({ + unit: 'bytes', + start: 500, + end: null, + size: 1000, + }) + assert.equal(contentRange.toString(), 'bytes */1000') + }) + + it('handles partial range with end only', () => { + let contentRange = new ContentRange({ + unit: 'bytes', + start: null, + end: 999, + size: 1000, + }) + assert.equal(contentRange.toString(), 'bytes */1000') + }) + + it('handles zero-based ranges', () => { + let contentRange = new ContentRange('bytes 0-0/1') + assert.equal(contentRange.start, 0) + assert.equal(contentRange.end, 0) + assert.equal(contentRange.size, 1) + assert.equal(contentRange.toString(), 'bytes 0-0/1') + }) +}) diff --git a/packages/headers/src/lib/content-range.ts b/packages/headers/src/lib/content-range.ts new file mode 100644 index 00000000000..f373f20424c --- /dev/null +++ b/packages/headers/src/lib/content-range.ts @@ -0,0 +1,66 @@ +import { type HeaderValue } from './header-value.ts' + +export interface ContentRangeInit { + /** + * The unit of the range, typically "bytes" + */ + unit?: string + /** + * The start position of the range (inclusive) + * Set to null for unsatisfied ranges + */ + start?: number | null + /** + * The end position of the range (inclusive) + * Set to null for unsatisfied ranges + */ + end?: number | null + /** + * The total size of the resource + * Set to '*' for unknown size + */ + size?: number | '*' +} + +/** + * The value of a `Content-Range` HTTP header. + * + * [MDN `Content-Range` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Range) + * + * [HTTP/1.1 Specification](https://httpwg.org/specs/rfc9110.html#field.content-range) + */ +export class ContentRange implements HeaderValue, ContentRangeInit { + unit: string = '' + start: number | null = null + end: number | null = null + size: number | '*' = '*' + + constructor(init?: string | ContentRangeInit) { + if (init) { + if (typeof init === 'string') { + // Parse: "bytes 200-1000/67589" or "bytes */67589" or "bytes 200-1000/*" + let match = init.match(/^(\w+)\s+(?:(\d+)-(\d+)|\*)\/((?:\d+|\*))$/) + if (match) { + this.unit = match[1] + this.start = match[2] ? parseInt(match[2], 10) : null + this.end = match[3] ? parseInt(match[3], 10) : null + this.size = match[4] === '*' ? '*' : parseInt(match[4], 10) + } + } else { + if (init.unit !== undefined) this.unit = init.unit + if (init.start !== undefined) this.start = init.start + if (init.end !== undefined) this.end = init.end + if (init.size !== undefined) this.size = init.size + } + } + } + + toString(): string { + if (!this.unit) return '' + + let range = this.start !== null && this.end !== null ? `${this.start}-${this.end}` : '*' + + return `${this.unit} ${range}/${this.size}` + } +} + diff --git a/packages/headers/src/lib/super-headers.test.ts b/packages/headers/src/lib/super-headers.test.ts index ed687292da3..c36d744fd2c 100644 --- a/packages/headers/src/lib/super-headers.test.ts +++ b/packages/headers/src/lib/super-headers.test.ts @@ -6,6 +6,7 @@ import { AcceptEncoding } from './accept-encoding.ts' import { AcceptLanguage } from './accept-language.ts' import { CacheControl } from './cache-control.ts' import { ContentDisposition } from './content-disposition.ts' +import { ContentRange } from './content-range.ts' import { ContentType } from './content-type.ts' import { Cookie } from './cookie.ts' import { SuperHeaders } from './super-headers.ts' @@ -220,6 +221,13 @@ describe('SuperHeaders', () => { assert.equal(headers.get('Content-Length'), '42') }) + it('handles the contentRange property', () => { + let headers = new SuperHeaders({ + contentRange: { unit: 'bytes', start: 200, end: 1000, size: 67589 }, + }) + assert.equal(headers.get('Content-Range'), 'bytes 200-1000/67589') + }) + it('handles the contentType property', () => { let headers = new SuperHeaders({ contentType: { mediaType: 'text/plain', charset: 'utf-8' }, @@ -305,6 +313,19 @@ describe('SuperHeaders', () => { let headers = new SuperHeaders({ unknown: 42 }) assert.equal(headers.get('Unknown'), '42') }) + + it('handles undefined values by not setting headers', () => { + let headers = new SuperHeaders({ + contentType: 'text/plain', + contentLength: undefined, + etag: undefined, + cacheControl: 'public', + }) + assert.equal(headers.get('Content-Type'), 'text/plain') + assert.equal(headers.get('Content-Length'), null) + assert.equal(headers.get('ETag'), null) + assert.equal(headers.get('Cache-Control'), 'public') + }) }) describe('property getters and setters', () => { @@ -494,6 +515,28 @@ describe('SuperHeaders', () => { assert.equal(headers.contentLength, null) }) + it('supports the contentRange property', () => { + let headers = new SuperHeaders() + + assert.ok(headers.contentRange instanceof ContentRange) + + headers.contentRange = 'bytes 200-1000/67589' + assert.equal(headers.contentRange.unit, 'bytes') + assert.equal(headers.contentRange.start, 200) + assert.equal(headers.contentRange.end, 1000) + assert.equal(headers.contentRange.size, 67589) + + headers.contentRange = { unit: 'bytes', start: 0, end: 999, size: '*' } + assert.equal(headers.contentRange.unit, 'bytes') + assert.equal(headers.contentRange.start, 0) + assert.equal(headers.contentRange.end, 999) + assert.equal(headers.contentRange.size, '*') + + headers.contentRange = null + assert.ok(headers.contentRange instanceof ContentRange) + assert.equal(headers.contentRange.toString(), '') + }) + it('supports the contentType property', () => { let headers = new SuperHeaders() diff --git a/packages/headers/src/lib/super-headers.ts b/packages/headers/src/lib/super-headers.ts index d37b0dd87ba..10fc6ddf38b 100644 --- a/packages/headers/src/lib/super-headers.ts +++ b/packages/headers/src/lib/super-headers.ts @@ -3,6 +3,7 @@ import { type AcceptEncodingInit, AcceptEncoding } from './accept-encoding.ts' import { type AcceptLanguageInit, AcceptLanguage } from './accept-language.ts' import { type CacheControlInit, CacheControl } from './cache-control.ts' import { type ContentDispositionInit, ContentDisposition } from './content-disposition.ts' +import { type ContentRangeInit, ContentRange } from './content-range.ts' import { type ContentTypeInit, ContentType } from './content-type.ts' import { type CookieInit, Cookie } from './cookie.ts' import { canonicalHeaderName } from './header-names.ts' @@ -58,6 +59,10 @@ interface SuperHeadersPropertyInit { * The [`Content-Length`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Length) header value. */ contentLength?: string | number + /** + * The [`Content-Range`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Range) header value. + */ + contentRange?: string | ContentRangeInit /** * The [`Content-Type`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type) header value. */ @@ -114,7 +119,7 @@ interface SuperHeadersPropertyInit { export type SuperHeadersInit = | Iterable<[string, string]> - | (SuperHeadersPropertyInit & Record) + | (SuperHeadersPropertyInit & Record) const CRLF = '\r\n' @@ -129,6 +134,7 @@ const ContentDispositionKey = 'content-disposition' const ContentEncodingKey = 'content-encoding' const ContentLanguageKey = 'content-language' const ContentLengthKey = 'content-length' +const ContentRangeKey = 'content-range' const ContentTypeKey = 'content-type' const CookieKey = 'cookie' const DateKey = 'date' @@ -179,7 +185,7 @@ export class SuperHeaders extends Headers { let descriptor = Object.getOwnPropertyDescriptor(SuperHeaders.prototype, name) if (descriptor?.set) { descriptor.set.call(this, value) - } else { + } else if (value !== undefined) { this.set(name, value.toString()) } } @@ -514,6 +520,22 @@ export class SuperHeaders extends Headers { this.#setNumberValue(ContentLengthKey, value) } + /** + * The `Content-Range` header indicates where the content of a response body + * belongs in relation to a complete resource. + * + * [MDN `Content-Range` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Range) + * + * [HTTP/1.1 Specification](https://httpwg.org/specs/rfc9110.html#field.content-range) + */ + get contentRange(): ContentRange { + return this.#getHeaderValue(ContentRangeKey, ContentRange) + } + + set contentRange(value: string | ContentRangeInit | undefined | null) { + this.#setHeaderValue(ContentRangeKey, ContentRange, value) + } + /** * The `Content-Type` header indicates the media type of the resource. * diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 544cca22c87..c36089ab875 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -82,6 +82,9 @@ importers: '@remix-run/headers': specifier: workspace:* version: link:../headers + '@remix-run/lazy-file': + specifier: workspace:* + version: link:../lazy-file '@remix-run/route-pattern': specifier: workspace:* version: link:../route-pattern From 6d8f07bc3c4dd31e37e46841136d40926672bcd9 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Wed, 5 Nov 2025 10:35:22 +1100 Subject: [PATCH 02/19] Tidy up stray diffs after merge --- packages/fetch-router/README.md | 73 ------------------------- packages/fetch-router/src/lib/router.ts | 2 + 2 files changed, 2 insertions(+), 73 deletions(-) diff --git a/packages/fetch-router/README.md b/packages/fetch-router/README.md index 29d91cad510..9cb0cdb8fcf 100644 --- a/packages/fetch-router/README.md +++ b/packages/fetch-router/README.md @@ -587,79 +587,6 @@ router.get('/*', { }) ``` -### Nested Routers - -Compose routers to organize large applications: - -```ts -let apiRouter = createRouter() -apiRouter.get('/users', () => new Response('Users')) -apiRouter.get('/posts', () => new Response('Posts')) - -let adminRouter = createRouter() -adminRouter.get('/dashboard', () => new Response('Dashboard')) -adminRouter.get('/settings', () => new Response('Settings')) - -let mainRouter = createRouter() - -// Mount routers at specific paths -mainRouter.mount('/api', apiRouter) -mainRouter.mount('/admin', adminRouter) - -mainRouter.get('/', () => new Response('Home')) - -await mainRouter.fetch('https://example.com/api/users') // "Users" -await mainRouter.fetch('https://example.com/admin/dashboard') // "Dashboard" -``` - -Nested routers can have their own middleware, and parent middleware applies to all children. - -### HTML Responses with the `html()` Helper - -The `html()` helper makes it easy to return HTML responses with automatic content-type headers: - -```ts -import { createRoutes, createRouter, html } from '@remix-run/fetch-router' - -let routes = createRoutes({ - home: '/', - about: '/about', -}) - -let router = createRouter() - -router.get(routes.home, () => - html(` - - - Home - -

Welcome

-

About

- - - `), -) - -router.get(routes.about, () => - html( - ` - - - About - -

About Us

-

Home

- - - `, - { status: 200 }, - ), -) -``` - -The `html()` helper automatically dedents template strings, so your inline HTML looks clean in your code. - ### Request Context Every middleware and request handler receives a `context` object with useful properties: diff --git a/packages/fetch-router/src/lib/router.ts b/packages/fetch-router/src/lib/router.ts index 541580db22a..5c9d17fccaa 100644 --- a/packages/fetch-router/src/lib/router.ts +++ b/packages/fetch-router/src/lib/router.ts @@ -195,6 +195,8 @@ export class Router { } } + // HTTP-method specific shorthand + /** * Map a GET route/pattern to a request handler. * @param route The route/pattern to match From f8cffa8b86e3d21f0920256126935a3a3fee2a2b Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Wed, 5 Nov 2025 10:59:06 +1100 Subject: [PATCH 03/19] Fix ReadableStream type error --- packages/fetch-router/global.d.ts | 7 +++++++ packages/fetch-router/tsconfig.build.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 packages/fetch-router/global.d.ts diff --git a/packages/fetch-router/global.d.ts b/packages/fetch-router/global.d.ts new file mode 100644 index 00000000000..b6c89f69960 --- /dev/null +++ b/packages/fetch-router/global.d.ts @@ -0,0 +1,7 @@ +// See https://github.com/DefinitelyTyped/DefinitelyTyped/discussions/62651 + +interface ReadableStream { + values(options?: { preventCancel?: boolean }): AsyncIterableIterator + [Symbol.asyncIterator](): AsyncIterableIterator +} + diff --git a/packages/fetch-router/tsconfig.build.json b/packages/fetch-router/tsconfig.build.json index fdeb70cad14..1c59f82b772 100644 --- a/packages/fetch-router/tsconfig.build.json +++ b/packages/fetch-router/tsconfig.build.json @@ -6,6 +6,6 @@ "declarationMap": true, "outDir": "./dist" }, - "include": ["src"], + "include": ["global.d.ts", "src"], "exclude": ["src/**/*.test.ts"] } From 977641267fa2ab25720a1fe40d29aeda83dd8d6d Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Wed, 5 Nov 2025 11:03:27 +1100 Subject: [PATCH 04/19] Update pnpm lockfile --- pnpm-lock.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6980f7fb991..9a0e1e2e86f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -96,7 +96,7 @@ importers: specifier: workspace:^ version: link:../html-template '@remix-run/lazy-file': - specifier: workspace:* + specifier: workspace:^ version: link:../lazy-file '@remix-run/route-pattern': specifier: workspace:^ From d08727d72aa248d86429934c22e5acae4f2ead07 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Wed, 5 Nov 2025 13:29:01 +1100 Subject: [PATCH 05/19] Migrate more file handler logic to headers package --- packages/fetch-router/README.md | 34 +- .../fetch-router/src/lib/file-handler.test.ts | 430 ++++++++++-------- packages/fetch-router/src/lib/file-handler.ts | 224 +++------ .../fetch-router/src/lib/middleware/static.ts | 11 +- packages/headers/src/index.ts | 2 + .../headers/src/lib/content-range.test.ts | 10 +- packages/headers/src/lib/content-range.ts | 4 +- packages/headers/src/lib/if-match.test.ts | 104 +++++ packages/headers/src/lib/if-match.ts | 64 +++ packages/headers/src/lib/range.test.ts | 250 ++++++++++ packages/headers/src/lib/range.ts | 164 +++++++ .../headers/src/lib/super-headers.test.ts | 67 ++- packages/headers/src/lib/super-headers.ts | 62 +++ 13 files changed, 1045 insertions(+), 381 deletions(-) create mode 100644 packages/headers/src/lib/if-match.test.ts create mode 100644 packages/headers/src/lib/if-match.ts create mode 100644 packages/headers/src/lib/range.test.ts create mode 100644 packages/headers/src/lib/range.ts diff --git a/packages/fetch-router/README.md b/packages/fetch-router/README.md index 9cb0cdb8fcf..b399070aed2 100644 --- a/packages/fetch-router/README.md +++ b/packages/fetch-router/README.md @@ -540,33 +540,15 @@ router.map(routes.admin.dashboard, { The `staticFiles()` middleware serves static files from the filesystem. The middleware always falls through to the handler if the file is not found, allowing you to customize the 404 response. ```ts -router.get('/*', { +let router = createRouter({ middleware: [staticFiles('./public')], - handler() { - return new Response('Not Found', { status: 404 }) - }, -}) -``` - -By default, this middleware uses the full request pathname to resolve files. You can customize this by providing a path resolver function which is passed the request context. For example, to resolve files based on a route param: - -```ts -router.get('/assets/*path', { - middleware: [ - staticFiles('./public', { - path: ({ params }) => params.path, - }), - ], - handler() { - return new Response('Not Found', { status: 404 }) - }, }) ``` You can further customize the behavior of this middleware by providing additional options: ```ts -router.get('/*', { +let router = createRouter({ middleware: [ staticFiles('./public', { cacheControl: 'public, max-age=3600', @@ -581,6 +563,18 @@ router.get('/*', { lastModified: false, }), ], +}) +``` + +By default, this middleware uses the full request pathname to resolve files. You can customize this by providing a path resolver function which is passed the request context. For example, to resolve files based on a route param: + +```ts +router.get('/assets/*path', { + middleware: [ + staticFiles('./public', { + path: ({ params }) => params.path, + }), + ], handler() { return new Response('Not Found', { status: 404 }) }, diff --git a/packages/fetch-router/src/lib/file-handler.test.ts b/packages/fetch-router/src/lib/file-handler.test.ts index 8085a599805..2930bd3fdd4 100644 --- a/packages/fetch-router/src/lib/file-handler.test.ts +++ b/packages/fetch-router/src/lib/file-handler.test.ts @@ -202,126 +202,166 @@ describe('createFileHandler', () => { assert.equal(response2.status, 304) }) - }) - } - }) - - describe('If-Match support', () => { - for (let method of ['GET', 'HEAD'] as const) { - describe(method, () => { - it('returns 200 (OK) when If-Match matches ETag', async () => { - let file = createMockFile('Hello, World!', { lastModified: 1000000 }) - let handler = createFileHandler(() => file) - - let response1 = await handler(createContext('http://localhost/test.txt', { method })) - let etag = response1.headers.get('ETag') - assert.ok(etag) - - let response2 = await handler( - createContext('http://localhost/test.txt', { - method, - headers: { 'If-Match': etag }, - }), - ) - - assert.equal(response2.status, 200) - assert.equal(await response2.text(), method === 'HEAD' ? '' : 'Hello, World!') - }) - it('returns 412 (Precondition Failed) when If-Match does not match', async () => { + it('ignores If-None-Match when etag is disabled', async () => { let file = createMockFile('Hello, World!') - let handler = createFileHandler(() => file) - let response = await handler( - createContext('http://localhost/test.txt', { - method, - headers: { 'If-Match': 'W/"wrong-etag"' }, - }), + // First, get the ETag that would be generated + let handlerWithEtag = createFileHandler(() => file) + let response1 = await handlerWithEtag( + createContext('http://localhost/test.txt', { method }), ) + let etag = response1.headers.get('ETag') + assert.ok(etag) - assert.equal(response.status, 412) - }) - - it('returns 200 (OK) when If-Match is *', async () => { - let file = createMockFile('Hello, World!') - let handler = createFileHandler(() => file) - + // Now test with etag disabled but send the matching ETag + let handler = createFileHandler(() => file, { etag: false }) let response = await handler( createContext('http://localhost/test.txt', { method, - headers: { 'If-Match': '*' }, + headers: { 'If-None-Match': etag }, }), ) + // Should return 200, not 304, because etag is disabled assert.equal(response.status, 200) assert.equal(await response.text(), method === 'HEAD' ? '' : 'Hello, World!') }) + }) + } + }) - it('returns 200 (OK) when If-Match contains multiple ETags and one matches', async () => { - let file = createMockFile('Hello, World!', { lastModified: 1000000 }) - let handler = createFileHandler(() => file) - - let response1 = await handler(createContext('http://localhost/test.txt', { method })) - let etag = response1.headers.get('ETag') - assert.ok(etag) - - let response2 = await handler( - createContext('http://localhost/test.txt', { - method, - headers: { 'If-Match': `W/"wrong-1", ${etag}, W/"wrong-2"` }, - }), - ) + describe('If-Match support', () => { + for (let method of ['GET', 'HEAD'] as const) { + describe(method, () => { + describe('precondition validation', () => { + it('returns 200 (OK) when If-Match matches ETag', async () => { + let file = createMockFile('Hello, World!', { lastModified: 1000000 }) + let handler = createFileHandler(() => file) + + let response1 = await handler(createContext('http://localhost/test.txt', { method })) + let etag = response1.headers.get('ETag') + assert.ok(etag) + + let response2 = await handler( + createContext('http://localhost/test.txt', { + method, + headers: { 'If-Match': etag }, + }), + ) + + assert.equal(response2.status, 200) + assert.equal(await response2.text(), method === 'HEAD' ? '' : 'Hello, World!') + }) + + it('returns 412 (Precondition Failed) when If-Match does not match', async () => { + let file = createMockFile('Hello, World!') + let handler = createFileHandler(() => file) + + let response = await handler( + createContext('http://localhost/test.txt', { + method, + headers: { 'If-Match': 'W/"wrong-etag"' }, + }), + ) + + assert.equal(response.status, 412) + }) + + it('returns 200 (OK) when If-Match is *', async () => { + let file = createMockFile('Hello, World!') + let handler = createFileHandler(() => file) + + let response = await handler( + createContext('http://localhost/test.txt', { + method, + headers: { 'If-Match': '*' }, + }), + ) + + assert.equal(response.status, 200) + assert.equal(await response.text(), method === 'HEAD' ? '' : 'Hello, World!') + }) + + it('returns 200 (OK) when If-Match contains multiple ETags and one matches', async () => { + let file = createMockFile('Hello, World!', { lastModified: 1000000 }) + let handler = createFileHandler(() => file) + + let response1 = await handler(createContext('http://localhost/test.txt', { method })) + let etag = response1.headers.get('ETag') + assert.ok(etag) + + let response2 = await handler( + createContext('http://localhost/test.txt', { + method, + headers: { 'If-Match': `W/"wrong-1", ${etag}, W/"wrong-2"` }, + }), + ) + + assert.equal(response2.status, 200) + assert.equal(await response2.text(), method === 'HEAD' ? '' : 'Hello, World!') + }) + + it('returns 412 (Precondition Failed) when If-Match contains multiple ETags and none match', async () => { + let file = createMockFile('Hello, World!') + let handler = createFileHandler(() => file) + + let response = await handler( + createContext('http://localhost/test.txt', { + method, + headers: { 'If-Match': 'W/"wrong-1", W/"wrong-2"' }, + }), + ) + + assert.equal(response.status, 412) + }) + }) - assert.equal(response2.status, 200) - assert.equal(await response2.text(), method === 'HEAD' ? '' : 'Hello, World!') + describe('prioritization', () => { + it('returns 412 (Precondition Failed) when If-Match fails, even if If-None-Match would match', async () => { + let file = createMockFile('Hello, World!', { lastModified: 1000000 }) + let handler = createFileHandler(() => file) + + let response1 = await handler(createContext('http://localhost/test.txt', { method })) + let etag = response1.headers.get('ETag') + assert.ok(etag) + + let response2 = await handler( + createContext('http://localhost/test.txt', { + method, + headers: { + 'If-Match': 'W/"wrong-etag"', + 'If-None-Match': etag, + }, + }), + ) + + assert.equal(response2.status, 412) + }) }) - it('returns 412 (Precondition Failed) when If-Match contains multiple ETags and none match', async () => { + it('ignores If-Match when etag is disabled', async () => { let file = createMockFile('Hello, World!') - let handler = createFileHandler(() => file) - let response = await handler( - createContext('http://localhost/test.txt', { - method, - headers: { 'If-Match': 'W/"wrong-1", W/"wrong-2"' }, - }), + // First, get the ETag that would be generated + let handlerWithEtag = createFileHandler(() => file) + let response1 = await handlerWithEtag( + createContext('http://localhost/test.txt', { method }), ) - - assert.equal(response.status, 412) - }) - - it('returns 412 (Precondition Failed) when If-Match fails, even if If-None-Match would match', async () => { - let file = createMockFile('Hello, World!', { lastModified: 1000000 }) - let handler = createFileHandler(() => file) - - let response1 = await handler(createContext('http://localhost/test.txt', { method })) let etag = response1.headers.get('ETag') assert.ok(etag) - let response2 = await handler( - createContext('http://localhost/test.txt', { - method, - headers: { - 'If-Match': 'W/"wrong-etag"', - 'If-None-Match': etag, - }, - }), - ) - - assert.equal(response2.status, 412) - }) - - it('ignores If-Match when etag is disabled', async () => { - let file = createMockFile('Hello, World!') + // Now test with etag disabled but send a non-matching ETag + // (If we weren't ignoring it, this would return 412) let handler = createFileHandler(() => file, { etag: false }) - let response = await handler( createContext('http://localhost/test.txt', { method, - headers: { 'If-Match': 'W/"any-etag"' }, + headers: { 'If-Match': 'W/"wrong-etag"' }, }), ) + // Should return 200, not 412, because etag is disabled assert.equal(response.status, 200) assert.equal(await response.text(), method === 'HEAD' ? '' : 'Hello, World!') }) @@ -332,96 +372,116 @@ describe('createFileHandler', () => { describe('If-Unmodified-Since support', () => { for (let method of ['GET', 'HEAD'] as const) { describe(method, () => { - it('returns 200 (OK) when If-Unmodified-Since is after Last-Modified', async () => { - let fileDate = new Date('2025-01-01') - let futureDate = new Date('2026-01-01') - let file = createMockFile('Hello, World!', { lastModified: fileDate.getTime() }) - let handler = createFileHandler(() => file) - - let response = await handler( - createContext('http://localhost/test.txt', { - method, - headers: { 'If-Unmodified-Since': futureDate.toUTCString() }, - }), - ) - - assert.equal(response.status, 200) - assert.equal(await response.text(), method === 'HEAD' ? '' : 'Hello, World!') - }) - - it('returns 200 (OK) when If-Unmodified-Since matches Last-Modified', async () => { - let fileDate = new Date('2025-01-01') - let file = createMockFile('Hello, World!', { lastModified: fileDate.getTime() }) - let handler = createFileHandler(() => file) - - let response = await handler( - createContext('http://localhost/test.txt', { - method, - headers: { 'If-Unmodified-Since': fileDate.toUTCString() }, - }), - ) - - assert.equal(response.status, 200) - assert.equal(await response.text(), method === 'HEAD' ? '' : 'Hello, World!') - }) - - it('returns 412 (Precondition Failed) when If-Unmodified-Since is before Last-Modified', async () => { - let fileDate = new Date('2025-01-01') - let pastDate = new Date('2024-01-01') - let file = createMockFile('Hello, World!', { lastModified: fileDate.getTime() }) - let handler = createFileHandler(() => file) - - let response = await handler( - createContext('http://localhost/test.txt', { - method, - headers: { 'If-Unmodified-Since': pastDate.toUTCString() }, - }), - ) - - assert.equal(response.status, 412) - }) - - it('ignores If-Unmodified-Since when If-Match is present', async () => { - let fileDate = new Date('2025-01-01') - let pastDate = new Date('2024-01-01') - let file = createMockFile('Hello, World!', { lastModified: fileDate.getTime() }) - let handler = createFileHandler(() => file) - - let response1 = await handler(createContext('http://localhost/test.txt', { method })) - let etag = response1.headers.get('ETag') - assert.ok(etag) - - let response2 = await handler( - createContext('http://localhost/test.txt', { - method, - headers: { - 'If-Match': etag, - 'If-Unmodified-Since': pastDate.toUTCString(), - }, - }), - ) - - assert.equal(response2.status, 200) - assert.equal(await response2.text(), method === 'HEAD' ? '' : 'Hello, World!') + describe('precondition validation', () => { + it('returns 200 (OK) when If-Unmodified-Since is after Last-Modified', async () => { + let fileDate = new Date('2025-01-01') + let futureDate = new Date('2026-01-01') + let file = createMockFile('Hello, World!', { lastModified: fileDate.getTime() }) + let handler = createFileHandler(() => file) + + let response = await handler( + createContext('http://localhost/test.txt', { + method, + headers: { 'If-Unmodified-Since': futureDate.toUTCString() }, + }), + ) + + assert.equal(response.status, 200) + assert.equal(await response.text(), method === 'HEAD' ? '' : 'Hello, World!') + }) + + it('returns 200 (OK) when If-Unmodified-Since matches Last-Modified', async () => { + let fileDate = new Date('2025-01-01') + let file = createMockFile('Hello, World!', { lastModified: fileDate.getTime() }) + let handler = createFileHandler(() => file) + + let response = await handler( + createContext('http://localhost/test.txt', { + method, + headers: { 'If-Unmodified-Since': fileDate.toUTCString() }, + }), + ) + + assert.equal(response.status, 200) + assert.equal(await response.text(), method === 'HEAD' ? '' : 'Hello, World!') + }) + + it('returns 412 (Precondition Failed) when If-Unmodified-Since is before Last-Modified', async () => { + let fileDate = new Date('2025-01-01') + let pastDate = new Date('2024-01-01') + let file = createMockFile('Hello, World!', { lastModified: fileDate.getTime() }) + let handler = createFileHandler(() => file) + + let response = await handler( + createContext('http://localhost/test.txt', { + method, + headers: { 'If-Unmodified-Since': pastDate.toUTCString() }, + }), + ) + + assert.equal(response.status, 412) + }) + + it('ignores malformed If-Unmodified-Since', async () => { + let fileDate = new Date('2025-01-01') + let file = createMockFile('Hello, World!', { lastModified: fileDate.getTime() }) + let handler = createFileHandler(() => file) + + let response = await handler( + createContext('http://localhost/test.txt', { + method, + headers: { 'If-Unmodified-Since': 'invalid-date' }, + }), + ) + + assert.equal(response.status, 200) + assert.equal(await response.text(), method === 'HEAD' ? '' : 'Hello, World!') + }) }) - it('returns 412 (Precondition Failed) when If-Match fails, even if If-Unmodified-Since would pass', async () => { - let fileDate = new Date('2025-01-01') - let futureDate = new Date('2026-01-01') - let file = createMockFile('Hello, World!', { lastModified: fileDate.getTime() }) - let handler = createFileHandler(() => file) - - let response = await handler( - createContext('http://localhost/test.txt', { - method, - headers: { - 'If-Match': 'W/"wrong-etag"', - 'If-Unmodified-Since': futureDate.toUTCString(), - }, - }), - ) - - assert.equal(response.status, 412) + describe('prioritization', () => { + it('ignores If-Unmodified-Since when If-Match is present', async () => { + let fileDate = new Date('2025-01-01') + let pastDate = new Date('2024-01-01') + let file = createMockFile('Hello, World!', { lastModified: fileDate.getTime() }) + let handler = createFileHandler(() => file) + + let response1 = await handler(createContext('http://localhost/test.txt', { method })) + let etag = response1.headers.get('ETag') + assert.ok(etag) + + let response2 = await handler( + createContext('http://localhost/test.txt', { + method, + headers: { + 'If-Match': etag, + 'If-Unmodified-Since': pastDate.toUTCString(), + }, + }), + ) + + assert.equal(response2.status, 200) + assert.equal(await response2.text(), method === 'HEAD' ? '' : 'Hello, World!') + }) + + it('returns 412 (Precondition Failed) when If-Match fails, even if If-Unmodified-Since would pass', async () => { + let fileDate = new Date('2025-01-01') + let futureDate = new Date('2026-01-01') + let file = createMockFile('Hello, World!', { lastModified: fileDate.getTime() }) + let handler = createFileHandler(() => file) + + let response = await handler( + createContext('http://localhost/test.txt', { + method, + headers: { + 'If-Match': 'W/"wrong-etag"', + 'If-Unmodified-Since': futureDate.toUTCString(), + }, + }), + ) + + assert.equal(response.status, 412) + }) }) it('ignores If-Unmodified-Since when lastModified is disabled', async () => { @@ -439,22 +499,6 @@ describe('createFileHandler', () => { assert.equal(response.status, 200) assert.equal(await response.text(), method === 'HEAD' ? '' : 'Hello, World!') }) - - it('ignores malformed If-Unmodified-Since', async () => { - let fileDate = new Date('2025-01-01') - let file = createMockFile('Hello, World!', { lastModified: fileDate.getTime() }) - let handler = createFileHandler(() => file) - - let response = await handler( - createContext('http://localhost/test.txt', { - method, - headers: { 'If-Unmodified-Since': 'invalid-date' }, - }), - ) - - assert.equal(response.status, 200) - assert.equal(await response.text(), method === 'HEAD' ? '' : 'Hello, World!') - }) }) } }) diff --git a/packages/fetch-router/src/lib/file-handler.ts b/packages/fetch-router/src/lib/file-handler.ts index 4ee174c08e4..2076a1a4465 100644 --- a/packages/fetch-router/src/lib/file-handler.ts +++ b/packages/fetch-router/src/lib/file-handler.ts @@ -107,9 +107,9 @@ export function createFileHandler< if (request.method !== 'GET' && request.method !== 'HEAD') { return new Response('Method Not Allowed', { status: 405, - headers: { - Allow: 'GET, HEAD', - }, + headers: new SuperHeaders({ + allow: ['GET', 'HEAD'], + }), }) } @@ -138,9 +138,10 @@ export function createFileHandler< acceptRanges = 'bytes' } + let hasIfMatch = context.headers.has('If-Match') + // If-Match support: https://httpwg.org/specs/rfc9110.html#field.if-match - let ifMatch = request.headers.get('If-Match') - if (etag && ifMatch != null && !matchesETag(ifMatch, etag)) { + if (etag && hasIfMatch && !context.headers.ifMatch.matches(etag)) { return new Response('Precondition Failed', { status: 412, headers: new SuperHeaders({ @@ -152,7 +153,7 @@ export function createFileHandler< } // If-Unmodified-Since support: https://httpwg.org/specs/rfc9110.html#field.if-unmodified-since - if (lastModified && ifMatch == null) { + if (lastModified && !hasIfMatch) { let ifUnmodifiedSinceDate = context.headers.ifUnmodifiedSince if (ifUnmodifiedSinceDate != null) { let ifUnmodifiedSinceTime = ifUnmodifiedSinceDate.getTime() @@ -174,17 +175,15 @@ export function createFileHandler< if (etag || lastModified) { let shouldReturnNotModified = false - let ifNoneMatch = context.headers.ifNoneMatch - let ifModifiedSinceDate = context.headers.ifModifiedSince - - if (ifNoneMatch.tags.length > 0) { - if (etag && ifNoneMatch.matches(etag)) { - shouldReturnNotModified = true - } - } else if (ifModifiedSinceDate != null && lastModified) { - let ifModifiedSinceTime = ifModifiedSinceDate.getTime() - if (roundToSecond(lastModified) <= roundToSecond(ifModifiedSinceTime)) { - shouldReturnNotModified = true + if (etag && context.headers.ifNoneMatch.matches(etag)) { + shouldReturnNotModified = true + } else if (lastModified && context.headers.ifNoneMatch.tags.length === 0) { + let ifModifiedSinceDate = context.headers.ifModifiedSince + if (ifModifiedSinceDate != null) { + let ifModifiedSinceTime = ifModifiedSinceDate.getTime() + if (roundToSecond(lastModified) <= roundToSecond(ifModifiedSinceTime)) { + shouldReturnNotModified = true + } } } @@ -202,56 +201,64 @@ export function createFileHandler< // Range support: https://httpwg.org/specs/rfc9110.html#field.range // If-Range support: https://httpwg.org/specs/rfc9110.html#field.if-range - if (acceptRanges && request.method === 'GET') { - let range = request.headers.get('Range') - if (range) { - let shouldProcessRange = true - - let ifRange = request.headers.get('If-Range') - if (ifRange != null) { - // Since we only use weak ETags, we can only compare Last-Modified timestamps - let ifRangeTime = parseHttpDate(ifRange) - shouldProcessRange = Boolean( - lastModified && - ifRangeTime && - roundToSecond(lastModified) === roundToSecond(ifRangeTime), - ) - } + if (acceptRanges && request.method === 'GET' && context.headers.has('Range')) { + let range = context.headers.range - if (shouldProcessRange) { - let rangeResult = parseRangeHeader(range, file.size) + // Check if the Range header was sent but parsing resulted in no valid ranges (malformed) + if (range.ranges.length === 0) { + return new Response('Bad Request', { + status: 400, + }) + } - if (rangeResult.type === 'malformed') { - return new Response('Bad Request', { - status: 400, - }) - } + let shouldProcessRange = true - if (rangeResult.type === 'unsatisfiable') { - return new Response('Range Not Satisfiable', { - status: 416, - headers: { - 'Content-Range': `bytes */${file.size}`, - }, - }) - } + let ifRange = request.headers.get('If-Range') + if (ifRange != null) { + // Since we only use weak ETags, we can only compare Last-Modified timestamps + let ifRangeTime = parseHttpDate(ifRange) + shouldProcessRange = Boolean( + lastModified && ifRangeTime && roundToSecond(lastModified) === roundToSecond(ifRangeTime), + ) + } + + if (shouldProcessRange) { + if (!range.canSatisfy(file.size)) { + return new Response('Range Not Satisfiable', { + status: 416, + headers: new SuperHeaders({ + contentRange: { unit: 'bytes', size: file.size }, + }), + }) + } - let { start, end } = rangeResult - let { size } = file + let normalized = range.normalize(file.size) - return new Response(file.slice(start, end + 1), { - status: 206, + // We only support single ranges (not multipart) + if (normalized.length > 1) { + return new Response('Range Not Satisfiable', { + status: 416, headers: new SuperHeaders({ - contentType, - contentLength: end - start + 1, - contentRange: { unit: 'bytes', start, end, size }, - etag, - lastModified, - cacheControl, - acceptRanges, + contentRange: { unit: 'bytes', size: file.size }, }), }) } + + let { start, end } = normalized[0] + let { size } = file + + return new Response(file.slice(start, end + 1), { + status: 206, + headers: new SuperHeaders({ + contentType, + contentLength: end - start + 1, + contentRange: { unit: 'bytes', start, end, size }, + etag, + lastModified, + cacheControl, + acceptRanges, + }), + }) } } @@ -273,11 +280,6 @@ function generateWeakETag(file: File): string { return `W/"${file.size}-${file.lastModified}"` } -function matchesETag(ifNoneMatch: string, etag: string): boolean { - let tags = ifNoneMatch.split(',').map((tag) => tag.trim()) - return tags.includes(etag) || tags.includes('*') -} - /** * Rounds a timestamp to second precision. * HTTP Last-Modified headers only have second precision, so this is used @@ -308,99 +310,3 @@ function parseHttpDate(dateString: string): number | null { return timestamp } - -const rangeHeaderPattern = /^bytes=(.+)$/ -const rangeHeaderPartPattern = /^(\d*)-(\d*)$/ - -type ParseRangeResult = - | { type: 'success'; start: number; end: number } - | { type: 'malformed' } - | { type: 'unsatisfiable' } - -/** - * Parses a single range header part (e.g., "0-99", "100-", "-500"). Returns a - * result indicating success with normalized bounds, or malformed/unsatisfiable. - */ -function parseRangeHeaderPart(rangeHeaderPart: string, fileSize: number): ParseRangeResult { - let match = rangeHeaderPart.trim().match(rangeHeaderPartPattern) - if (!match) { - return { type: 'malformed' } - } - - let [, startStr, endStr] = match - - // At least one bound must be specified - if (!startStr && !endStr) { - return { type: 'malformed' } - } - - let start = startStr ? parseInt(startStr, 10) : null - let end = endStr ? parseInt(endStr, 10) : null - - // Normalize the range based on what's specified - if (start != null && end != null) { - // Both bounds specified (e.g., "0-99") - if (start > end) { - return { type: 'malformed' } - } - - // Clamp end to file size - if (end >= fileSize) { - end = fileSize - 1 - } - } else if (start != null) { - // Only start specified (e.g., "100-") - end = fileSize - 1 - } else { - // Only end specified (e.g., "-500" means last 500 bytes) - let suffix = end! - start = Math.max(0, fileSize - suffix) - end = fileSize - 1 - } - - if (start >= fileSize) { - return { type: 'unsatisfiable' } - } - - return { type: 'success', start, end } -} - -/** - * Parses a Range header value. Returns a result object with a type of - * 'success', 'malformed', or 'unsatisfiable'. The `start` and `end` values are - * only present if the type is 'success'. Multipart ranges are not supported. - */ -function parseRangeHeader(range: string, fileSize: number): ParseRangeResult { - // Extract the bytes= portion - let bytesMatch = range.trim().match(rangeHeaderPattern) - - if (!bytesMatch) { - return { type: 'malformed' } - } - - let rangeParts = bytesMatch[1].split(',') - - let firstRangeResult: ParseRangeResult | undefined - for (let rangePart of rangeParts) { - let rangePartResult = parseRangeHeaderPart(rangePart, fileSize) - if (!firstRangeResult) { - firstRangeResult = rangePartResult - } - if (rangePartResult.type === 'malformed') { - return { type: 'malformed' } - } - } - - if (!firstRangeResult) { - return { type: 'malformed' } - } - - if (rangeParts.length > 1) { - // If we're here, the client sent valid multipart ranges, so we want to - // communicate that their request is syntactically valid but unsatisfiable. - // This is to keep it distinct from a malformed range header. - return { type: 'unsatisfiable' } - } - - return firstRangeResult -} diff --git a/packages/fetch-router/src/lib/middleware/static.ts b/packages/fetch-router/src/lib/middleware/static.ts index 836f82b2123..7cd1b075745 100644 --- a/packages/fetch-router/src/lib/middleware/static.ts +++ b/packages/fetch-router/src/lib/middleware/static.ts @@ -36,16 +36,17 @@ function requestPathnameResolver(context: RequestContext): string { * @param options - Optional configuration * * @example - * // Use URL pathname (simple case) - * router.get('/*', { - * use: [staticFiles('./public')], - * handler() { return new Response('Not Found', { status: 404 }) } + * // Use URL pathname + * let router = createRouter({ + * middleware: [staticFiles('./public')], * }) * * @example * // Custom path resolver using route params * router.get('/assets/*path', { - * use: [staticFiles('./assets', { path: ({ params }) => params.path })], + * middleware: [staticFiles('./assets', { + * path: ({ params }) => params.path, + * })], * handler() { return new Response('Not Found', { status: 404 }) } * }) */ diff --git a/packages/headers/src/index.ts b/packages/headers/src/index.ts index 2f9b6af8a4d..17380ddbfa5 100644 --- a/packages/headers/src/index.ts +++ b/packages/headers/src/index.ts @@ -5,7 +5,9 @@ export { type CacheControlInit, CacheControl } from './lib/cache-control.ts' export { type ContentDispositionInit, ContentDisposition } from './lib/content-disposition.ts' export { type ContentTypeInit, ContentType } from './lib/content-type.ts' export { type CookieInit, Cookie } from './lib/cookie.ts' +export { type IfMatchInit, IfMatch } from './lib/if-match.ts' export { type IfNoneMatchInit, IfNoneMatch } from './lib/if-none-match.ts' +export { type RangeInit, Range } from './lib/range.ts' export { type CookieProperties, type SetCookieInit, SetCookie } from './lib/set-cookie.ts' export { diff --git a/packages/headers/src/lib/content-range.test.ts b/packages/headers/src/lib/content-range.test.ts index 51532c02703..f7ec1463e85 100644 --- a/packages/headers/src/lib/content-range.test.ts +++ b/packages/headers/src/lib/content-range.test.ts @@ -9,7 +9,7 @@ describe('ContentRange', () => { assert.equal(contentRange.unit, '') assert.equal(contentRange.start, null) assert.equal(contentRange.end, null) - assert.equal(contentRange.size, '*') + assert.equal(contentRange.size, undefined) }) it('initializes with a string (satisfied range)', () => { @@ -123,6 +123,14 @@ describe('ContentRange', () => { assert.equal(contentRange.toString(), '') }) + it('converts to an empty string when size is not set', () => { + let contentRange = new ContentRange() + contentRange.unit = 'bytes' + contentRange.start = 0 + contentRange.end = 999 + assert.equal(contentRange.toString(), '') + }) + it('handles partial range with start only', () => { let contentRange = new ContentRange({ unit: 'bytes', diff --git a/packages/headers/src/lib/content-range.ts b/packages/headers/src/lib/content-range.ts index f373f20424c..4dc37655480 100644 --- a/packages/headers/src/lib/content-range.ts +++ b/packages/headers/src/lib/content-range.ts @@ -33,7 +33,7 @@ export class ContentRange implements HeaderValue, ContentRangeInit { unit: string = '' start: number | null = null end: number | null = null - size: number | '*' = '*' + size?: number | '*' constructor(init?: string | ContentRangeInit) { if (init) { @@ -56,7 +56,7 @@ export class ContentRange implements HeaderValue, ContentRangeInit { } toString(): string { - if (!this.unit) return '' + if (!this.unit || this.size === undefined) return '' let range = this.start !== null && this.end !== null ? `${this.start}-${this.end}` : '*' diff --git a/packages/headers/src/lib/if-match.test.ts b/packages/headers/src/lib/if-match.test.ts new file mode 100644 index 00000000000..61c1cd792a0 --- /dev/null +++ b/packages/headers/src/lib/if-match.test.ts @@ -0,0 +1,104 @@ +import * as assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { IfMatch } from './if-match.ts' + +describe('IfMatch', () => { + it('initializes with an empty string', () => { + let header = new IfMatch('') + assert.deepEqual(header.tags, []) + }) + + it('initializes with a string with a single tag', () => { + let header = new IfMatch('67ab43') + assert.deepEqual(header.tags, ['"67ab43"']) + + let header2 = new IfMatch('"67ab43"') + assert.deepEqual(header2.tags, ['"67ab43"']) + + let header3 = new IfMatch('W/"67ab43"') + assert.deepEqual(header3.tags, ['W/"67ab43"']) + }) + + it('initializes with a string with multiple tags', () => { + let header = new IfMatch('67ab43, 54ed21') + assert.deepEqual(header.tags, ['"67ab43"', '"54ed21"']) + + let header2 = new IfMatch('"67ab43", "54ed21"') + assert.deepEqual(header2.tags, ['"67ab43"', '"54ed21"']) + + let header3 = new IfMatch('W/"67ab43", "54ed21"') + assert.deepEqual(header3.tags, ['W/"67ab43"', '"54ed21"']) + }) + + it('initializes with an array of tags', () => { + let header = new IfMatch(['67ab43', '54ed21']) + assert.deepEqual(header.tags, ['"67ab43"', '"54ed21"']) + + let header2 = new IfMatch(['"67ab43"', '"54ed21"']) + assert.deepEqual(header2.tags, ['"67ab43"', '"54ed21"']) + + let header3 = new IfMatch(['W/"67ab43"', '"54ed21"']) + assert.deepEqual(header3.tags, ['W/"67ab43"', '"54ed21"']) + }) + + it('initializes with an object', () => { + let header = new IfMatch({ tags: ['67ab43', '54ed21'] }) + assert.deepEqual(header.tags, ['"67ab43"', '"54ed21"']) + + let header2 = new IfMatch({ tags: ['"67ab43"', '"54ed21"'] }) + assert.deepEqual(header2.tags, ['"67ab43"', '"54ed21"']) + + let header3 = new IfMatch({ tags: ['W/"67ab43"', '"54ed21"'] }) + assert.deepEqual(header3.tags, ['W/"67ab43"', '"54ed21"']) + }) + + it('initializes with another IfMatch', () => { + let header = new IfMatch(new IfMatch('67ab43, 54ed21')) + assert.deepEqual(header.tags, ['"67ab43"', '"54ed21"']) + }) + + it('converts to a string', () => { + let header = new IfMatch('W/"67ab43", "54ed21"') + assert.equal(header.toString(), 'W/"67ab43", "54ed21"') + }) + + describe('has()', () => { + it('checks if a tag is present', () => { + let header = new IfMatch('67ab43, 54ed21') + assert.ok(header.has('"67ab43"')) + assert.ok(header.has('"54ed21"')) + assert.ok(!header.has('"7892dd"')) + assert.ok(!header.has('*')) + + let header2 = new IfMatch('W/"67ab43", "54ed21"') + assert.ok(header2.has('W/"67ab43"')) + assert.ok(header2.has('"54ed21"')) + assert.ok(!header2.has('"7892dd"')) + }) + }) + + describe('matches()', () => { + it('returns true when header is not present', () => { + let emptyHeader = new IfMatch() + assert.ok(emptyHeader.matches('"67ab43"')) + }) + + it('returns true when header is present and matches', () => { + let matchingHeader = new IfMatch('67ab43, 54ed21') + assert.ok(matchingHeader.matches('"67ab43"')) + assert.ok(matchingHeader.matches('"54ed21"')) + }) + + it('returns false when header is present but does not match', () => { + let matchingHeader = new IfMatch('67ab43, 54ed21') + assert.ok(!matchingHeader.matches('"7892dd"')) + }) + + it('returns true when wildcard is present', () => { + let wildcardHeader = new IfMatch('*') + assert.ok(wildcardHeader.matches('"67ab43"')) + assert.ok(wildcardHeader.matches('"anything"')) + }) + }) +}) diff --git a/packages/headers/src/lib/if-match.ts b/packages/headers/src/lib/if-match.ts new file mode 100644 index 00000000000..88a7eadf818 --- /dev/null +++ b/packages/headers/src/lib/if-match.ts @@ -0,0 +1,64 @@ +import { type HeaderValue } from './header-value.ts' +import { quoteEtag } from './utils.ts' + +export interface IfMatchInit { + /** + * The entity tags to compare against the current entity. + */ + tags: string[] +} + +/** + * The value of an `If-Match` HTTP header. + * + * [MDN `If-Match` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Match) + * + * [HTTP/1.1 Specification](https://datatracker.ietf.org/doc/html/rfc7232#section-3.1) + */ +export class IfMatch implements HeaderValue, IfMatchInit { + tags: string[] = [] + + constructor(init?: string | string[] | IfMatchInit) { + if (init) { + if (typeof init === 'string') { + this.tags.push(...init.split(/\s*,\s*/).map(quoteEtag)) + } else if (Array.isArray(init)) { + this.tags.push(...init.map(quoteEtag)) + } else { + this.tags.push(...init.tags.map(quoteEtag)) + } + } + } + + /** + * Checks if the header contains the given entity tag. + * + * Note: This method checks only for exact matches and does not consider wildcards. + * + * @param tag The entity tag to check for. + * @returns `true` if the tag is present in the header, `false` otherwise. + */ + has(tag: string): boolean { + return this.tags.includes(quoteEtag(tag)) + } + + /** + * Checks if the precondition passes for the given entity tag. + * + * Note: This method returns `true` if the `If-Match` header is not present, + * regardless of the entity tag being checked since the precondition passes. + * + * @param tag The entity tag to check against. + * @returns `true` if the precondition passes, `false` if it fails (should return 412). + */ + matches(tag: string): boolean { + if (this.tags.length === 0) { + return true + } + return this.has(tag) || this.tags.includes('*') // Present and matches or wildcard = pass + } + + toString() { + return this.tags.join(', ') + } +} diff --git a/packages/headers/src/lib/range.test.ts b/packages/headers/src/lib/range.test.ts new file mode 100644 index 00000000000..1865bfc73b5 --- /dev/null +++ b/packages/headers/src/lib/range.test.ts @@ -0,0 +1,250 @@ +import * as assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { Range } from './range.ts' + +describe('Range', () => { + it('initializes with an empty string', () => { + let range = new Range('') + assert.equal(range.unit, '') + assert.deepEqual(range.ranges, []) + }) + + describe('parsing from string', () => { + it('parses a simple range', () => { + let range = new Range('bytes=0-99') + assert.equal(range.unit, 'bytes') + assert.equal(range.ranges.length, 1) + assert.equal(range.ranges[0].start, 0) + assert.equal(range.ranges[0].end, 99) + }) + + it('parses a range with only start', () => { + let range = new Range('bytes=100-') + assert.equal(range.unit, 'bytes') + assert.equal(range.ranges.length, 1) + assert.equal(range.ranges[0].start, 100) + assert.equal(range.ranges[0].end, undefined) + }) + + it('parses a suffix range (only end)', () => { + let range = new Range('bytes=-500') + assert.equal(range.unit, 'bytes') + assert.equal(range.ranges.length, 1) + assert.equal(range.ranges[0].start, undefined) + assert.equal(range.ranges[0].end, 500) + }) + + it('parses multiple ranges', () => { + let range = new Range('bytes=0-99,200-299,400-') + assert.equal(range.unit, 'bytes') + assert.equal(range.ranges.length, 3) + assert.equal(range.ranges[0].start, 0) + assert.equal(range.ranges[0].end, 99) + assert.equal(range.ranges[1].start, 200) + assert.equal(range.ranges[1].end, 299) + assert.equal(range.ranges[2].start, 400) + assert.equal(range.ranges[2].end, undefined) + }) + + it('handles malformed range with no bounds', () => { + let range = new Range('bytes=-') + assert.equal(range.ranges.length, 0) + }) + + it('handles completely invalid syntax', () => { + let range = new Range('not-a-range') + assert.equal(range.ranges.length, 0) + }) + + it('handles whitespace in ranges', () => { + let range = new Range('bytes=0-99, 200-299') + assert.equal(range.ranges.length, 2) + assert.equal(range.ranges[0].start, 0) + assert.equal(range.ranges[0].end, 99) + assert.equal(range.ranges[1].start, 200) + assert.equal(range.ranges[1].end, 299) + }) + }) + + describe('construction from object', () => { + it('creates range from object init', () => { + let range = new Range({ + unit: 'bytes', + ranges: [{ start: 0, end: 99 }], + }) + assert.equal(range.unit, 'bytes') + assert.equal(range.ranges.length, 1) + assert.equal(range.ranges[0].start, 0) + assert.equal(range.ranges[0].end, 99) + }) + + it('uses empty unit if not specified', () => { + let range = new Range({ + ranges: [{ start: 0, end: 99 }], + }) + assert.equal(range.unit, '') + }) + + it('initializes with another Range', () => { + let range1 = new Range('bytes=0-99') + let range2 = new Range({ + unit: range1.unit, + ranges: range1.ranges, + }) + assert.equal(range2.unit, 'bytes') + assert.equal(range2.ranges.length, 1) + assert.equal(range2.ranges[0].start, 0) + assert.equal(range2.ranges[0].end, 99) + }) + }) + + describe('canSatisfy', () => { + it('returns true when range is within resource', () => { + let range = new Range('bytes=0-99') + assert.equal(range.canSatisfy(1000), true) + }) + + it('returns true when range starts within resource', () => { + let range = new Range('bytes=100-') + assert.equal(range.canSatisfy(1000), true) + }) + + it('returns true for suffix range', () => { + let range = new Range('bytes=-500') + assert.equal(range.canSatisfy(1000), true) + }) + + it('returns false when range starts beyond resource', () => { + let range = new Range('bytes=1000-') + assert.equal(range.canSatisfy(500), false) + }) + + it('returns false for empty ranges', () => { + let range = new Range({ ranges: [] }) + assert.equal(range.canSatisfy(1000), false) + }) + + it('returns false when start > end', () => { + let range = new Range({ + ranges: [{ start: 100, end: 50 }], + }) + assert.equal(range.canSatisfy(1000), false) + }) + + it('returns false when range has no bounds', () => { + let range = new Range({ + ranges: [{}], + }) + assert.equal(range.canSatisfy(1000), false) + }) + + it('returns false for malformed range string', () => { + let range = new Range('bytes=-') + assert.equal(range.canSatisfy(1000), false) + }) + + it('returns true when at least one range is satisfiable', () => { + let range = new Range('bytes=1000-,0-99') + assert.equal(range.canSatisfy(500), true) + }) + }) + + describe('normalize', () => { + it('normalizes simple range', () => { + let range = new Range('bytes=0-99') + let normalized = range.normalize(1000) + assert.equal(normalized.length, 1) + assert.equal(normalized[0].start, 0) + assert.equal(normalized[0].end, 99) + }) + + it('normalizes start-only range', () => { + let range = new Range('bytes=100-') + let normalized = range.normalize(1000) + assert.equal(normalized.length, 1) + assert.equal(normalized[0].start, 100) + assert.equal(normalized[0].end, 999) + }) + + it('normalizes suffix range', () => { + let range = new Range('bytes=-500') + let normalized = range.normalize(1000) + assert.equal(normalized.length, 1) + assert.equal(normalized[0].start, 500) + assert.equal(normalized[0].end, 999) + }) + + it('clamps end to file size', () => { + let range = new Range('bytes=0-5000') + let normalized = range.normalize(1000) + assert.equal(normalized.length, 1) + assert.equal(normalized[0].start, 0) + assert.equal(normalized[0].end, 999) + }) + + it('normalizes multiple ranges', () => { + let range = new Range('bytes=0-99,200-299') + let normalized = range.normalize(1000) + assert.equal(normalized.length, 2) + assert.equal(normalized[0].start, 0) + assert.equal(normalized[0].end, 99) + assert.equal(normalized[1].start, 200) + assert.equal(normalized[1].end, 299) + }) + + it('returns empty array for unsatisfiable range', () => { + let range = new Range('bytes=1000-') + let normalized = range.normalize(500) + assert.equal(normalized.length, 0) + }) + + it('returns empty array for malformed range', () => { + let range = new Range('bytes=-') + let normalized = range.normalize(1000) + assert.equal(normalized.length, 0) + }) + + it('handles suffix larger than file size', () => { + let range = new Range('bytes=-5000') + let normalized = range.normalize(1000) + assert.equal(normalized.length, 1) + assert.equal(normalized[0].start, 0) + assert.equal(normalized[0].end, 999) + }) + }) + + describe('toString', () => { + it('converts simple range to string', () => { + let range = new Range('bytes=0-99') + assert.equal(range.toString(), 'bytes=0-99') + }) + + it('converts start-only range to string', () => { + let range = new Range('bytes=100-') + assert.equal(range.toString(), 'bytes=100-') + }) + + it('converts suffix range to string', () => { + let range = new Range('bytes=-500') + assert.equal(range.toString(), 'bytes=-500') + }) + + it('converts multiple ranges to string', () => { + let range = new Range('bytes=0-99,200-299') + assert.equal(range.toString(), 'bytes=0-99,200-299') + }) + + it('returns empty string for empty ranges', () => { + let range = new Range({ ranges: [] }) + assert.equal(range.toString(), '') + }) + + it('returns empty string when unit is not set', () => { + let range = new Range() + range.unit = '' + range.ranges = [{ start: 0, end: 99 }] + assert.equal(range.toString(), '') + }) + }) +}) diff --git a/packages/headers/src/lib/range.ts b/packages/headers/src/lib/range.ts new file mode 100644 index 00000000000..cdf13cb119b --- /dev/null +++ b/packages/headers/src/lib/range.ts @@ -0,0 +1,164 @@ +import { type HeaderValue } from './header-value.ts' + +export interface RangeInit { + /** + * The unit of the range, typically "bytes" + */ + unit?: string + /** + * The ranges requested. Each range has optional start and end values. + * - {start: 0, end: 99} = bytes 0-99 + * - {start: 100} = bytes 100- (from 100 to end) + * - {end: 500} = bytes -500 (last 500 bytes) + */ + ranges?: Array<{ start?: number; end?: number }> +} + +/** + * The value of a `Range` HTTP header. + * + * [MDN `Range` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range) + * + * [HTTP/1.1 Specification](https://httpwg.org/specs/rfc9110.html#field.range) + */ +export class Range implements HeaderValue, RangeInit { + unit: string = '' + ranges: Array<{ start?: number; end?: number }> = [] + + constructor(init?: string | RangeInit) { + if (init) { + if (typeof init === 'string') { + // Parse: "bytes=200-1000" or "bytes=200-" or "bytes=-500" or "bytes=0-99,200-299" + let match = init.match(/^(\w+)=(.+)$/) + if (match) { + this.unit = match[1] + let rangeParts = match[2].split(',') + + // Track if any range part is invalid to mark the entire header as malformed + let hasInvalidPart = false + + for (let part of rangeParts) { + let rangeMatch = part.trim().match(/^(\d*)-(\d*)$/) + if (!rangeMatch) { + // Invalid syntax for this range part + hasInvalidPart = true + continue + } + + let [, startStr, endStr] = rangeMatch + // At least one bound must be specified + if (!startStr && !endStr) { + hasInvalidPart = true + continue + } + + let start = startStr ? parseInt(startStr, 10) : undefined + let end = endStr ? parseInt(endStr, 10) : undefined + + // If both bounds are specified, start must be <= end + if (start !== undefined && end !== undefined && start > end) { + hasInvalidPart = true + continue + } + + this.ranges.push({ start, end }) + } + + // If any part was invalid, mark as malformed by clearing ranges + if (hasInvalidPart) { + this.ranges = [] + } + } + } else { + if (init.unit !== undefined) this.unit = init.unit + if (init.ranges !== undefined) this.ranges = init.ranges + } + } + } + + /** + * Checks if this range can be satisfied for a resource of the given size. + * Returns false if the range is malformed or all ranges are beyond the resource size. + */ + canSatisfy(resourceSize: number): boolean { + // No unit or no ranges means header was malformed or empty + if (!this.unit || this.ranges.length === 0) return false + + // Validate all ranges first + for (let range of this.ranges) { + // At least one bound must be specified + if (range.start === undefined && range.end === undefined) { + return false + } + // If both are specified, start must be <= end + if (range.start !== undefined && range.end !== undefined && range.start > range.end) { + return false + } + } + + // Check if at least one range is within the resource + for (let range of this.ranges) { + if (range.start === undefined) { + // Suffix range (e.g., "-500") is always satisfiable + return true + } + if (range.start < resourceSize) { + // At least one range starts within the resource + return true + } + } + + return false + } + + /** + * Normalizes the ranges for a resource of the given size. + * Returns an array of ranges with resolved start and end values. + * Returns an empty array if the range cannot be satisfied. + */ + normalize(resourceSize: number): Array<{ start: number; end: number }> { + if (!this.canSatisfy(resourceSize)) { + return [] + } + + return this.ranges.map((range) => { + if (range.start !== undefined && range.end !== undefined) { + // Both bounds specified (e.g., "0-99") + return { + start: range.start, + end: Math.min(range.end, resourceSize - 1), + } + } else if (range.start !== undefined) { + // Only start specified (e.g., "100-") + return { + start: range.start, + end: resourceSize - 1, + } + } else { + // Only end specified (e.g., "-500" means last 500 bytes) + let suffix = range.end! + return { + start: Math.max(0, resourceSize - suffix), + end: resourceSize - 1, + } + } + }) + } + + toString(): string { + if (!this.unit || this.ranges.length === 0) return '' + + let rangeParts = this.ranges.map((range) => { + if (range.start !== undefined && range.end !== undefined) { + return `${range.start}-${range.end}` + } else if (range.start !== undefined) { + return `${range.start}-` + } else if (range.end !== undefined) { + return `-${range.end}` + } + return '' + }) + + return `${this.unit}=${rangeParts.join(',')}` + } +} diff --git a/packages/headers/src/lib/super-headers.test.ts b/packages/headers/src/lib/super-headers.test.ts index c36d744fd2c..caaaf2540fe 100644 --- a/packages/headers/src/lib/super-headers.test.ts +++ b/packages/headers/src/lib/super-headers.test.ts @@ -9,8 +9,10 @@ import { ContentDisposition } from './content-disposition.ts' import { ContentRange } from './content-range.ts' import { ContentType } from './content-type.ts' import { Cookie } from './cookie.ts' -import { SuperHeaders } from './super-headers.ts' +import { IfMatch } from './if-match.ts' import { IfNoneMatch } from './if-none-match.ts' +import { Range } from './range.ts' +import { SuperHeaders } from './super-headers.ts' describe('SuperHeaders', () => { it('is an instance of Headers', () => { @@ -271,6 +273,11 @@ describe('SuperHeaders', () => { assert.equal(headers.get('If-Modified-Since'), 'Fri, 01 Jan 2021 00:00:00 GMT') }) + it('handles the ifMatch property', () => { + let headers = new SuperHeaders({ ifMatch: ['67ab43', '54ed21'] }) + assert.equal(headers.get('If-Match'), '"67ab43", "54ed21"') + }) + it('handles the ifNoneMatch property', () => { let headers = new SuperHeaders({ ifNoneMatch: ['67ab43', '54ed21'] }) assert.equal(headers.get('If-None-Match'), '"67ab43", "54ed21"') @@ -416,6 +423,21 @@ describe('SuperHeaders', () => { assert.equal(headers.age, null) }) + it('supports the allow property', () => { + let headers = new SuperHeaders() + + assert.equal(headers.allow, null) + + headers.allow = 'GET, HEAD' + assert.equal(headers.allow, 'GET, HEAD') + + headers.allow = ['GET', 'POST', 'PUT', 'DELETE'] + assert.equal(headers.allow, 'GET, POST, PUT, DELETE') + + headers.allow = null + assert.equal(headers.allow, null) + }) + it('supports the cacheControl property', () => { let headers = new SuperHeaders() @@ -652,6 +674,26 @@ describe('SuperHeaders', () => { assert.equal(headers.ifModifiedSince, null) }) + it('supports the ifMatch property', () => { + let headers = new SuperHeaders() + + assert.ok(headers.ifMatch instanceof IfMatch) + assert.equal(headers.ifMatch.tags.length, 0) + + headers.ifMatch = '67ab43' + assert.deepEqual(headers.ifMatch.tags, ['"67ab43"']) + + headers.ifMatch = ['67ab43', '54ed21'] + assert.deepEqual(headers.ifMatch.tags, ['"67ab43"', '"54ed21"']) + + headers.ifMatch = { tags: ['W/"67ab43"'] } + assert.deepEqual(headers.ifMatch.tags, ['W/"67ab43"']) + + headers.ifMatch = null + assert.ok(headers.ifMatch instanceof IfMatch) + assert.equal(headers.ifMatch.tags.length, 0) + }) + it('supports the ifNoneMatch property', () => { let headers = new SuperHeaders() @@ -709,6 +751,29 @@ describe('SuperHeaders', () => { assert.equal(headers.location, null) }) + it('supports the range property', () => { + let headers = new SuperHeaders() + + assert.ok(headers.range instanceof Range) + assert.equal(headers.range.ranges.length, 0) + + headers.range = 'bytes=0-99' + assert.equal(headers.range.unit, 'bytes') + assert.equal(headers.range.ranges.length, 1) + assert.equal(headers.range.ranges[0].start, 0) + assert.equal(headers.range.ranges[0].end, 99) + + headers.range = { unit: 'bytes', ranges: [{ start: 100, end: 199 }] } + assert.equal(headers.range.unit, 'bytes') + assert.equal(headers.range.ranges.length, 1) + assert.equal(headers.range.ranges[0].start, 100) + assert.equal(headers.range.ranges[0].end, 199) + + headers.range = null + assert.ok(headers.range instanceof Range) + assert.equal(headers.range.ranges.length, 0) + }) + it('supports the referer property', () => { let headers = new SuperHeaders() diff --git a/packages/headers/src/lib/super-headers.ts b/packages/headers/src/lib/super-headers.ts index 10fc6ddf38b..8cface1f93e 100644 --- a/packages/headers/src/lib/super-headers.ts +++ b/packages/headers/src/lib/super-headers.ts @@ -8,7 +8,9 @@ import { type ContentTypeInit, ContentType } from './content-type.ts' import { type CookieInit, Cookie } from './cookie.ts' import { canonicalHeaderName } from './header-names.ts' import { type HeaderValue } from './header-value.ts' +import { type IfMatchInit, IfMatch } from './if-match.ts' import { type IfNoneMatchInit, IfNoneMatch } from './if-none-match.ts' +import { type RangeInit, Range } from './range.ts' import { type SetCookieInit, SetCookie } from './set-cookie.ts' import { isIterable, quoteEtag } from './utils.ts' @@ -35,6 +37,10 @@ interface SuperHeadersPropertyInit { * The [`Age`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Age) header value. */ age?: string | number + /** + * The [`Allow`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Allow) header value. + */ + allow?: string | string[] /** * The [`Cache-Control`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control) header value. */ @@ -91,6 +97,10 @@ interface SuperHeadersPropertyInit { * The [`If-Modified-Since`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Modified-Since) header value. */ ifModifiedSince?: string | DateInit + /** + * The [`If-Match`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Match) header value. + */ + ifMatch?: string | string[] | IfMatchInit /** * The [`If-None-Match`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match) header value. */ @@ -107,6 +117,10 @@ interface SuperHeadersPropertyInit { * The [`Location`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Location) header value. */ location?: string + /** + * The [`Range`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range) header value. + */ + range?: string | RangeInit /** * The [`Referer`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referer) header value. */ @@ -128,6 +142,7 @@ const AcceptEncodingKey = 'accept-encoding' const AcceptLanguageKey = 'accept-language' const AcceptRangesKey = 'accept-ranges' const AgeKey = 'age' +const AllowKey = 'allow' const CacheControlKey = 'cache-control' const ConnectionKey = 'connection' const ContentDispositionKey = 'content-disposition' @@ -141,11 +156,13 @@ const DateKey = 'date' const ETagKey = 'etag' const ExpiresKey = 'expires' const HostKey = 'host' +const IfMatchKey = 'if-match' const IfModifiedSinceKey = 'if-modified-since' const IfNoneMatchKey = 'if-none-match' const IfUnmodifiedSinceKey = 'if-unmodified-since' const LastModifiedKey = 'last-modified' const LocationKey = 'location' +const RangeKey = 'range' const RefererKey = 'referer' const SetCookieKey = 'set-cookie' @@ -423,6 +440,21 @@ export class SuperHeaders extends Headers { this.#setNumberValue(AgeKey, value) } + /** + * The `Allow` header lists the HTTP methods that are supported by the resource. + * + * [MDN `Allow` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Allow) + * + * [HTTP/1.1 Specification](https://httpwg.org/specs/rfc9110.html#field.allow) + */ + get allow(): string | null { + return this.#getStringValue(AllowKey) + } + + set allow(value: string | string[] | undefined | null) { + this.#setStringValue(AllowKey, Array.isArray(value) ? value.join(', ') : value) + } + /** * The `Cache-Control` header contains directives for caching mechanisms in both requests and responses. * @@ -643,6 +675,21 @@ export class SuperHeaders extends Headers { this.#setDateValue(IfModifiedSinceKey, value) } + /** + * The `If-Match` header makes a request conditional on the presence of a matching ETag. + * + * [MDN `If-Match` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Match) + * + * [HTTP/1.1 Specification](https://datatracker.ietf.org/doc/html/rfc7232#section-3.1) + */ + get ifMatch(): IfMatch { + return this.#getHeaderValue(IfMatchKey, IfMatch) + } + + set ifMatch(value: string | string[] | IfMatchInit | undefined | null) { + this.#setHeaderValue(IfMatchKey, IfMatch, value) + } + /** * The `If-None-Match` header makes a request conditional on the absence of a matching ETag. * @@ -704,6 +751,21 @@ export class SuperHeaders extends Headers { this.#setStringValue(LocationKey, value) } + /** + * The `Range` header indicates the part of a resource that the client wants to receive. + * + * [MDN `Range` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range) + * + * [HTTP/1.1 Specification](https://httpwg.org/specs/rfc9110.html#field.range) + */ + get range(): Range { + return this.#getHeaderValue(RangeKey, Range) + } + + set range(value: string | RangeInit | undefined | null) { + this.#setHeaderValue(RangeKey, Range, value) + } + /** * The `Referer` header contains the address of the previous web page from which a link to the * currently requested page was followed. From 7507f2b07c35b56fe0992c181d0cce69b24b4829 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Thu, 6 Nov 2025 17:26:22 +1100 Subject: [PATCH 06/19] Add support for strong ETags --- packages/fetch-router/README.md | 78 +++++- packages/fetch-router/src/file-handler.ts | 8 +- .../fetch-router/src/lib/file-handler.test.ts | 254 +++++++++++++----- packages/fetch-router/src/lib/file-handler.ts | 178 ++++++++++-- .../src/lib/fs-file-resolver.test.ts | 62 +++-- .../fetch-router/src/lib/fs-file-resolver.ts | 3 +- packages/headers/src/lib/if-match.test.ts | 32 +++ packages/headers/src/lib/if-match.ts | 29 +- 8 files changed, 524 insertions(+), 120 deletions(-) diff --git a/packages/fetch-router/README.md b/packages/fetch-router/README.md index b399070aed2..78565f95529 100644 --- a/packages/fetch-router/README.md +++ b/packages/fetch-router/README.md @@ -535,11 +535,14 @@ router.map(routes.admin.dashboard, { }) ``` -### Serving Static Files with `staticFiles()` Middleware +### Serving Static Files -The `staticFiles()` middleware serves static files from the filesystem. The middleware always falls through to the handler if the file is not found, allowing you to customize the 404 response. +The `staticFiles()` middleware serves static files from the filesystem. ```ts +import { createRouter } from '@remix-run/fetch-router' +import { staticFiles } from '@remix-run/fetch-router/static-middleware' + let router = createRouter({ middleware: [staticFiles('./public')], }) @@ -548,24 +551,37 @@ let router = createRouter({ You can further customize the behavior of this middleware by providing additional options: ```ts +import { createRouter } from '@remix-run/fetch-router' +import { staticFiles } from '@remix-run/fetch-router/static-middleware' + let router = createRouter({ middleware: [ staticFiles('./public', { + // Cache-Control header value + // Defaults to `undefined` cacheControl: 'public, max-age=3600', + // Whether to support HTTP Range requests for partial content. // Defaults to `true`. - acceptRanges: false, - // Whether to generate a weak ETag header for the response. - // Defaults to `true`. - etag: false, + acceptRanges: true, + + // 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 a `Last-Modified` header for the response. // Defaults to `true`. - lastModified: false, + lastModified: true, }), ], }) ``` +#### Custom Path Resolution + By default, this middleware uses the full request pathname to resolve files. You can customize this by providing a path resolver function which is passed the request context. For example, to resolve files based on a route param: ```ts @@ -581,6 +597,54 @@ router.get('/assets/*path', { }) ``` +Note that paths returned by this function should be relative to the root directory passed to `staticFiles()`. This is to ensure that files outside of the root directory are not served. + +#### Strong ETags and Content Hashing + +For assets that require strong validation (e.g., to support [`If-Match` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Match) for [`Range` requests](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range)), you can configure strong ETag generation. + +```ts +import { createRouter } from '@remix-run/fetch-router' +import { staticFiles } from '@remix-run/fetch-router/static-middleware' + +let router = createRouter({ + middleware: [ + staticFiles('./public', { + etag: 'strong', + }), + ], +}) +``` + +When using strong ETags, the default behavior is that they are generated on every request with [`SubtleCrypto.digest()`](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest) using the `'SHA-256'` algorithm, but this can be customized if needed. + +```ts +import { createRouter } from '@remix-run/fetch-router' +import { staticFiles } from '@remix-run/fetch-router/static-middleware' + +let router = createRouter({ + middleware: [ + staticFiles('./public', { + etag: 'strong', + + // Specify the SubtleCrypto.digest() hashing algorithm, + // or a custom digest function (`async (file: File) => string`). + // Defaults to `'SHA-256'`. + digest: 'SHA-256', + + // Provide a cache to avoid re-hashing files on every request. + // You may want to use an LRU cache to prevent memory leaks. + // Defaults to `undefined`, meaning that there is no cache. + digestCache: new Map(), + + // Customize the logic for generating the cache key. + // Defaults to `({ path, file }) => `${path}:${file.lastModified}``. + digestCacheKey: ({ path }) => path, + }), + ], +}) +``` + ### Request Context Every middleware and request handler receives a `context` object with useful properties: diff --git a/packages/fetch-router/src/file-handler.ts b/packages/fetch-router/src/file-handler.ts index 26b52a91f34..725dc203fa7 100644 --- a/packages/fetch-router/src/file-handler.ts +++ b/packages/fetch-router/src/file-handler.ts @@ -1,3 +1,7 @@ -export type { FileResolver, FileHandlerOptions } from './lib/file-handler.ts' +export type { + FileResolver, + FileHandlerOptions, + FileDigestFunction, + FileDigestCacheKeyFunction, +} from './lib/file-handler.ts' export { createFileHandler } from './lib/file-handler.ts' - diff --git a/packages/fetch-router/src/lib/file-handler.test.ts b/packages/fetch-router/src/lib/file-handler.test.ts index 2930bd3fdd4..f166f2c81fe 100644 --- a/packages/fetch-router/src/lib/file-handler.test.ts +++ b/packages/fetch-router/src/lib/file-handler.test.ts @@ -14,11 +14,12 @@ describe('createFileHandler', () => { type?: string lastModified?: number } = {}, - ): File { - return new File([content], 'mock.txt', { + ): { file: File; path: string } { + let file = new File([content], 'mock.txt', { type: options.type || 'text/plain', lastModified: options.lastModified || Date.now(), }) + return { file, path: `/path/to/${file.name}` } } function createContext( @@ -46,8 +47,7 @@ describe('createFileHandler', () => { describe('basic functionality', () => { it('serves a file', async () => { - let file = createMockFile('Hello, World!') - let handler = createFileHandler(() => file) + let handler = createFileHandler(() => createMockFile('Hello, World!')) let response = await handler(createContext('http://localhost/test.txt')) @@ -58,8 +58,7 @@ describe('createFileHandler', () => { }) it('serves a file with HEAD request', async () => { - let file = createMockFile('Hello, World!') - let handler = createFileHandler(() => file) + let handler = createFileHandler(() => createMockFile('Hello, World!')) let response = await handler(createContext('http://localhost/test.txt', { method: 'HEAD' })) @@ -79,8 +78,7 @@ describe('createFileHandler', () => { }) it('returns 405 for unsupported methods', async () => { - let file = createMockFile('Hello, World!') - let handler = createFileHandler(() => file) + let handler = createFileHandler(() => createMockFile('Hello, World!')) let response = await handler(createContext('http://localhost/test.txt', { method: 'POST' })) @@ -109,7 +107,7 @@ describe('createFileHandler', () => { describe('ETag support', () => { for (let method of ['GET', 'HEAD'] as const) { describe(method, () => { - it('includes ETag header', async () => { + it('includes weak ETag header by default', async () => { let file = createMockFile('Hello, World!', { lastModified: 1000000 }) let handler = createFileHandler(() => file) @@ -130,6 +128,108 @@ describe('createFileHandler', () => { assert.equal(response.status, 200) assert.equal(response.headers.get('ETag'), null) }) + + it('generates strong ETag when etag=strong', async () => { + let file = createMockFile('Hello, World!') + let handler = createFileHandler(() => file, { etag: 'strong' }) + + let response = await handler(createContext('http://localhost/test.txt', { method })) + + let etag = response.headers.get('ETag') + assert.equal(response.status, 200) + assert.ok(etag) + assert.ok(!etag.startsWith('W/'), 'Should not be a weak ETag') + assert.match(etag, /^"[a-f0-9]+"$/, 'Should be a hex digest wrapped in quotes') + }) + + it('uses SHA-256 by default for strong ETags', async () => { + let file = createMockFile('Hello, World!') + let handler = createFileHandler(() => file, { etag: 'strong' }) + + let response = await handler(createContext('http://localhost/test.txt', { method })) + + let etag = response.headers.get('ETag') + assert.ok(etag) + // SHA-256 produces 64 hex characters (32 bytes * 2) + assert.match(etag, /^"[a-f0-9]{64}"$/) + }) + + it('supports custom digest algorithm', async () => { + let file = createMockFile('Hello, World!') + let handler = createFileHandler(() => file, { + etag: 'strong', + digest: 'SHA-512', + }) + + let response = await handler(createContext('http://localhost/test.txt', { method })) + + let etag = response.headers.get('ETag') + assert.ok(etag) + // SHA-512 produces 128 hex characters (64 bytes * 2) + assert.match(etag, /^"[a-f0-9]{128}"$/) + }) + + it('supports custom digest function', async () => { + let file = createMockFile('Hello, World!') + let handler = createFileHandler(() => file, { + etag: 'strong', + digest: async () => 'custom-hash-12345', + }) + + let response = await handler(createContext('http://localhost/test.txt', { method })) + + let etag = response.headers.get('ETag') + assert.equal(etag, '"custom-hash-12345"') + }) + + it('caches digests when digestCache is provided', async () => { + let file = createMockFile('Hello, World!') + let cache = new Map() + let computeCount = 0 + + let handler = createFileHandler(() => file, { + etag: 'strong', + digest: async () => { + computeCount++ + return `digest-${computeCount}` + }, + digestCache: cache, + }) + + // First request - should compute + let response1 = await handler(createContext('http://localhost/test.txt', { method })) + assert.equal(response1.headers.get('ETag'), '"digest-1"') + + // Second request - should use cache + let response2 = await handler(createContext('http://localhost/test.txt', { method })) + assert.equal(response2.headers.get('ETag'), '"digest-1"') + + // Clear cache - should recompute with new digest + cache.clear() + // Third request - should recompute with new digest + let response3 = await handler(createContext('http://localhost/test.txt', { method })) + assert.equal(response3.headers.get('ETag'), '"digest-2"') + + // Fourth request - should use cache + let response4 = await handler(createContext('http://localhost/test.txt', { method })) + assert.equal(response4.headers.get('ETag'), '"digest-2"') + }) + + it('uses custom cache key function', async () => { + let file = createMockFile('Hello, World!', { lastModified: 1000000 }) + let cache = new Map() + + let handler = createFileHandler(() => file, { + etag: 'strong', + digestCache: cache, + digestCacheKey: ({ path }) => `custom:${path}`, // Stable key without mtime + }) + + await handler(createContext('http://localhost/test.txt', { method })) + + // Cache key should be custom format (path defaults to file.name which is 'mock.txt') + assert.ok(cache.has('custom:/path/to/mock.txt')) + }) }) } }) @@ -235,14 +335,37 @@ describe('createFileHandler', () => { for (let method of ['GET', 'HEAD'] as const) { describe(method, () => { describe('precondition validation', () => { - it('returns 200 (OK) when If-Match matches ETag', async () => { + it('returns 412 (Precondition Failed) when resource has weak ETag', async () => { let file = createMockFile('Hello, World!', { lastModified: 1000000 }) let handler = createFileHandler(() => file) let response1 = await handler(createContext('http://localhost/test.txt', { method })) let etag = response1.headers.get('ETag') assert.ok(etag) + assert.ok(etag.startsWith('W/')) // Verify it's a weak ETag + // If-Match uses strong comparison, so weak ETags never match + let response2 = await handler( + createContext('http://localhost/test.txt', { + method, + headers: { 'If-Match': etag }, + }), + ) + + assert.equal(response2.status, 412) + }) + + it('returns 200 (OK) when resource has strong ETag and If-Match matches', async () => { + let file = createMockFile('Hello, World!') + let handler = createFileHandler(() => file, { etag: 'strong' }) + + // Get the strong ETag + let response1 = await handler(createContext('http://localhost/test.txt', { method })) + let etag = response1.headers.get('ETag') + assert.ok(etag) + assert.ok(!etag.startsWith('W/')) // Verify it's a strong ETag + + // If-Match should work with strong ETags let response2 = await handler( createContext('http://localhost/test.txt', { method, @@ -254,52 +377,47 @@ describe('createFileHandler', () => { assert.equal(await response2.text(), method === 'HEAD' ? '' : 'Hello, World!') }) - it('returns 412 (Precondition Failed) when If-Match does not match', async () => { + it('returns 412 (Precondition Failed) when If-Match does not match (weak ETag)', async () => { let file = createMockFile('Hello, World!') let handler = createFileHandler(() => file) let response = await handler( createContext('http://localhost/test.txt', { method, - headers: { 'If-Match': 'W/"wrong-etag"' }, + headers: { 'If-Match': '"wrong-etag"' }, }), ) assert.equal(response.status, 412) }) - it('returns 200 (OK) when If-Match is *', async () => { + it('returns 412 (Precondition Failed) when If-Match does not match (strong ETag)', async () => { let file = createMockFile('Hello, World!') - let handler = createFileHandler(() => file) + let handler = createFileHandler(() => file, { etag: 'strong' }) let response = await handler( createContext('http://localhost/test.txt', { method, - headers: { 'If-Match': '*' }, + headers: { 'If-Match': '"wrong-etag"' }, }), ) - assert.equal(response.status, 200) - assert.equal(await response.text(), method === 'HEAD' ? '' : 'Hello, World!') + assert.equal(response.status, 412) }) - it('returns 200 (OK) when If-Match contains multiple ETags and one matches', async () => { - let file = createMockFile('Hello, World!', { lastModified: 1000000 }) + it('returns 200 (OK) when If-Match is *', async () => { + let file = createMockFile('Hello, World!') let handler = createFileHandler(() => file) - let response1 = await handler(createContext('http://localhost/test.txt', { method })) - let etag = response1.headers.get('ETag') - assert.ok(etag) - - let response2 = await handler( + let response = await handler( createContext('http://localhost/test.txt', { method, - headers: { 'If-Match': `W/"wrong-1", ${etag}, W/"wrong-2"` }, + headers: { 'If-Match': '*' }, }), ) - assert.equal(response2.status, 200) - assert.equal(await response2.text(), method === 'HEAD' ? '' : 'Hello, World!') + assert.equal(response.status, 200) + assert.equal(await response.text(), method === 'HEAD' ? '' : 'Hello, World!') }) it('returns 412 (Precondition Failed) when If-Match contains multiple ETags and none match', async () => { @@ -309,7 +427,7 @@ describe('createFileHandler', () => { let response = await handler( createContext('http://localhost/test.txt', { method, - headers: { 'If-Match': 'W/"wrong-1", W/"wrong-2"' }, + headers: { 'If-Match': '"wrong-1", "wrong-2"' }, }), ) @@ -440,47 +558,50 @@ describe('createFileHandler', () => { }) describe('prioritization', () => { - it('ignores If-Unmodified-Since when If-Match is present', async () => { + it('returns 412 (Precondition Failed) when If-Match fails, even if If-Unmodified-Since would pass', async () => { let fileDate = new Date('2025-01-01') - let pastDate = new Date('2024-01-01') + let futureDate = new Date('2026-01-01') let file = createMockFile('Hello, World!', { lastModified: fileDate.getTime() }) let handler = createFileHandler(() => file) - let response1 = await handler(createContext('http://localhost/test.txt', { method })) - let etag = response1.headers.get('ETag') - assert.ok(etag) - - let response2 = await handler( + let response = await handler( createContext('http://localhost/test.txt', { method, headers: { - 'If-Match': etag, - 'If-Unmodified-Since': pastDate.toUTCString(), + 'If-Match': 'W/"wrong-etag"', + 'If-Unmodified-Since': futureDate.toUTCString(), }, }), ) - assert.equal(response2.status, 200) - assert.equal(await response2.text(), method === 'HEAD' ? '' : 'Hello, World!') + assert.equal(response.status, 412) }) - it('returns 412 (Precondition Failed) when If-Match fails, even if If-Unmodified-Since would pass', async () => { - let fileDate = new Date('2025-01-01') - let futureDate = new Date('2026-01-01') - let file = createMockFile('Hello, World!', { lastModified: fileDate.getTime() }) - let handler = createFileHandler(() => file) + it('ignores If-Unmodified-Since when If-Match is present (strong ETag)', async () => { + let pastDate = new Date('2024-01-01') + let file = createMockFile('Hello, World!', { lastModified: pastDate.getTime() }) + let handler = createFileHandler(() => file, { etag: 'strong' }) - let response = await handler( + // Get the strong ETag + let response1 = await handler(createContext('http://localhost/test.txt', { method })) + let etag = response1.headers.get('ETag') + assert.ok(etag) + assert.ok(!etag.startsWith('W/')) // Verify it's a strong ETag + + // If-Match passes, so If-Unmodified-Since should be ignored + // (even though it would fail if evaluated - pastDate is before file's lastModified) + let response2 = await handler( createContext('http://localhost/test.txt', { method, headers: { - 'If-Match': 'W/"wrong-etag"', - 'If-Unmodified-Since': futureDate.toUTCString(), + 'If-Match': etag, + 'If-Unmodified-Since': pastDate.toUTCString(), }, }), ) - assert.equal(response.status, 412) + assert.equal(response2.status, 200) + assert.equal(await response2.text(), method === 'HEAD' ? '' : 'Hello, World!') }) }) @@ -780,43 +901,46 @@ describe('createFileHandler', () => { assert.equal(response.headers.get('Content-Range'), null) }) - it('returns 206 (Partial Content) when If-Match succeeds with Range request', async () => { - let file = createMockFile('0123456789', { lastModified: 1000000 }) + it('returns 412 (Precondition Failed) when If-Match fails before processing Range', async () => { + let file = createMockFile('0123456789') let handler = createFileHandler(() => file) - let response1 = await handler(createContext('http://localhost/test.txt')) - let etag = response1.headers.get('ETag') - assert.ok(etag) - - let response2 = await handler( + let response = await handler( createContext('http://localhost/test.txt', { headers: { - 'If-Match': etag, + 'If-Match': 'W/"wrong-etag"', Range: 'bytes=0-4', }, }), ) - assert.equal(response2.status, 206) - assert.equal(await response2.text(), '01234') - assert.equal(response2.headers.get('Content-Range'), 'bytes 0-4/10') + assert.equal(response.status, 412) + assert.equal(response.headers.get('Content-Range'), null) }) - it('returns 412 (Precondition Failed) when If-Match fails before processing Range', async () => { + it('returns 206 (Partial Content) when If-Match succeeds with Range request (strong ETag)', async () => { let file = createMockFile('0123456789') - let handler = createFileHandler(() => file) + let handler = createFileHandler(() => file, { etag: 'strong' }) - let response = await handler( + // Get the strong ETag + let response1 = await handler(createContext('http://localhost/test.txt')) + let etag = response1.headers.get('ETag') + assert.ok(etag) + assert.ok(!etag.startsWith('W/')) // Verify it's a strong ETag + + // If-Match passes, Range should be processed + let response2 = await handler( createContext('http://localhost/test.txt', { headers: { - 'If-Match': 'W/"wrong-etag"', + 'If-Match': etag, Range: 'bytes=0-4', }, }), ) - assert.equal(response.status, 412) - assert.equal(response.headers.get('Content-Range'), null) + assert.equal(response2.status, 206) + assert.equal(await response2.text(), '01234') + assert.equal(response2.headers.get('Content-Range'), 'bytes 0-4/10') }) it('returns 206 (Partial Content) when If-Unmodified-Since passes with Range request', async () => { diff --git a/packages/fetch-router/src/lib/file-handler.ts b/packages/fetch-router/src/lib/file-handler.ts index 2076a1a4465..04a6e024977 100644 --- a/packages/fetch-router/src/lib/file-handler.ts +++ b/packages/fetch-router/src/lib/file-handler.ts @@ -7,7 +7,49 @@ import type { RequestMethod } from './request-methods.ts' export type FileResolver< Method extends RequestMethod | 'ANY' = RequestMethod | 'ANY', Params extends Record = {}, -> = (context: RequestContext) => File | null | Promise +> = ( + context: RequestContext, +) => { file: File; path: string } | null | Promise<{ file: File; path: string } | null> + +/** + * Custom function for computing file digests. + * + * @param file - The file to hash + * @returns The computed digest as a string + * + * @example + * async (file) => { + * let buffer = await file.arrayBuffer() + * return customHash(buffer) + * } + */ +export type FileDigestFunction = (file: File) => Promise + +/** + * Function to generate cache keys for digest storage. + * + * @param params - Object containing the file path and File object + * @returns The cache key as a string + * + * @example + * ({ path, file }) => `${path}:${file.lastModified}` + * @example + * ({ path, file }) => `v2:${path}:${file.lastModified}` + */ +export type FileDigestCacheKeyFunction = (params: { path: string; file: File }) => string + +/** + * Hash algorithm name for SubtleCrypto.digest() or custom digest function. + */ +type DigestAlgorithm = 'SHA-256' | 'SHA-512' | 'SHA-384' | 'SHA-1' | (string & {}) // Allows any string while providing autocomplete for common algorithms + +/** + * Cache interface for storing computed file digests. + */ +interface DigestCache { + get(key: string): Promise | string | undefined + set(key: string, digest: string): void +} export interface FileHandlerOptions { /** @@ -20,14 +62,58 @@ export interface FileHandlerOptions { cacheControl?: string /** - * Whether to generate ETags for files. + * ETag generation strategy. * - * ETags are generated using a weak tag format based on file size and last modified time: - * `W/"-"` + * - `'weak'`: Generates weak ETags based on file size and last modified time (`W/"-"`) + * - `'strong'`: Generates strong ETags by hashing file content (requires digest computation) + * - `false`: Disables ETag generation * - * @default true + * @default 'weak' + */ + etag?: false | 'weak' | 'strong' + + /** + * Hash algorithm or custom digest function for strong ETags. + * + * When `etag` is `'strong'`, this determines how the file content is hashed. + * - String: Algorithm name for SubtleCrypto.digest() (e.g., 'SHA-256', 'SHA-512') + * - 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 'SHA-512' + * @example async (file) => customHash(await file.arrayBuffer()) + */ + digest?: DigestAlgorithm | FileDigestFunction + + /** + * Cache for storing computed file digests to avoid re-hashing files. + * + * Only used when `etag: 'strong'`. Since hashing file content is expensive, + * providing a cache is strongly recommended for production use. + * + * Any object with `get(key)` and `set(key, digest)` methods can be used. + * If not provided, digests will be computed on every request. + * + * @example new Map() */ - etag?: boolean + digestCache?: DigestCache + + /** + * Function to generate cache keys for digest storage. + * + * The default includes file path and last modified time to ensure cache invalidation + * when files change: `${path}:${file.lastModified}` + * + * The `path` is provided by the file resolver. + * + * Only used when `etag: 'strong'` and `digestCache` is provided. + * + * @default ({ path, file }) => `${path}:${file.lastModified}` + * @example ({ path, file }) => `prefix:${path}:${file.lastModified}` + */ + digestCacheKey?: FileDigestCacheKeyFunction /** * Whether to include Last-Modified headers. @@ -51,14 +137,14 @@ export interface FileHandlerOptions { * Creates a file handler that implements HTTP semantics for serving files. * * The handler can be used directly as a route handler, or wrapped in middleware - * that intercepts 404 responses to fall through to other handlers. + * that intercepts 404/405 responses to fall through to other handlers. * * @param resolveFile - Function that resolves the file for a given request * @param options - Optional configuration for HTTP headers and features * @returns A route handler function * * @example - * // Use directly as a route handler + * // Use directly as a route handler with weak ETags (default) * let fileHandler = createFileHandler( * async (context) => { * let filePath = path.join('/files', context.params.path) @@ -67,16 +153,21 @@ export interface FileHandlerOptions { * } catch { * return null // -> 404 * } - * }, - * { - * etag: true, - * acceptRanges: true * } * ) * * router.get('/files/*path', fileHandler) * * @example + * // Use strong ETags with caching + * + * let fileHandler = createFileHandler(resolver, { + * etag: 'strong', + * digestCache: new Map() + * cacheControl: 'public, max-age=31536000, immutable' + * }) + * + * @example * // Wrap in custom middleware * router.get('/files/*path', async (context) => { * let response = await fileHandler(context) @@ -95,7 +186,11 @@ export function createFileHandler< ): RequestHandler { let { cacheControl, - etag: etagEnabled = true, + etag: etagStrategy = 'weak', + digest: digestOption = 'SHA-256', + digestCache, + digestCacheKey = ({ path, file }: { path: string; file: File }) => + `${path}:${file.lastModified}`, lastModified: lastModifiedEnabled = true, acceptRanges: acceptRangesEnabled = true, } = options @@ -114,18 +209,23 @@ export function createFileHandler< } // Resolve the file - let file = await resolveFile(context) + let resolved = await resolveFile(context) - if (!file) { + if (!resolved) { return new Response('Not Found', { status: 404 }) } + let { file, path } = resolved + let contentType = file.type let contentLength = file.size let etag: string | undefined - if (etagEnabled) { + if (etagStrategy === 'weak') { etag = generateWeakETag(file) + } else if (etagStrategy === 'strong') { + let digest = await computeDigest(path, file, digestOption, digestCache, digestCacheKey) + etag = `"${digest}"` } let lastModified: number | undefined @@ -280,6 +380,52 @@ function generateWeakETag(file: File): string { return `W/"${file.size}-${file.lastModified}"` } +/** + * Computes a digest (hash) for a file, with optional caching. + */ +async function computeDigest( + path: string, + file: File, + digestOption: DigestAlgorithm | FileDigestFunction, + cache: DigestCache | undefined, + getCacheKey: FileDigestCacheKeyFunction, +): Promise { + if (cache) { + let key = getCacheKey({ path, file }) + let cached = await cache.get(key) + if (cached) { + return cached + } + } + + let digest: string + if (typeof digestOption === 'function') { + // Custom digest function + digest = await digestOption(file) + } else { + // Use SubtleCrypto with algorithm name + digest = await hashFile(file, digestOption) + } + + if (cache) { + let key = getCacheKey({ path, file }) + await cache.set(key, digest) + } + + return digest +} + +/** + * Hashes a file using SubtleCrypto. + */ +async function hashFile(file: File, algorithm: string): Promise { + let buffer = await file.arrayBuffer() + let hashBuffer = await crypto.subtle.digest(algorithm, buffer) + let hashArray = Array.from(new Uint8Array(hashBuffer)) + let hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('') + return hashHex +} + /** * Rounds a timestamp to second precision. * HTTP Last-Modified headers only have second precision, so this is used diff --git a/packages/fetch-router/src/lib/fs-file-resolver.test.ts b/packages/fetch-router/src/lib/fs-file-resolver.test.ts index ab6c7640def..078924a838e 100644 --- a/packages/fetch-router/src/lib/fs-file-resolver.test.ts +++ b/packages/fetch-router/src/lib/fs-file-resolver.test.ts @@ -67,28 +67,32 @@ describe('createFsFileResolver', () => { createTestFile('test.txt', 'Hello, World!') let resolver = createFsFileResolver(tmpDir, requestPathnameResolver) - let file = await resolver(createContext('test.txt')) + let result = await resolver(createContext('test.txt')) - assert.ok(file instanceof File) - assert.equal(await file.text(), 'Hello, World!') - assert.equal(file.type, 'text/plain') + assert.ok(result) + assert.ok(result.file instanceof File) + assert.equal(result.path, path.join(path.resolve(tmpDir), 'test.txt')) + assert.equal(await result.file.text(), 'Hello, World!') + assert.equal(result.file.type, 'text/plain') }) it('resolves files from nested directories', async () => { createTestFile('dir/subdir/file.txt', 'Nested file') let resolver = createFsFileResolver(tmpDir, requestPathnameResolver) - let file = await resolver(createContext('dir/subdir/file.txt')) + let result = await resolver(createContext('dir/subdir/file.txt')) - assert.ok(file instanceof File) - assert.equal(await file.text(), 'Nested file') + assert.ok(result) + assert.ok(result.file instanceof File) + assert.equal(result.path, path.join(path.resolve(tmpDir), 'dir/subdir/file.txt')) + assert.equal(await result.file.text(), 'Nested file') }) it('returns null for non-existent file', async () => { let resolver = createFsFileResolver(tmpDir, requestPathnameResolver) - let file = await resolver(createContext('nonexistent.txt')) + let result = await resolver(createContext('nonexistent.txt')) - assert.equal(file, null) + assert.equal(result, null) }) it('returns null when directory is requested', async () => { @@ -96,18 +100,18 @@ describe('createFsFileResolver', () => { fs.mkdirSync(dirPath) let resolver = createFsFileResolver(tmpDir, requestPathnameResolver) - let file = await resolver(createContext('subdir')) + let result = await resolver(createContext('subdir')) - assert.equal(file, null) + assert.equal(result, null) }) it('returns null when path resolver returns null', async () => { createTestFile('test.txt', 'Hello, World!') let resolver = createFsFileResolver(tmpDir, () => null) - let file = await resolver(createContext('test.txt')) + let result = await resolver(createContext('test.txt')) - assert.equal(file, null) + assert.equal(result, null) }) }) @@ -117,10 +121,12 @@ describe('createFsFileResolver', () => { createTestFile('test.txt', 'Hello') let resolver = createFsFileResolver(relativeTmpDir, requestPathnameResolver) - let file = await resolver(createContext('test.txt')) + let result = await resolver(createContext('test.txt')) - assert.ok(file instanceof File) - assert.equal(await file.text(), 'Hello') + assert.ok(result) + assert.ok(result.file instanceof File) + assert.equal(result.path, path.join(path.resolve(relativeTmpDir), 'test.txt')) + assert.equal(await result.file.text(), 'Hello') }) it('resolves absolute root paths', async () => { @@ -128,10 +134,12 @@ describe('createFsFileResolver', () => { createTestFile('test.txt', 'Hello') let resolver = createFsFileResolver(absoluteTmpDir, requestPathnameResolver) - let file = await resolver(createContext('test.txt')) + let result = await resolver(createContext('test.txt')) - assert.ok(file instanceof File) - assert.equal(await file.text(), 'Hello') + assert.ok(result) + assert.ok(result.file instanceof File) + assert.equal(result.path, path.join(absoluteTmpDir, 'test.txt')) + assert.equal(await result.file.text(), 'Hello') }) }) @@ -145,12 +153,14 @@ describe('createFsFileResolver', () => { let publicDir = path.join(tmpDir, publicDirName) let resolver = createFsFileResolver(publicDir, requestPathnameResolver) - let allowedFile = await resolver(createContext('allowed.txt')) - assert.ok(allowedFile instanceof File) - assert.equal(await allowedFile.text(), 'Allowed content') + let result = await resolver(createContext('allowed.txt')) + assert.ok(result) + assert.ok(result.file instanceof File) + assert.equal(result.path, path.join(path.resolve(publicDir), 'allowed.txt')) + assert.equal(await result.file.text(), 'Allowed content') - let traversalFile = await resolver(createContext('../secret.txt')) - assert.equal(traversalFile, null) + let traversalResult = await resolver(createContext('../secret.txt')) + assert.equal(traversalResult, null) }) it('does not support absolute paths in the resolved path', async () => { @@ -162,8 +172,8 @@ describe('createFsFileResolver', () => { let resolver = createFsFileResolver(tmpDir, () => secretPath) try { - let file = await resolver(createContext('anything')) - assert.equal(file, null) + let result = await resolver(createContext('anything')) + assert.equal(result, null) } finally { fs.unlinkSync(secretPath) } diff --git a/packages/fetch-router/src/lib/fs-file-resolver.ts b/packages/fetch-router/src/lib/fs-file-resolver.ts index a06cb5fe90e..dd2e4877bb0 100644 --- a/packages/fetch-router/src/lib/fs-file-resolver.ts +++ b/packages/fetch-router/src/lib/fs-file-resolver.ts @@ -48,7 +48,8 @@ export function createFsFileResolver< let filePath = path.join(root, relativePath) try { - return openFile(filePath) + let file = await openFile(filePath) + return { file, path: filePath } } catch (error) { if (isNoEntityError(error) || isNotAFileError(error)) { return null diff --git a/packages/headers/src/lib/if-match.test.ts b/packages/headers/src/lib/if-match.test.ts index 61c1cd792a0..d50538bc3b8 100644 --- a/packages/headers/src/lib/if-match.test.ts +++ b/packages/headers/src/lib/if-match.test.ts @@ -100,5 +100,37 @@ describe('IfMatch', () => { assert.ok(wildcardHeader.matches('"67ab43"')) assert.ok(wildcardHeader.matches('"anything"')) }) + + describe('ETag handling', () => { + it('returns false when resource has weak tag', () => { + let header = new IfMatch('67ab43') + assert.ok(!header.matches('W/"67ab43"')) + }) + + it('returns false when If-Match header has weak tag', () => { + let header = new IfMatch('W/"67ab43"') + assert.ok(!header.matches('"67ab43"')) + }) + + it('returns false when both resource and If-Match header have weak tags', () => { + let header = new IfMatch('W/"67ab43"') + assert.ok(!header.matches('W/"67ab43"')) + }) + + it('returns true when both resource and If-Match header have strong tags', () => { + let header = new IfMatch('"67ab43"') + assert.ok(header.matches('"67ab43"')) + }) + + it('returns false when If-Match has mix of weak and strong tags and resource is weak', () => { + let header = new IfMatch('W/"67ab43", "54ed21"') + assert.ok(!header.matches('W/"67ab43"')) + }) + + it('returns true when If-Match has mix of weak and strong tags and resource matches strong tag', () => { + let header = new IfMatch('W/"67ab43", "54ed21"') + assert.ok(header.matches('"54ed21"')) + }) + }) }) }) diff --git a/packages/headers/src/lib/if-match.ts b/packages/headers/src/lib/if-match.ts index 88a7eadf818..3af49162403 100644 --- a/packages/headers/src/lib/if-match.ts +++ b/packages/headers/src/lib/if-match.ts @@ -45,8 +45,11 @@ export class IfMatch implements HeaderValue, IfMatchInit { /** * Checks if the precondition passes for the given entity tag. * - * Note: This method returns `true` if the `If-Match` header is not present, - * regardless of the entity tag being checked since the precondition passes. + * This method always returns `true` if the `If-Match` header is not present + * since the precondition passes regardless of the entity tag being checked. + * + * Uses strong comparison as per RFC 9110, meaning weak entity tags (prefixed with `W/`) + * will never match. * * @param tag The entity tag to check against. * @returns `true` if the precondition passes, `false` if it fails (should return 412). @@ -55,7 +58,27 @@ export class IfMatch implements HeaderValue, IfMatchInit { if (this.tags.length === 0) { return true } - return this.has(tag) || this.tags.includes('*') // Present and matches or wildcard = pass + + // Wildcard always matches (regardless of weak/strong) + if (this.tags.includes('*')) { + return true + } + + let normalizedTag = quoteEtag(tag) + + // Weak tags never match in If-Match (strong comparison only) + if (normalizedTag.startsWith('W/')) { + return false + } + + // Only match against strong tags in the header + for (let headerTag of this.tags) { + if (!headerTag.startsWith('W/') && headerTag === normalizedTag) { + return true + } + } + + return false } toString() { From 4c176d09fa1f0b036056e16ea7e9a5bf761e1ba0 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Fri, 7 Nov 2025 09:48:23 +1100 Subject: [PATCH 07/19] Use context.url instead of context.request.url Co-authored-by: Michael Jackson --- packages/fetch-router/src/lib/middleware/static.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/fetch-router/src/lib/middleware/static.ts b/packages/fetch-router/src/lib/middleware/static.ts index 7cd1b075745..69d154a528d 100644 --- a/packages/fetch-router/src/lib/middleware/static.ts +++ b/packages/fetch-router/src/lib/middleware/static.ts @@ -22,7 +22,7 @@ export type StaticFilesOptions< * // Returns: "assets/style.css" */ function requestPathnameResolver(context: RequestContext): string { - return new URL(context.request.url).pathname.replace(/^\/+/, '') + return context.url.pathname.replace(/^\/+/, '') } /** From c66f8163ae2f77e0a907d1486f2d685cbd99084f Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Fri, 7 Nov 2025 09:58:41 +1100 Subject: [PATCH 08/19] Update readme --- packages/fetch-router/README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/fetch-router/README.md b/packages/fetch-router/README.md index 78565f95529..ad76985caf6 100644 --- a/packages/fetch-router/README.md +++ b/packages/fetch-router/README.md @@ -601,7 +601,7 @@ Note that paths returned by this function should be relative to the root directo #### Strong ETags and Content Hashing -For assets that require strong validation (e.g., to support [`If-Match` headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Match) for [`Range` requests](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range)), you can configure strong ETag generation. +For assets that require strong validation (e.g., to support [`If-Match`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Match) and [`If-Range`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Range) headers for [`Range` requests](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range)), you can configure strong ETag generation. ```ts import { createRouter } from '@remix-run/fetch-router' @@ -633,7 +633,6 @@ let router = createRouter({ digest: 'SHA-256', // Provide a cache to avoid re-hashing files on every request. - // You may want to use an LRU cache to prevent memory leaks. // Defaults to `undefined`, meaning that there is no cache. digestCache: new Map(), From 1122a4b02722708568b2dbdc4388138bb1060f54 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Fri, 7 Nov 2025 09:59:17 +1100 Subject: [PATCH 09/19] Use new RequestContext instead of manually mocking --- .../fetch-router/src/lib/file-handler.test.ts | 24 +++++++------------ 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/packages/fetch-router/src/lib/file-handler.test.ts b/packages/fetch-router/src/lib/file-handler.test.ts index f166f2c81fe..82ef6e892ec 100644 --- a/packages/fetch-router/src/lib/file-handler.test.ts +++ b/packages/fetch-router/src/lib/file-handler.test.ts @@ -1,11 +1,11 @@ import * as assert from 'node:assert/strict' import { describe, it, mock } from 'node:test' -import SuperHeaders, { type SuperHeadersInit } from '@remix-run/headers' +import type { SuperHeadersInit } from '@remix-run/headers' +import { SuperHeaders } from '@remix-run/headers' import { createFileHandler } from './file-handler.ts' -import type { RequestContext } from './request-context.ts' +import { RequestContext } from './request-context.ts' import type { RequestMethod } from './request-methods.ts' -import { AppStorage } from './app-storage.ts' describe('createFileHandler', () => { function createMockFile( @@ -29,20 +29,12 @@ describe('createFileHandler', () => { headers?: SuperHeadersInit } = {}, ): RequestContext<'GET', {}> { - let headers = new SuperHeaders(options.headers ?? {}) - return { - formData: undefined, - storage: new AppStorage(), - url: new URL(url), - files: null, - method: 'GET', - request: new Request(url, { - method: options.method || 'GET', - headers, + return new RequestContext( + new Request(url, { + method: options.method ?? 'GET', + headers: new SuperHeaders(options.headers ?? {}), }), - params: {}, - headers, - } + ) } describe('basic functionality', () => { From 90d5aba6f55ee38572c6a4cbf02269df5447ec22 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Fri, 7 Nov 2025 09:59:38 +1100 Subject: [PATCH 10/19] Export ContentRange and ContentRangeInit --- packages/headers/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/headers/src/index.ts b/packages/headers/src/index.ts index 17380ddbfa5..686d6f11bea 100644 --- a/packages/headers/src/index.ts +++ b/packages/headers/src/index.ts @@ -3,6 +3,7 @@ export { type AcceptEncodingInit, AcceptEncoding } from './lib/accept-encoding.t export { type AcceptLanguageInit, AcceptLanguage } from './lib/accept-language.ts' export { type CacheControlInit, CacheControl } from './lib/cache-control.ts' export { type ContentDispositionInit, ContentDisposition } from './lib/content-disposition.ts' +export { type ContentRangeInit, ContentRange } from './lib/content-range.ts' export { type ContentTypeInit, ContentType } from './lib/content-type.ts' export { type CookieInit, Cookie } from './lib/cookie.ts' export { type IfMatchInit, IfMatch } from './lib/if-match.ts' From 2caf8dcbb7e09c3555cceab740987d4ac8da946a Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Mon, 10 Nov 2025 09:24:57 +1100 Subject: [PATCH 11/19] Migrate to `findFile` and `file` response helper --- demos/bookstore/app/router.ts | 46 +- packages/fetch-router/README.md | 284 ++-- packages/fetch-router/package.json | 13 +- packages/fetch-router/src/file-handler.ts | 7 - packages/fetch-router/src/find-file.ts | 2 + packages/fetch-router/src/fs-file-resolver.ts | 3 - .../fetch-router/src/lib/file-handler.test.ts | 1322 ----------------- packages/fetch-router/src/lib/file-handler.ts | 458 ------ .../fetch-router/src/lib/find-file.test.ts | 146 ++ packages/fetch-router/src/lib/find-file.ts | 54 + .../src/lib/fs-file-resolver.test.ts | 201 --- .../fetch-router/src/lib/fs-file-resolver.ts | 68 - .../fetch-router/src/lib/middleware/static.ts | 34 +- .../src/lib/response-helpers/file.ts | 429 ++++++ packages/fetch-router/src/response-helpers.ts | 6 + 15 files changed, 857 insertions(+), 2216 deletions(-) delete mode 100644 packages/fetch-router/src/file-handler.ts create mode 100644 packages/fetch-router/src/find-file.ts delete mode 100644 packages/fetch-router/src/fs-file-resolver.ts delete mode 100644 packages/fetch-router/src/lib/file-handler.test.ts delete mode 100644 packages/fetch-router/src/lib/file-handler.ts create mode 100644 packages/fetch-router/src/lib/find-file.test.ts create mode 100644 packages/fetch-router/src/lib/find-file.ts delete mode 100644 packages/fetch-router/src/lib/fs-file-resolver.test.ts delete mode 100644 packages/fetch-router/src/lib/fs-file-resolver.ts create mode 100644 packages/fetch-router/src/lib/response-helpers/file.ts diff --git a/demos/bookstore/app/router.ts b/demos/bookstore/app/router.ts index cdb38e3e799..d72d56c106c 100644 --- a/demos/bookstore/app/router.ts +++ b/demos/bookstore/app/router.ts @@ -1,8 +1,10 @@ import { createRouter } from '@remix-run/fetch-router' import { asyncContext } from '@remix-run/fetch-router/async-context-middleware' +import { findFile } from '@remix-run/fetch-router/find-file' 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 { file } from '@remix-run/fetch-router/response-helpers' import { staticFiles } from '@remix-run/fetch-router/static-middleware' import { routes } from '../routes.ts' @@ -39,34 +41,30 @@ middleware.push( export let router = createRouter({ middleware }) -router.get(routes.assets, { - middleware: [ - staticFiles('./public/assets', { - path: ({ params }) => params.path, - cacheControl: 'no-store, must-revalidate', - etag: false, - lastModified: false, - acceptRanges: false, - }), - ], - handler() { +router.get(routes.assets, async (context) => { + let assetFile = await findFile('./public/assets', context.params.path) + if (!assetFile) { return new Response('Not Found', { status: 404 }) - }, + } + return file(assetFile, context, { + cacheControl: 'no-store, must-revalidate', + etag: false, + lastModified: false, + acceptRanges: false, + }) }) -router.get(routes.images, { - middleware: [ - staticFiles('./public/images', { - path: ({ params }) => params.path, - cacheControl: 'no-store, must-revalidate', - etag: false, - lastModified: false, - acceptRanges: false, - }), - ], - handler() { +router.get(routes.images, async (context) => { + let imageFile = await findFile('./public/images', context.params.path) + if (!imageFile) { return new Response('Not Found', { status: 404 }) - }, + } + return file(imageFile, context, { + cacheControl: 'no-store, must-revalidate', + etag: false, + lastModified: false, + acceptRanges: false, + }) }) router.get(routes.uploads, uploadsHandler) diff --git a/packages/fetch-router/README.md b/packages/fetch-router/README.md index 58ec3f2c0dc..0251adef835 100644 --- a/packages/fetch-router/README.md +++ b/packages/fetch-router/README.md @@ -541,115 +541,6 @@ router.map(routes.admin.dashboard, { }) ``` -### Serving Static Files - -The `staticFiles()` middleware serves static files from the filesystem. - -```ts -import { createRouter } from '@remix-run/fetch-router' -import { staticFiles } from '@remix-run/fetch-router/static-middleware' - -let router = createRouter({ - middleware: [staticFiles('./public')], -}) -``` - -You can further customize the behavior of this middleware by providing additional options: - -```ts -import { createRouter } from '@remix-run/fetch-router' -import { staticFiles } from '@remix-run/fetch-router/static-middleware' - -let router = createRouter({ - middleware: [ - staticFiles('./public', { - // Cache-Control header value - // Defaults to `undefined` - cacheControl: 'public, max-age=3600', - - // Whether to support HTTP Range requests for partial content. - // Defaults to `true`. - acceptRanges: true, - - // 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 a `Last-Modified` header for the response. - // Defaults to `true`. - lastModified: true, - }), - ], -}) -``` - -#### Custom Path Resolution - -By default, this middleware uses the full request pathname to resolve files. You can customize this by providing a path resolver function which is passed the request context. For example, to resolve files based on a route param: - -```ts -router.get('/assets/*path', { - middleware: [ - staticFiles('./public', { - path: ({ params }) => params.path, - }), - ], - handler() { - return new Response('Not Found', { status: 404 }) - }, -}) -``` - -Note that paths returned by this function should be relative to the root directory passed to `staticFiles()`. This is to ensure that files outside of the root directory are not served. - -#### 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) and [`If-Range`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Range) headers for [`Range` requests](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range)), you can configure strong ETag generation. - -```ts -import { createRouter } from '@remix-run/fetch-router' -import { staticFiles } from '@remix-run/fetch-router/static-middleware' - -let router = createRouter({ - middleware: [ - staticFiles('./public', { - etag: 'strong', - }), - ], -}) -``` - -When using strong ETags, the default behavior is that they are generated on every request with [`SubtleCrypto.digest()`](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest) using the `'SHA-256'` algorithm, but this can be customized if needed. - -```ts -import { createRouter } from '@remix-run/fetch-router' -import { staticFiles } from '@remix-run/fetch-router/static-middleware' - -let router = createRouter({ - middleware: [ - staticFiles('./public', { - etag: 'strong', - - // Specify the SubtleCrypto.digest() hashing algorithm, - // or a custom digest function (`async (file: File) => string`). - // Defaults to `'SHA-256'`. - digest: 'SHA-256', - - // Provide a cache to avoid re-hashing files on every request. - // Defaults to `undefined`, meaning that there is no cache. - digestCache: new Map(), - - // Customize the logic for generating the cache key. - // Defaults to `({ path, file }) => `${path}:${file.lastModified}``. - digestCacheKey: ({ path }) => path, - }), - ], -}) -``` - ### Request Context Every middleware and request handler receives a `context` object with useful properties: @@ -714,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, context, 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 @@ -773,6 +665,180 @@ let button = html`` // 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 several tools for serving files, built as layers that compose together: + +- **`file()` response helper** - The primitive for returning file responses with full HTTP semantics +- **`findFile()` function** - Helps map route patterns to files on disk +- **`staticFiles()` middleware** - Convenience middleware that combines both + +#### 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 { openFile } from '@remix-run/lazy-file/fs' +import { file } from '@remix-run/fetch-router/response-helpers' + +router.get('/downloads/:filename', async (context) => { + let downloadFile = await openFile(`./downloads/${context.params.filename}`) + + return file(downloadFile, context) +}) +``` + +##### Options + +The `file()` helper accepts an optional third argument with configuration options: + +```ts +return file(downloadFile, context, { + // 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 file(assetFile, context, { + 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 file(assetFile, context, { + etag: 'strong', + + // Specify a different SubtleCrypto.digest() algorithm + digest: 'SHA-512', +}) +``` + +Or provide a custom digest function: + +```ts +return file(assetFile, context, { + etag: 'strong', + + // Custom digest function + digest: async (file) => { + let buffer = await file.arrayBuffer() + return customHash(buffer) + }, +}) +``` + +When using strong ETags, you can provide a cache to avoid re-hashing files on every request: + +```ts +return file(assetFile, context, { + etag: 'strong', + + // Provide a cache to avoid re-hashing files on every request. + // Defaults to `undefined`, meaning that there is no cache. + digestCache: new Map(), + + // Customize the logic for generating the cache key. + // Defaults to `({ path, file }) => `${path}:${file.lastModified}``. + digestCacheKey: (context) => context.params.path, +}) +``` + +#### Using `findFile()` with `file()` + +When you need to map a route pattern to a directory of files on disk, use `findFile()` to resolve files before sending them with the `file()` response helper: + +```ts +import { findFile } from '@remix-run/fetch-router/find-file' +import { file } from '@remix-run/fetch-router/response-helpers' + +router.get('/assets/*path', async (context) => { + let assetFile = await findFile('./public/assets', context.params.path) + + if (!assetFile) { + return new Response('Not Found', { status: 404 }) + } + + return file(assetFile, context, { + cacheControl: 'public, max-age=3600', + }) +}) +``` + +The `findFile()` function returns a `File` object with its `name` property set to the full absolute path on the server, or `null` if the file doesn't exist or is outside the specified root directory. + +#### The `staticFiles()` Middleware + +For convenience, the `staticFiles()` middleware combines `findFile()` and `file()` into a single middleware: + +```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', + digestCache: new Map(), + }), + ], +}) +``` + +By default, the middleware uses the full request pathname to resolve files. You can customize this with a `path` resolver function: + +```ts +router.get('/assets/*path', { + middleware: [ + staticFiles('./public', { + path: (context) => context.params.path, + }), + ], + handler() { + return new Response('Not Found', { status: 404 }) + }, +}) +``` + ### Testing Testing is straightforward because `fetch-router` uses the standard `fetch()` API: diff --git a/packages/fetch-router/package.json b/packages/fetch-router/package.json index 7b088dffbc5..b2e9e51320e 100644 --- a/packages/fetch-router/package.json +++ b/packages/fetch-router/package.json @@ -22,9 +22,8 @@ "exports": { ".": "./src/index.ts", "./async-context-middleware": "./src/async-context-middleware.ts", - "./file-handler": "./src/file-handler.ts", + "./find-file": "./src/find-file.ts", "./form-data-middleware": "./src/form-data-middleware.ts", - "./fs-file-resolver": "./src/fs-file-resolver.ts", "./logger-middleware": "./src/logger-middleware.ts", "./method-override-middleware": "./src/method-override-middleware.ts", "./response-helpers": "./src/response-helpers.ts", @@ -41,18 +40,14 @@ "types": "./dist/async-context-middleware.d.ts", "default": "./dist/async-context-middleware.js" }, - "./file-handler": { - "types": "./dist/file-handler.d.ts", - "default": "./dist/file-handler.js" + "./find-file": { + "types": "./dist/find-file.d.ts", + "default": "./dist/find-file.js" }, "./form-data-middleware": { "types": "./dist/form-data-middleware.d.ts", "default": "./dist/form-data-middleware.js" }, - "./fs-file-resolver": { - "types": "./dist/fs-file-resolver.d.ts", - "default": "./dist/fs-file-resolver.js" - }, "./logger-middleware": { "types": "./dist/logger-middleware.d.ts", "default": "./dist/logger-middleware.js" diff --git a/packages/fetch-router/src/file-handler.ts b/packages/fetch-router/src/file-handler.ts deleted file mode 100644 index 725dc203fa7..00000000000 --- a/packages/fetch-router/src/file-handler.ts +++ /dev/null @@ -1,7 +0,0 @@ -export type { - FileResolver, - FileHandlerOptions, - FileDigestFunction, - FileDigestCacheKeyFunction, -} from './lib/file-handler.ts' -export { createFileHandler } from './lib/file-handler.ts' diff --git a/packages/fetch-router/src/find-file.ts b/packages/fetch-router/src/find-file.ts new file mode 100644 index 00000000000..6ded30b4101 --- /dev/null +++ b/packages/fetch-router/src/find-file.ts @@ -0,0 +1,2 @@ +export { findFile } from './lib/find-file.ts' + diff --git a/packages/fetch-router/src/fs-file-resolver.ts b/packages/fetch-router/src/fs-file-resolver.ts deleted file mode 100644 index 5bfcee518f6..00000000000 --- a/packages/fetch-router/src/fs-file-resolver.ts +++ /dev/null @@ -1,3 +0,0 @@ -export type { PathResolver } from './lib/fs-file-resolver.ts' -export { createFsFileResolver } from './lib/fs-file-resolver.ts' - diff --git a/packages/fetch-router/src/lib/file-handler.test.ts b/packages/fetch-router/src/lib/file-handler.test.ts deleted file mode 100644 index 82ef6e892ec..00000000000 --- a/packages/fetch-router/src/lib/file-handler.test.ts +++ /dev/null @@ -1,1322 +0,0 @@ -import * as assert from 'node:assert/strict' -import { describe, it, mock } from 'node:test' -import type { SuperHeadersInit } from '@remix-run/headers' -import { SuperHeaders } from '@remix-run/headers' - -import { createFileHandler } from './file-handler.ts' -import { RequestContext } from './request-context.ts' -import type { RequestMethod } from './request-methods.ts' - -describe('createFileHandler', () => { - function createMockFile( - content: string, - options: { - type?: string - lastModified?: number - } = {}, - ): { file: File; path: string } { - let file = new File([content], 'mock.txt', { - type: options.type || 'text/plain', - lastModified: options.lastModified || Date.now(), - }) - return { file, path: `/path/to/${file.name}` } - } - - function createContext( - url: string, - options: { - method?: RequestMethod - headers?: SuperHeadersInit - } = {}, - ): RequestContext<'GET', {}> { - return new RequestContext( - new Request(url, { - method: options.method ?? 'GET', - headers: new SuperHeaders(options.headers ?? {}), - }), - ) - } - - describe('basic functionality', () => { - it('serves a file', async () => { - let handler = createFileHandler(() => createMockFile('Hello, World!')) - - let response = await handler(createContext('http://localhost/test.txt')) - - assert.equal(response.status, 200) - assert.equal(await response.text(), 'Hello, World!') - assert.equal(response.headers.get('Content-Type'), 'text/plain') - assert.equal(response.headers.get('Content-Length'), '13') - }) - - it('serves a file with HEAD request', async () => { - let handler = createFileHandler(() => createMockFile('Hello, World!')) - - let response = await handler(createContext('http://localhost/test.txt', { method: 'HEAD' })) - - assert.equal(response.status, 200) - assert.equal(await response.text(), '') - assert.equal(response.headers.get('Content-Type'), 'text/plain') - assert.equal(response.headers.get('Content-Length'), '13') - }) - - it('returns 404 when file resolver returns null', async () => { - let handler = createFileHandler(() => null) - - let response = await handler(createContext('http://localhost/test.txt')) - - assert.equal(response.status, 404) - assert.equal(await response.text(), 'Not Found') - }) - - it('returns 405 for unsupported methods', async () => { - let handler = createFileHandler(() => createMockFile('Hello, World!')) - - let response = await handler(createContext('http://localhost/test.txt', { method: 'POST' })) - - assert.equal(response.status, 405) - assert.equal(await response.text(), 'Method Not Allowed') - }) - - it('passes request context to file resolver', async () => { - let file = createMockFile('Hello, World!') - let mockFileResolver = mock.fn((_context: RequestContext<'GET', {}>) => file) - let handler = createFileHandler(mockFileResolver) - - let response = await handler(createContext('http://localhost/test.txt', { method: 'GET' })) - - assert.equal(response.status, 200) - assert.equal(await response.text(), 'Hello, World!') - assert.equal(mockFileResolver.mock.callCount(), 1) - assert.equal(mockFileResolver.mock.calls[0].arguments[0].method, 'GET') - assert.equal( - mockFileResolver.mock.calls[0].arguments[0].request.url, - 'http://localhost/test.txt', - ) - }) - }) - - describe('ETag support', () => { - for (let method of ['GET', 'HEAD'] as const) { - describe(method, () => { - it('includes weak ETag header by default', async () => { - let file = createMockFile('Hello, World!', { lastModified: 1000000 }) - let handler = createFileHandler(() => file) - - let response = await handler(createContext('http://localhost/test.txt', { method })) - - let etag = response.headers.get('ETag') - assert.equal(response.status, 200) - assert.ok(etag) - assert.match(etag, /^W\/"[\d]+-[\d]+\.?[\d]*"$/) - }) - - it('does not include ETag when etag=false', async () => { - let file = createMockFile('Hello, World!') - let handler = createFileHandler(() => file, { etag: false }) - - let response = await handler(createContext('http://localhost/test.txt', { method })) - - assert.equal(response.status, 200) - assert.equal(response.headers.get('ETag'), null) - }) - - it('generates strong ETag when etag=strong', async () => { - let file = createMockFile('Hello, World!') - let handler = createFileHandler(() => file, { etag: 'strong' }) - - let response = await handler(createContext('http://localhost/test.txt', { method })) - - let etag = response.headers.get('ETag') - assert.equal(response.status, 200) - assert.ok(etag) - assert.ok(!etag.startsWith('W/'), 'Should not be a weak ETag') - assert.match(etag, /^"[a-f0-9]+"$/, 'Should be a hex digest wrapped in quotes') - }) - - it('uses SHA-256 by default for strong ETags', async () => { - let file = createMockFile('Hello, World!') - let handler = createFileHandler(() => file, { etag: 'strong' }) - - let response = await handler(createContext('http://localhost/test.txt', { method })) - - let etag = response.headers.get('ETag') - assert.ok(etag) - // SHA-256 produces 64 hex characters (32 bytes * 2) - assert.match(etag, /^"[a-f0-9]{64}"$/) - }) - - it('supports custom digest algorithm', async () => { - let file = createMockFile('Hello, World!') - let handler = createFileHandler(() => file, { - etag: 'strong', - digest: 'SHA-512', - }) - - let response = await handler(createContext('http://localhost/test.txt', { method })) - - let etag = response.headers.get('ETag') - assert.ok(etag) - // SHA-512 produces 128 hex characters (64 bytes * 2) - assert.match(etag, /^"[a-f0-9]{128}"$/) - }) - - it('supports custom digest function', async () => { - let file = createMockFile('Hello, World!') - let handler = createFileHandler(() => file, { - etag: 'strong', - digest: async () => 'custom-hash-12345', - }) - - let response = await handler(createContext('http://localhost/test.txt', { method })) - - let etag = response.headers.get('ETag') - assert.equal(etag, '"custom-hash-12345"') - }) - - it('caches digests when digestCache is provided', async () => { - let file = createMockFile('Hello, World!') - let cache = new Map() - let computeCount = 0 - - let handler = createFileHandler(() => file, { - etag: 'strong', - digest: async () => { - computeCount++ - return `digest-${computeCount}` - }, - digestCache: cache, - }) - - // First request - should compute - let response1 = await handler(createContext('http://localhost/test.txt', { method })) - assert.equal(response1.headers.get('ETag'), '"digest-1"') - - // Second request - should use cache - let response2 = await handler(createContext('http://localhost/test.txt', { method })) - assert.equal(response2.headers.get('ETag'), '"digest-1"') - - // Clear cache - should recompute with new digest - cache.clear() - // Third request - should recompute with new digest - let response3 = await handler(createContext('http://localhost/test.txt', { method })) - assert.equal(response3.headers.get('ETag'), '"digest-2"') - - // Fourth request - should use cache - let response4 = await handler(createContext('http://localhost/test.txt', { method })) - assert.equal(response4.headers.get('ETag'), '"digest-2"') - }) - - it('uses custom cache key function', async () => { - let file = createMockFile('Hello, World!', { lastModified: 1000000 }) - let cache = new Map() - - let handler = createFileHandler(() => file, { - etag: 'strong', - digestCache: cache, - digestCacheKey: ({ path }) => `custom:${path}`, // Stable key without mtime - }) - - await handler(createContext('http://localhost/test.txt', { method })) - - // Cache key should be custom format (path defaults to file.name which is 'mock.txt') - assert.ok(cache.has('custom:/path/to/mock.txt')) - }) - }) - } - }) - - describe('If-None-Match support', () => { - for (let method of ['GET', 'HEAD'] as const) { - describe(method, () => { - it('returns 304 (Not Modified) when If-None-Match matches ETag', async () => { - let file = createMockFile('Hello, World!', { lastModified: 1000000 }) - let handler = createFileHandler(() => file) - - let response1 = await handler(createContext('http://localhost/test.txt', { method })) - let etag = response1.headers.get('ETag') - assert.ok(etag) - - let response2 = await handler( - createContext('http://localhost/test.txt', { - method, - headers: { 'If-None-Match': etag }, - }), - ) - - assert.equal(response2.status, 304) - assert.equal(await response2.text(), '') - }) - - it('returns 304 (Not Modified) when If-None-Match is *', async () => { - let file = createMockFile('Hello, World!') - let handler = createFileHandler(() => file) - - let response = await handler( - createContext('http://localhost/test.txt', { - method, - headers: { 'If-None-Match': '*' }, - }), - ) - - assert.equal(response.status, 304) - }) - - it('returns 200 (OK) when If-None-Match does not match', async () => { - let file = createMockFile('Hello, World!') - let handler = createFileHandler(() => file) - - let response = await handler( - createContext('http://localhost/test.txt', { - method, - headers: { 'If-None-Match': 'W/"wrong-etag"' }, - }), - ) - - assert.equal(response.status, 200) - assert.equal(await response.text(), method === 'HEAD' ? '' : 'Hello, World!') - }) - - it('handles multiple ETags in If-None-Match', async () => { - let file = createMockFile('Hello, World!', { lastModified: 1000000 }) - let handler = createFileHandler(() => file) - - let response1 = await handler(createContext('http://localhost/test.txt', { method })) - let etag = response1.headers.get('ETag') - assert.ok(etag) - - let response2 = await handler( - createContext('http://localhost/test.txt', { - method, - headers: { 'If-None-Match': `W/"wrong-1", ${etag}, W/"wrong-2"` }, - }), - ) - - assert.equal(response2.status, 304) - }) - - it('ignores If-None-Match when etag is disabled', async () => { - let file = createMockFile('Hello, World!') - - // First, get the ETag that would be generated - let handlerWithEtag = createFileHandler(() => file) - let response1 = await handlerWithEtag( - createContext('http://localhost/test.txt', { method }), - ) - let etag = response1.headers.get('ETag') - assert.ok(etag) - - // Now test with etag disabled but send the matching ETag - let handler = createFileHandler(() => file, { etag: false }) - let response = await handler( - createContext('http://localhost/test.txt', { - method, - headers: { 'If-None-Match': etag }, - }), - ) - - // Should return 200, not 304, because etag is disabled - assert.equal(response.status, 200) - assert.equal(await response.text(), method === 'HEAD' ? '' : 'Hello, World!') - }) - }) - } - }) - - describe('If-Match support', () => { - for (let method of ['GET', 'HEAD'] as const) { - describe(method, () => { - describe('precondition validation', () => { - it('returns 412 (Precondition Failed) when resource has weak ETag', async () => { - let file = createMockFile('Hello, World!', { lastModified: 1000000 }) - let handler = createFileHandler(() => file) - - let response1 = await handler(createContext('http://localhost/test.txt', { method })) - let etag = response1.headers.get('ETag') - assert.ok(etag) - assert.ok(etag.startsWith('W/')) // Verify it's a weak ETag - - // If-Match uses strong comparison, so weak ETags never match - let response2 = await handler( - createContext('http://localhost/test.txt', { - method, - headers: { 'If-Match': etag }, - }), - ) - - assert.equal(response2.status, 412) - }) - - it('returns 200 (OK) when resource has strong ETag and If-Match matches', async () => { - let file = createMockFile('Hello, World!') - let handler = createFileHandler(() => file, { etag: 'strong' }) - - // Get the strong ETag - let response1 = await handler(createContext('http://localhost/test.txt', { method })) - let etag = response1.headers.get('ETag') - assert.ok(etag) - assert.ok(!etag.startsWith('W/')) // Verify it's a strong ETag - - // If-Match should work with strong ETags - let response2 = await handler( - createContext('http://localhost/test.txt', { - method, - headers: { 'If-Match': etag }, - }), - ) - - assert.equal(response2.status, 200) - assert.equal(await response2.text(), method === 'HEAD' ? '' : 'Hello, World!') - }) - - it('returns 412 (Precondition Failed) when If-Match does not match (weak ETag)', async () => { - let file = createMockFile('Hello, World!') - let handler = createFileHandler(() => file) - - let response = await handler( - createContext('http://localhost/test.txt', { - method, - headers: { 'If-Match': '"wrong-etag"' }, - }), - ) - - assert.equal(response.status, 412) - }) - - it('returns 412 (Precondition Failed) when If-Match does not match (strong ETag)', async () => { - let file = createMockFile('Hello, World!') - let handler = createFileHandler(() => file, { etag: 'strong' }) - - let response = await handler( - createContext('http://localhost/test.txt', { - method, - headers: { 'If-Match': '"wrong-etag"' }, - }), - ) - - assert.equal(response.status, 412) - }) - - it('returns 200 (OK) when If-Match is *', async () => { - let file = createMockFile('Hello, World!') - let handler = createFileHandler(() => file) - - let response = await handler( - createContext('http://localhost/test.txt', { - method, - headers: { 'If-Match': '*' }, - }), - ) - - assert.equal(response.status, 200) - assert.equal(await response.text(), method === 'HEAD' ? '' : 'Hello, World!') - }) - - it('returns 412 (Precondition Failed) when If-Match contains multiple ETags and none match', async () => { - let file = createMockFile('Hello, World!') - let handler = createFileHandler(() => file) - - let response = await handler( - createContext('http://localhost/test.txt', { - method, - headers: { 'If-Match': '"wrong-1", "wrong-2"' }, - }), - ) - - assert.equal(response.status, 412) - }) - }) - - describe('prioritization', () => { - it('returns 412 (Precondition Failed) when If-Match fails, even if If-None-Match would match', async () => { - let file = createMockFile('Hello, World!', { lastModified: 1000000 }) - let handler = createFileHandler(() => file) - - let response1 = await handler(createContext('http://localhost/test.txt', { method })) - let etag = response1.headers.get('ETag') - assert.ok(etag) - - let response2 = await handler( - createContext('http://localhost/test.txt', { - method, - headers: { - 'If-Match': 'W/"wrong-etag"', - 'If-None-Match': etag, - }, - }), - ) - - assert.equal(response2.status, 412) - }) - }) - - it('ignores If-Match when etag is disabled', async () => { - let file = createMockFile('Hello, World!') - - // First, get the ETag that would be generated - let handlerWithEtag = createFileHandler(() => file) - let response1 = await handlerWithEtag( - createContext('http://localhost/test.txt', { method }), - ) - let etag = response1.headers.get('ETag') - assert.ok(etag) - - // Now test with etag disabled but send a non-matching ETag - // (If we weren't ignoring it, this would return 412) - let handler = createFileHandler(() => file, { etag: false }) - let response = await handler( - createContext('http://localhost/test.txt', { - method, - headers: { 'If-Match': 'W/"wrong-etag"' }, - }), - ) - - // Should return 200, not 412, because etag is disabled - assert.equal(response.status, 200) - assert.equal(await response.text(), method === 'HEAD' ? '' : 'Hello, World!') - }) - }) - } - }) - - describe('If-Unmodified-Since support', () => { - for (let method of ['GET', 'HEAD'] as const) { - describe(method, () => { - describe('precondition validation', () => { - it('returns 200 (OK) when If-Unmodified-Since is after Last-Modified', async () => { - let fileDate = new Date('2025-01-01') - let futureDate = new Date('2026-01-01') - let file = createMockFile('Hello, World!', { lastModified: fileDate.getTime() }) - let handler = createFileHandler(() => file) - - let response = await handler( - createContext('http://localhost/test.txt', { - method, - headers: { 'If-Unmodified-Since': futureDate.toUTCString() }, - }), - ) - - assert.equal(response.status, 200) - assert.equal(await response.text(), method === 'HEAD' ? '' : 'Hello, World!') - }) - - it('returns 200 (OK) when If-Unmodified-Since matches Last-Modified', async () => { - let fileDate = new Date('2025-01-01') - let file = createMockFile('Hello, World!', { lastModified: fileDate.getTime() }) - let handler = createFileHandler(() => file) - - let response = await handler( - createContext('http://localhost/test.txt', { - method, - headers: { 'If-Unmodified-Since': fileDate.toUTCString() }, - }), - ) - - assert.equal(response.status, 200) - assert.equal(await response.text(), method === 'HEAD' ? '' : 'Hello, World!') - }) - - it('returns 412 (Precondition Failed) when If-Unmodified-Since is before Last-Modified', async () => { - let fileDate = new Date('2025-01-01') - let pastDate = new Date('2024-01-01') - let file = createMockFile('Hello, World!', { lastModified: fileDate.getTime() }) - let handler = createFileHandler(() => file) - - let response = await handler( - createContext('http://localhost/test.txt', { - method, - headers: { 'If-Unmodified-Since': pastDate.toUTCString() }, - }), - ) - - assert.equal(response.status, 412) - }) - - it('ignores malformed If-Unmodified-Since', async () => { - let fileDate = new Date('2025-01-01') - let file = createMockFile('Hello, World!', { lastModified: fileDate.getTime() }) - let handler = createFileHandler(() => file) - - let response = await handler( - createContext('http://localhost/test.txt', { - method, - headers: { 'If-Unmodified-Since': 'invalid-date' }, - }), - ) - - assert.equal(response.status, 200) - assert.equal(await response.text(), method === 'HEAD' ? '' : 'Hello, World!') - }) - }) - - describe('prioritization', () => { - it('returns 412 (Precondition Failed) when If-Match fails, even if If-Unmodified-Since would pass', async () => { - let fileDate = new Date('2025-01-01') - let futureDate = new Date('2026-01-01') - let file = createMockFile('Hello, World!', { lastModified: fileDate.getTime() }) - let handler = createFileHandler(() => file) - - let response = await handler( - createContext('http://localhost/test.txt', { - method, - headers: { - 'If-Match': 'W/"wrong-etag"', - 'If-Unmodified-Since': futureDate.toUTCString(), - }, - }), - ) - - assert.equal(response.status, 412) - }) - - it('ignores If-Unmodified-Since when If-Match is present (strong ETag)', async () => { - let pastDate = new Date('2024-01-01') - let file = createMockFile('Hello, World!', { lastModified: pastDate.getTime() }) - let handler = createFileHandler(() => file, { etag: 'strong' }) - - // Get the strong ETag - let response1 = await handler(createContext('http://localhost/test.txt', { method })) - let etag = response1.headers.get('ETag') - assert.ok(etag) - assert.ok(!etag.startsWith('W/')) // Verify it's a strong ETag - - // If-Match passes, so If-Unmodified-Since should be ignored - // (even though it would fail if evaluated - pastDate is before file's lastModified) - let response2 = await handler( - createContext('http://localhost/test.txt', { - method, - headers: { - 'If-Match': etag, - 'If-Unmodified-Since': pastDate.toUTCString(), - }, - }), - ) - - assert.equal(response2.status, 200) - assert.equal(await response2.text(), method === 'HEAD' ? '' : 'Hello, World!') - }) - }) - - it('ignores If-Unmodified-Since when lastModified is disabled', async () => { - let pastDate = new Date('2024-01-01') - let file = createMockFile('Hello, World!') - let handler = createFileHandler(() => file, { lastModified: false }) - - let response = await handler( - createContext('http://localhost/test.txt', { - method, - headers: { 'If-Unmodified-Since': pastDate.toUTCString() }, - }), - ) - - assert.equal(response.status, 200) - assert.equal(await response.text(), method === 'HEAD' ? '' : 'Hello, World!') - }) - }) - } - }) - - describe('Last-Modified support', () => { - for (let method of ['GET', 'HEAD'] as const) { - describe(method, () => { - it('includes Last-Modified header', async () => { - let fileDate = new Date('2025-01-01') - let file = createMockFile('Hello, World!', { lastModified: fileDate.getTime() }) - let handler = createFileHandler(() => file) - - let response = await handler(createContext('http://localhost/test.txt', { method })) - - assert.equal(response.status, 200) - assert.equal(response.headers.get('Last-Modified'), fileDate.toUTCString()) - }) - - it('does not include Last-Modified when lastModified=false', async () => { - let file = createMockFile('Hello, World!') - let handler = createFileHandler(() => file, { lastModified: false }) - - let response = await handler(createContext('http://localhost/test.txt', { method })) - - assert.equal(response.status, 200) - assert.equal(response.headers.get('Last-Modified'), null) - }) - - it('returns 304 (Not Modified) when If-Modified-Since matches Last-Modified', async () => { - let fileDate = new Date('2025-01-01') - let file = createMockFile('Hello, World!', { lastModified: fileDate.getTime() }) - let handler = createFileHandler(() => file) - - let response = await handler( - createContext('http://localhost/test.txt', { - method, - headers: { 'If-Modified-Since': fileDate.toUTCString() }, - }), - ) - - assert.equal(response.status, 304) - assert.equal(await response.text(), '') - }) - - it('returns 304 (Not Modified) when If-Modified-Since is after Last-Modified', async () => { - let fileDate = new Date('2025-01-01') - let futureDate = new Date('2026-01-01') - let file = createMockFile('Hello, World!', { lastModified: fileDate.getTime() }) - let handler = createFileHandler(() => file) - - let response = await handler( - createContext('http://localhost/test.txt', { - method, - headers: { 'If-Modified-Since': futureDate.toUTCString() }, - }), - ) - - assert.equal(response.status, 304) - }) - - it('returns 200 (OK) when If-Modified-Since is before Last-Modified', async () => { - let fileDate = new Date('2025-01-01') - let pastDate = new Date('2024-01-01') - let file = createMockFile('Hello, World!', { lastModified: fileDate.getTime() }) - let handler = createFileHandler(() => file) - - let response = await handler( - createContext('http://localhost/test.txt', { - method, - headers: { 'If-Modified-Since': pastDate.toUTCString() }, - }), - ) - - assert.equal(response.status, 200) - assert.equal(await response.text(), method === 'HEAD' ? '' : 'Hello, World!') - }) - - it('prioritizes ETag over If-Modified-Since when both are present', async () => { - let fileDate = new Date('2025-01-01') - let file = createMockFile('Hello, World!', { lastModified: fileDate.getTime() }) - let handler = createFileHandler(() => file) - - let response1 = await handler(createContext('http://localhost/test.txt', { method })) - let etag = response1.headers.get('ETag') - - let response2 = await handler( - createContext('http://localhost/test.txt', { - method, - headers: { - 'If-None-Match': 'W/"wrong-etag"', - 'If-Modified-Since': fileDate.toUTCString(), - }, - }), - ) - - assert.equal(response2.status, 200) - }) - }) - } - }) - - describe('Range requests (GET only)', () => { - it('includes Accept-Ranges header', async () => { - let file = createMockFile('Hello') - let handler = createFileHandler(() => file) - - let response = await handler(createContext('http://localhost/test.txt')) - - assert.equal(response.headers.get('Accept-Ranges'), 'bytes') - }) - - it('omits Accept-Ranges header when acceptRanges=false', async () => { - let file = createMockFile('Hello') - let handler = createFileHandler(() => file, { acceptRanges: false }) - - let response = await handler(createContext('http://localhost/test.txt')) - - assert.equal(response.headers.get('Accept-Ranges'), null) - }) - - it('handles simple range request', async () => { - let file = createMockFile('0123456789') - let handler = createFileHandler(() => file) - - let response = await handler( - createContext('http://localhost/test.txt', { - headers: { Range: 'bytes=0-4' }, - }), - ) - - assert.equal(response.status, 206) - assert.equal(await response.text(), '01234') - assert.equal(response.headers.get('Content-Range'), 'bytes 0-4/10') - assert.equal(response.headers.get('Content-Length'), '5') - }) - - it('handles range with only start', async () => { - let file = createMockFile('0123456789') - let handler = createFileHandler(() => file) - - let response = await handler( - createContext('http://localhost/test.txt', { - headers: { Range: 'bytes=5-' }, - }), - ) - - assert.equal(response.status, 206) - assert.equal(await response.text(), '56789') - assert.equal(response.headers.get('Content-Range'), 'bytes 5-9/10') - }) - - it('handles suffix range (last N bytes)', async () => { - let file = createMockFile('0123456789') - let handler = createFileHandler(() => file) - - let response = await handler( - createContext('http://localhost/test.txt', { - headers: { Range: 'bytes=-3' }, - }), - ) - - assert.equal(response.status, 206) - assert.equal(await response.text(), '789') - assert.equal(response.headers.get('Content-Range'), 'bytes 7-9/10') - }) - - it('clamps end byte to file size when it exceeds', async () => { - let file = createMockFile('0123456789') - let handler = createFileHandler(() => file) - - let response = await handler( - createContext('http://localhost/test.txt', { - headers: { Range: 'bytes=0-999' }, - }), - ) - - assert.equal(response.status, 206) - assert.equal(await response.text(), '0123456789') - assert.equal(response.headers.get('Content-Range'), 'bytes 0-9/10') - assert.equal(response.headers.get('Content-Length'), '10') - }) - - it('returns 416 (Range Not Satisfiable) for unsatisfiable range', async () => { - let file = createMockFile('0123456789') - let handler = createFileHandler(() => file) - - let response = await handler( - createContext('http://localhost/test.txt', { - headers: { Range: 'bytes=20-30' }, - }), - ) - - assert.equal(response.status, 416) - assert.equal(response.headers.get('Content-Range'), 'bytes */10') - }) - - it('returns 416 (Range Not Satisfiable) for multipart ranges (not supported)', async () => { - let file = createMockFile('0123456789') - let handler = createFileHandler(() => file) - - let response = await handler( - createContext('http://localhost/test.txt', { - headers: { Range: 'bytes=0-2,5-7' }, - }), - ) - - assert.equal(response.status, 416) - assert.equal(response.headers.get('Content-Range'), 'bytes */10') - }) - - it('returns 400 (Bad Request) for malformed multipart range syntax', async () => { - let file = createMockFile('0123456789') - let handler = createFileHandler(() => file) - - let response = await handler( - createContext('http://localhost/test.txt', { - headers: { Range: 'bytes=0-2,garbage' }, - }), - ) - - assert.equal(response.status, 400) - assert.equal(await response.text(), 'Bad Request') - }) - - it('returns 400 (Bad Request) for start > end', async () => { - let file = createMockFile('0123456789') - let handler = createFileHandler(() => file) - - let response = await handler( - createContext('http://localhost/test.txt', { - headers: { Range: 'bytes=5-2' }, - }), - ) - - assert.equal(response.status, 400) - assert.equal(await response.text(), 'Bad Request') - }) - - it('returns 400 (Bad Request) for malformed range', async () => { - let file = createMockFile('0123456789') - let handler = createFileHandler(() => file) - - let response = await handler( - createContext('http://localhost/test.txt', { - headers: { Range: 'invalid' }, - }), - ) - - assert.equal(response.status, 400) - assert.equal(await response.text(), 'Bad Request') - }) - - it('returns 400 (Bad Request) for "bytes=" with no range', async () => { - let file = createMockFile('0123456789') - let handler = createFileHandler(() => file) - - let response = await handler( - createContext('http://localhost/test.txt', { - headers: { Range: 'bytes=' }, - }), - ) - - assert.equal(response.status, 400) - assert.equal(await response.text(), 'Bad Request') - }) - - it('returns full file when acceptRanges=false', async () => { - let file = createMockFile('0123456789') - let handler = createFileHandler(() => file, { acceptRanges: false }) - - let response = await handler( - createContext('http://localhost/test.txt', { - headers: { Range: 'bytes=0-4' }, - }), - ) - - assert.equal(response.status, 200) - assert.equal(await response.text(), '0123456789') - assert.equal(response.headers.get('Content-Range'), null) - }) - - it('returns 412 (Precondition Failed) when If-Match fails before processing Range', async () => { - let file = createMockFile('0123456789') - let handler = createFileHandler(() => file) - - let response = await handler( - createContext('http://localhost/test.txt', { - headers: { - 'If-Match': 'W/"wrong-etag"', - Range: 'bytes=0-4', - }, - }), - ) - - assert.equal(response.status, 412) - assert.equal(response.headers.get('Content-Range'), null) - }) - - it('returns 206 (Partial Content) when If-Match succeeds with Range request (strong ETag)', async () => { - let file = createMockFile('0123456789') - let handler = createFileHandler(() => file, { etag: 'strong' }) - - // Get the strong ETag - let response1 = await handler(createContext('http://localhost/test.txt')) - let etag = response1.headers.get('ETag') - assert.ok(etag) - assert.ok(!etag.startsWith('W/')) // Verify it's a strong ETag - - // If-Match passes, Range should be processed - let response2 = await handler( - createContext('http://localhost/test.txt', { - headers: { - 'If-Match': etag, - Range: 'bytes=0-4', - }, - }), - ) - - assert.equal(response2.status, 206) - assert.equal(await response2.text(), '01234') - assert.equal(response2.headers.get('Content-Range'), 'bytes 0-4/10') - }) - - it('returns 206 (Partial Content) when If-Unmodified-Since passes with Range request', async () => { - let fileDate = new Date('2025-01-01') - let futureDate = new Date('2026-01-01') - let file = createMockFile('0123456789', { lastModified: fileDate.getTime() }) - let handler = createFileHandler(() => file) - - let response = await handler( - createContext('http://localhost/test.txt', { - headers: { - 'If-Unmodified-Since': futureDate.toUTCString(), - Range: 'bytes=0-4', - }, - }), - ) - - assert.equal(response.status, 206) - assert.equal(await response.text(), '01234') - assert.equal(response.headers.get('Content-Range'), 'bytes 0-4/10') - }) - - it('returns 412 (Precondition Failed) when If-Unmodified-Since fails before processing Range', async () => { - let fileDate = new Date('2025-01-01') - let pastDate = new Date('2024-01-01') - let file = createMockFile('0123456789', { lastModified: fileDate.getTime() }) - let handler = createFileHandler(() => file) - - let response = await handler( - createContext('http://localhost/test.txt', { - headers: { - 'If-Unmodified-Since': pastDate.toUTCString(), - Range: 'bytes=0-4', - }, - }), - ) - - assert.equal(response.status, 412) - assert.equal(response.headers.get('Content-Range'), null) - }) - - it('returns 206 (Partial Content) when If-Range matches Last-Modified date', async () => { - let file = createMockFile('0123456789') - let handler = createFileHandler(() => file) - - let response1 = await handler(createContext('http://localhost/test.txt')) - let lastModified = response1.headers.get('Last-Modified') - assert.ok(lastModified) - - let response2 = await handler( - createContext('http://localhost/test.txt', { - headers: { - Range: 'bytes=0-4', - 'If-Range': lastModified, - }, - }), - ) - - assert.equal(response2.status, 206) - assert.equal(await response2.text(), '01234') - assert.equal(response2.headers.get('Content-Range'), 'bytes 0-4/10') - }) - - it('returns 200 (OK, full file) when If-Range does not match Last-Modified date', async () => { - let file = createMockFile('0123456789') - let handler = createFileHandler(() => file) - - let response = await handler( - createContext('http://localhost/test.txt', { - headers: { - Range: 'bytes=0-4', - 'If-Range': 'Wed, 21 Oct 2015 07:28:00 GMT', - }, - }), - ) - - assert.equal(response.status, 200) - assert.equal(await response.text(), '0123456789') - assert.equal(response.headers.get('Content-Range'), null) - }) - - it('ignores If-Range when acceptRanges is disabled', async () => { - let file = createMockFile('0123456789') - let handler = createFileHandler(() => file, { acceptRanges: false }) - - let response = await handler( - createContext('http://localhost/test.txt', { - headers: { - Range: 'bytes=0-4', - 'If-Range': 'Wed, 21 Oct 2015 07:28:00 GMT', - }, - }), - ) - - assert.equal(response.status, 200) - assert.equal(await response.text(), '0123456789') - assert.equal(response.headers.get('Content-Range'), null) - }) - - it('ignores If-Range when lastModified is disabled', async () => { - let file = createMockFile('0123456789') - let handler = createFileHandler(() => file, { lastModified: false }) - - let response = await handler( - createContext('http://localhost/test.txt', { - headers: { - Range: 'bytes=0-4', - 'If-Range': 'Wed, 21 Oct 2015 07:28:00 GMT', - }, - }), - ) - - assert.equal(response.status, 200) - assert.equal(await response.text(), '0123456789') - }) - - it('ignores If-Range with weak ETag value (only Last-Modified date supported)', async () => { - let file = createMockFile('0123456789', { lastModified: 1000000 }) - let handler = createFileHandler(() => file) - - let response1 = await handler(createContext('http://localhost/test.txt')) - let etag = response1.headers.get('ETag') - assert.ok(etag) - - let response2 = await handler( - createContext('http://localhost/test.txt', { - headers: { - Range: 'bytes=0-4', - 'If-Range': etag, - }, - }), - ) - - assert.equal(response2.status, 200) - assert.equal(await response2.text(), '0123456789') - }) - - it('returns full file when If-Range has invalid date format', async () => { - let file = createMockFile('0123456789') - let handler = createFileHandler(() => file) - - let response = await handler( - createContext('http://localhost/test.txt', { - headers: { - Range: 'bytes=0-4', - 'If-Range': '2025-01-01', - }, - }), - ) - - assert.equal(response.status, 200) - assert.equal(await response.text(), '0123456789') - assert.equal(response.headers.get('Content-Range'), null) - }) - - it('returns full file when If-Range is malformed', async () => { - let file = createMockFile('0123456789') - let handler = createFileHandler(() => file) - - let response = await handler( - createContext('http://localhost/test.txt', { - headers: { - Range: 'bytes=0-4', - 'If-Range': 'not-a-valid-value', - }, - }), - ) - - assert.equal(response.status, 200) - assert.equal(await response.text(), '0123456789') - assert.equal(response.headers.get('Content-Range'), null) - }) - - it('returns 304 (Not Modified) when If-None-Match matches etag', async () => { - let file = createMockFile('0123456789', { lastModified: 1000000 }) - let handler = createFileHandler(() => file) - - let response1 = await handler(createContext('http://localhost/test.txt')) - let etag = response1.headers.get('ETag') - assert.ok(etag) - - let response2 = await handler( - createContext('http://localhost/test.txt', { - headers: { - 'If-None-Match': etag, - Range: 'bytes=0-4', - }, - }), - ) - - assert.equal(response2.status, 304) - assert.equal(response2.headers.get('Content-Range'), null) - }) - - it('returns 206 (Partial Content) when If-None-Match does not match', async () => { - let file = createMockFile('0123456789') - let handler = createFileHandler(() => file) - - let response = await handler( - createContext('http://localhost/test.txt', { - headers: { - 'If-None-Match': '"wrong-etag"', - Range: 'bytes=0-4', - }, - }), - ) - - assert.equal(response.status, 206) - assert.equal(await response.text(), '01234') - assert.equal(response.headers.get('Content-Range'), 'bytes 0-4/10') - }) - - it('returns 304 (Not Modified) when If-Modified-Since matches', async () => { - let fileDate = new Date('2025-01-01') - let file = createMockFile('0123456789', { lastModified: fileDate.getTime() }) - let handler = createFileHandler(() => file) - - let response1 = await handler(createContext('http://localhost/test.txt')) - let lastModified = response1.headers.get('Last-Modified') - assert.ok(lastModified) - - let response2 = await handler( - createContext('http://localhost/test.txt', { - headers: { - 'If-Modified-Since': lastModified, - Range: 'bytes=0-4', - }, - }), - ) - - assert.equal(response2.status, 304) - assert.equal(response2.headers.get('Content-Range'), null) - }) - - it('returns 206 (Partial Content) when If-Modified-Since does not match', async () => { - let fileDate = new Date('2025-01-01') - let pastDate = new Date('2024-01-01') - let file = createMockFile('0123456789', { lastModified: fileDate.getTime() }) - let handler = createFileHandler(() => file) - - let response = await handler( - createContext('http://localhost/test.txt', { - headers: { - 'If-Modified-Since': pastDate.toUTCString(), - Range: 'bytes=0-4', - }, - }), - ) - - assert.equal(response.status, 206) - assert.equal(await response.text(), '01234') - assert.equal(response.headers.get('Content-Range'), 'bytes 0-4/10') - }) - - it('returns 304 (Not Modified) with If-None-Match + If-Range when If-None-Match matches', async () => { - let fileDate = new Date('2025-01-01') - let file = createMockFile('0123456789', { lastModified: fileDate.getTime() }) - let handler = createFileHandler(() => file) - - let response1 = await handler(createContext('http://localhost/test.txt')) - let etag = response1.headers.get('ETag') - assert.ok(etag) - let lastModified = response1.headers.get('Last-Modified') - assert.ok(lastModified) - - let response2 = await handler( - createContext('http://localhost/test.txt', { - headers: { - 'If-None-Match': etag, - 'If-Range': lastModified, - Range: 'bytes=0-4', - }, - }), - ) - - assert.equal(response2.status, 304) - assert.equal(response2.headers.get('Content-Range'), null) - }) - - it('returns 206 (Partial Content) with If-None-Match + If-Range when If-Range matches and If-None-Match does not match', async () => { - let fileDate = new Date('2025-01-01') - let file = createMockFile('0123456789', { lastModified: fileDate.getTime() }) - let handler = createFileHandler(() => file) - - let response1 = await handler(createContext('http://localhost/test.txt')) - let lastModified = response1.headers.get('Last-Modified') - assert.ok(lastModified) - - let response2 = await handler( - createContext('http://localhost/test.txt', { - headers: { - 'If-None-Match': '"wrong-etag"', - 'If-Range': lastModified, - Range: 'bytes=0-4', - }, - }), - ) - - assert.equal(response2.status, 206) - assert.equal(await response2.text(), '01234') - assert.equal(response2.headers.get('Content-Range'), 'bytes 0-4/10') - }) - - it('returns 200 (OK) with If-None-Match + If-Range when both If-None-Match and If-Range do not match', async () => { - let fileDate = new Date('2025-01-01') - let pastDate = new Date('2024-01-01') - let file = createMockFile('0123456789', { lastModified: fileDate.getTime() }) - let handler = createFileHandler(() => file) - - let response = await handler( - createContext('http://localhost/test.txt', { - headers: { - 'If-None-Match': '"wrong-etag"', - 'If-Range': pastDate.toUTCString(), - Range: 'bytes=0-4', - }, - }), - ) - - assert.equal(response.status, 200) - assert.equal(await response.text(), '0123456789') - assert.equal(response.headers.get('Content-Range'), null) - }) - - it('ignores Range header for HEAD requests', async () => { - let file = createMockFile('0123456789') - let handler = createFileHandler(() => file) - - let response = await handler( - createContext('http://localhost/test.txt', { - method: 'HEAD', - headers: { Range: 'bytes=0-4' }, - }), - ) - - assert.equal(response.status, 200) - assert.equal(response.headers.get('Accept-Ranges'), 'bytes') - assert.equal(response.headers.get('Content-Range'), null) - assert.equal(await response.text(), '') - }) - }) - - describe('Cache-Control', () => { - it('does not include Cache-Control header by default', async () => { - let file = createMockFile('Hello') - let handler = createFileHandler(() => file) - - let response = await handler(createContext('http://localhost/test.txt')) - - assert.equal(response.headers.get('Cache-Control'), null) - }) - - it('uses custom Cache-Control header', async () => { - let file = createMockFile('Hello') - let handler = createFileHandler(() => file, { - cacheControl: 'no-cache', - }) - - let response = await handler(createContext('http://localhost/test.txt')) - - assert.equal(response.headers.get('Cache-Control'), 'no-cache') - }) - }) - - describe('Content-Type', () => { - it('sets correct Content-Type from file', async () => { - let testCases = [ - { file: 'test.html', type: 'text/html' }, - { file: 'test.css', type: 'text/css' }, - { file: 'test.js', type: 'text/javascript' }, - { file: 'test.json', type: 'application/json' }, - { file: 'test.png', type: 'image/png' }, - { file: 'test.jpg', type: 'image/jpeg' }, - { file: 'test.svg', type: 'image/svg+xml' }, - ] - - for (let { file, type } of testCases) { - let file = createMockFile('test content', { type }) - let handler = createFileHandler(() => file) - - let response = await handler(createContext(`http://localhost/${file}`)) - assert.equal(response.status, 200) - assert.equal(response.headers.get('Content-Type'), type) - } - }) - }) -}) diff --git a/packages/fetch-router/src/lib/file-handler.ts b/packages/fetch-router/src/lib/file-handler.ts deleted file mode 100644 index 04a6e024977..00000000000 --- a/packages/fetch-router/src/lib/file-handler.ts +++ /dev/null @@ -1,458 +0,0 @@ -import SuperHeaders from '@remix-run/headers' - -import type { RequestContext } from './request-context.ts' -import type { RequestHandler } from './request-handler.ts' -import type { RequestMethod } from './request-methods.ts' - -export type FileResolver< - Method extends RequestMethod | 'ANY' = RequestMethod | 'ANY', - Params extends Record = {}, -> = ( - context: RequestContext, -) => { file: File; path: string } | null | Promise<{ file: File; path: string } | null> - -/** - * Custom function for computing file digests. - * - * @param file - The file to hash - * @returns The computed digest as a string - * - * @example - * async (file) => { - * let buffer = await file.arrayBuffer() - * return customHash(buffer) - * } - */ -export type FileDigestFunction = (file: File) => Promise - -/** - * Function to generate cache keys for digest storage. - * - * @param params - Object containing the file path and File object - * @returns The cache key as a string - * - * @example - * ({ path, file }) => `${path}:${file.lastModified}` - * @example - * ({ path, file }) => `v2:${path}:${file.lastModified}` - */ -export type FileDigestCacheKeyFunction = (params: { path: string; file: File }) => string - -/** - * Hash algorithm name for SubtleCrypto.digest() or custom digest function. - */ -type DigestAlgorithm = 'SHA-256' | 'SHA-512' | 'SHA-384' | 'SHA-1' | (string & {}) // Allows any string while providing autocomplete for common algorithms - -/** - * Cache interface for storing computed file digests. - */ -interface DigestCache { - get(key: string): Promise | string | undefined - set(key: string, digest: string): void -} - -export interface FileHandlerOptions { - /** - * Cache-Control header value. If not provided, no Cache-Control header will be set. - * - * @example 'public, max-age=31536000, immutable' // for hashed assets - * @example 'public, max-age=3600' // 1 hour - * @example 'no-cache' // always revalidate - */ - cacheControl?: string - - /** - * ETag generation strategy. - * - * - `'weak'`: Generates weak ETags based on file size and last modified time (`W/"-"`) - * - `'strong'`: Generates strong ETags by hashing file content (requires digest computation) - * - `false`: Disables ETag generation - * - * @default 'weak' - */ - etag?: false | 'weak' | 'strong' - - /** - * Hash algorithm or custom digest function for strong ETags. - * - * When `etag` is `'strong'`, this determines how the file content is hashed. - * - String: Algorithm name for SubtleCrypto.digest() (e.g., 'SHA-256', 'SHA-512') - * - 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 'SHA-512' - * @example async (file) => customHash(await file.arrayBuffer()) - */ - digest?: DigestAlgorithm | FileDigestFunction - - /** - * Cache for storing computed file digests to avoid re-hashing files. - * - * Only used when `etag: 'strong'`. Since hashing file content is expensive, - * providing a cache is strongly recommended for production use. - * - * Any object with `get(key)` and `set(key, digest)` methods can be used. - * If not provided, digests will be computed on every request. - * - * @example new Map() - */ - digestCache?: DigestCache - - /** - * Function to generate cache keys for digest storage. - * - * The default includes file path and last modified time to ensure cache invalidation - * when files change: `${path}:${file.lastModified}` - * - * The `path` is provided by the file resolver. - * - * Only used when `etag: 'strong'` and `digestCache` is provided. - * - * @default ({ path, file }) => `${path}:${file.lastModified}` - * @example ({ path, file }) => `prefix:${path}:${file.lastModified}` - */ - digestCacheKey?: FileDigestCacheKeyFunction - - /** - * Whether to include Last-Modified headers. - * - * @default true - */ - lastModified?: boolean - - /** - * Whether to support HTTP Range requests for partial content. - * - * When enabled, includes Accept-Ranges header and handles Range requests - * with 206 Partial Content responses. - * - * @default true - */ - acceptRanges?: boolean -} - -/** - * Creates a file handler that implements HTTP semantics for serving files. - * - * The handler can be used directly as a route handler, or wrapped in middleware - * that intercepts 404/405 responses to fall through to other handlers. - * - * @param resolveFile - Function that resolves the file for a given request - * @param options - Optional configuration for HTTP headers and features - * @returns A route handler function - * - * @example - * // Use directly as a route handler with weak ETags (default) - * let fileHandler = createFileHandler( - * async (context) => { - * let filePath = path.join('/files', context.params.path) - * try { - * return openFile(filePath) - * } catch { - * return null // -> 404 - * } - * } - * ) - * - * router.get('/files/*path', fileHandler) - * - * @example - * // Use strong ETags with caching - * - * let fileHandler = createFileHandler(resolver, { - * etag: 'strong', - * digestCache: new Map() - * cacheControl: 'public, max-age=31536000, immutable' - * }) - * - * @example - * // Wrap in custom middleware - * router.get('/files/*path', async (context) => { - * let response = await fileHandler(context) - * if (response.status === 404) { - * return new Response('Custom 404', { status: 404 }) - * } - * return response - * }) - */ -export function createFileHandler< - Method extends RequestMethod | 'ANY' = RequestMethod | 'ANY', - Params extends Record = {}, ->( - resolveFile: FileResolver, - options: FileHandlerOptions = {}, -): RequestHandler { - let { - cacheControl, - etag: etagStrategy = 'weak', - digest: digestOption = 'SHA-256', - digestCache, - digestCacheKey = ({ path, file }: { path: string; file: File }) => - `${path}:${file.lastModified}`, - lastModified: lastModifiedEnabled = true, - acceptRanges: acceptRangesEnabled = true, - } = options - - return async (context: RequestContext): Promise => { - let { request } = context - - // Only support GET and HEAD methods - if (request.method !== 'GET' && request.method !== 'HEAD') { - return new Response('Method Not Allowed', { - status: 405, - headers: new SuperHeaders({ - allow: ['GET', 'HEAD'], - }), - }) - } - - // Resolve the file - let resolved = await resolveFile(context) - - if (!resolved) { - return new Response('Not Found', { status: 404 }) - } - - let { file, path } = resolved - - let contentType = file.type - let contentLength = file.size - - let etag: string | undefined - if (etagStrategy === 'weak') { - etag = generateWeakETag(file) - } else if (etagStrategy === 'strong') { - let digest = await computeDigest(path, file, digestOption, digestCache, digestCacheKey) - etag = `"${digest}"` - } - - let lastModified: number | undefined - if (lastModifiedEnabled) { - lastModified = file.lastModified - } - - let acceptRanges: 'bytes' | undefined - if (acceptRangesEnabled) { - acceptRanges = 'bytes' - } - - let hasIfMatch = context.headers.has('If-Match') - - // If-Match support: https://httpwg.org/specs/rfc9110.html#field.if-match - if (etag && hasIfMatch && !context.headers.ifMatch.matches(etag)) { - return new Response('Precondition Failed', { - status: 412, - headers: new SuperHeaders({ - etag, - lastModified, - acceptRanges, - }), - }) - } - - // If-Unmodified-Since support: https://httpwg.org/specs/rfc9110.html#field.if-unmodified-since - if (lastModified && !hasIfMatch) { - let ifUnmodifiedSinceDate = context.headers.ifUnmodifiedSince - if (ifUnmodifiedSinceDate != null) { - let ifUnmodifiedSinceTime = ifUnmodifiedSinceDate.getTime() - if (roundToSecond(lastModified) > roundToSecond(ifUnmodifiedSinceTime)) { - return new Response('Precondition Failed', { - status: 412, - headers: new SuperHeaders({ - etag, - lastModified, - acceptRanges, - }), - }) - } - } - } - - // If-None-Match support: https://httpwg.org/specs/rfc9110.html#field.if-none-match - // If-Modified-Since support: https://httpwg.org/specs/rfc9110.html#field.if-modified-since - if (etag || lastModified) { - let shouldReturnNotModified = false - - if (etag && context.headers.ifNoneMatch.matches(etag)) { - shouldReturnNotModified = true - } else if (lastModified && context.headers.ifNoneMatch.tags.length === 0) { - let ifModifiedSinceDate = context.headers.ifModifiedSince - if (ifModifiedSinceDate != null) { - let ifModifiedSinceTime = ifModifiedSinceDate.getTime() - if (roundToSecond(lastModified) <= roundToSecond(ifModifiedSinceTime)) { - shouldReturnNotModified = true - } - } - } - - if (shouldReturnNotModified) { - return new Response(null, { - status: 304, - headers: new SuperHeaders({ - etag, - lastModified, - acceptRanges, - }), - }) - } - } - - // Range support: https://httpwg.org/specs/rfc9110.html#field.range - // If-Range support: https://httpwg.org/specs/rfc9110.html#field.if-range - if (acceptRanges && request.method === 'GET' && context.headers.has('Range')) { - let range = context.headers.range - - // Check if the Range header was sent but parsing resulted in no valid ranges (malformed) - if (range.ranges.length === 0) { - return new Response('Bad Request', { - status: 400, - }) - } - - let shouldProcessRange = true - - let ifRange = request.headers.get('If-Range') - if (ifRange != null) { - // Since we only use weak ETags, we can only compare Last-Modified timestamps - let ifRangeTime = parseHttpDate(ifRange) - shouldProcessRange = Boolean( - lastModified && ifRangeTime && roundToSecond(lastModified) === roundToSecond(ifRangeTime), - ) - } - - if (shouldProcessRange) { - if (!range.canSatisfy(file.size)) { - return new Response('Range Not Satisfiable', { - status: 416, - headers: new SuperHeaders({ - contentRange: { unit: 'bytes', size: file.size }, - }), - }) - } - - let normalized = range.normalize(file.size) - - // We only support single ranges (not multipart) - if (normalized.length > 1) { - return new Response('Range Not Satisfiable', { - status: 416, - headers: new SuperHeaders({ - contentRange: { unit: 'bytes', size: file.size }, - }), - }) - } - - let { start, end } = normalized[0] - let { size } = file - - return new Response(file.slice(start, end + 1), { - status: 206, - headers: new SuperHeaders({ - contentType, - contentLength: end - start + 1, - contentRange: { unit: 'bytes', start, end, size }, - etag, - lastModified, - cacheControl, - acceptRanges, - }), - }) - } - } - - return new Response(request.method === 'HEAD' ? null : file, { - status: 200, - headers: new SuperHeaders({ - contentType, - contentLength, - etag, - lastModified, - cacheControl, - acceptRanges, - }), - }) - } -} - -function generateWeakETag(file: File): string { - return `W/"${file.size}-${file.lastModified}"` -} - -/** - * Computes a digest (hash) for a file, with optional caching. - */ -async function computeDigest( - path: string, - file: File, - digestOption: DigestAlgorithm | FileDigestFunction, - cache: DigestCache | undefined, - getCacheKey: FileDigestCacheKeyFunction, -): Promise { - if (cache) { - let key = getCacheKey({ path, file }) - let cached = await cache.get(key) - if (cached) { - return cached - } - } - - let digest: string - if (typeof digestOption === 'function') { - // Custom digest function - digest = await digestOption(file) - } else { - // Use SubtleCrypto with algorithm name - digest = await hashFile(file, digestOption) - } - - if (cache) { - let key = getCacheKey({ path, file }) - await cache.set(key, digest) - } - - return digest -} - -/** - * Hashes a file using SubtleCrypto. - */ -async function hashFile(file: File, algorithm: string): Promise { - let buffer = await file.arrayBuffer() - let hashBuffer = await crypto.subtle.digest(algorithm, buffer) - let hashArray = Array.from(new Uint8Array(hashBuffer)) - let hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('') - return hashHex -} - -/** - * Rounds a timestamp to second precision. - * HTTP Last-Modified headers only have second precision, so this is used - * when comparing dates for conditional requests. - */ -function roundToSecond(timestamp: number): number { - return Math.floor(timestamp / 1000) -} - -const imfFixdatePattern = - /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), (\d{2}) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (\d{4}) (\d{2}):(\d{2}):(\d{2}) GMT$/ - -/** - * Parses an HTTP date header value. - * HTTP dates must follow RFC 7231 IMF-fixdate format: - * "Day, DD Mon YYYY HH:MM:SS GMT" (e.g., "Wed, 21 Oct 2015 07:28:00 GMT") - * Returns the timestamp in milliseconds, or null if invalid. - */ -function parseHttpDate(dateString: string): number | null { - if (!imfFixdatePattern.test(dateString)) { - return null - } - - let timestamp = Date.parse(dateString) - if (isNaN(timestamp)) { - return null - } - - return timestamp -} diff --git a/packages/fetch-router/src/lib/find-file.test.ts b/packages/fetch-router/src/lib/find-file.test.ts new file mode 100644 index 00000000000..7c9deb337e9 --- /dev/null +++ b/packages/fetch-router/src/lib/find-file.test.ts @@ -0,0 +1,146 @@ +import * as assert from 'node:assert/strict' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import { describe, it, beforeEach, afterEach } from 'node:test' + +import { findFile } from './find-file.ts' + +describe('findFile', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'find-file-test-')) + }) + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + function createTestFile(filename: string, content: string) { + let filePath = path.join(tmpDir, filename) + let dir = path.dirname(filePath) + + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }) + } + + fs.writeFileSync(filePath, content) + + return filePath + } + + describe('basic functionality', () => { + it('finds a file from the filesystem', async () => { + createTestFile('test.txt', 'Hello, World!') + + let file = await findFile(tmpDir, 'test.txt') + + assert.ok(file) + assert.ok(file instanceof File) + assert.equal(file.name, path.join(path.resolve(tmpDir), 'test.txt')) + assert.equal(await file.text(), 'Hello, World!') + assert.equal(file.type, 'text/plain') + }) + + it('finds files from nested directories', async () => { + createTestFile('dir/subdir/file.txt', 'Nested file') + + let file = await findFile(tmpDir, 'dir/subdir/file.txt') + + assert.ok(file) + assert.ok(file instanceof File) + assert.equal(file.name, path.join(path.resolve(tmpDir), 'dir/subdir/file.txt')) + assert.equal(await file.text(), 'Nested file') + }) + + it('returns null for non-existent file', async () => { + let file = await findFile(tmpDir, 'nonexistent.txt') + + assert.equal(file, null) + }) + + it('returns null when directory is requested', async () => { + let dirPath = path.join(tmpDir, 'subdir') + fs.mkdirSync(dirPath) + + let file = await findFile(tmpDir, 'subdir') + + assert.equal(file, null) + }) + }) + + describe('path resolution', () => { + it('resolves relative root paths', async () => { + let relativeTmpDir = path.relative(process.cwd(), tmpDir) + createTestFile('test.txt', 'Hello') + + let file = await findFile(relativeTmpDir, 'test.txt') + + assert.ok(file) + assert.ok(file instanceof File) + assert.equal(file.name, path.join(path.resolve(relativeTmpDir), 'test.txt')) + assert.equal(await file.text(), 'Hello') + }) + + it('resolves absolute root paths', async () => { + let absoluteTmpDir = path.resolve(tmpDir) + createTestFile('test.txt', 'Hello') + + let file = await findFile(absoluteTmpDir, 'test.txt') + + assert.ok(file) + assert.ok(file instanceof File) + assert.equal(file.name, path.join(absoluteTmpDir, 'test.txt')) + assert.equal(await file.text(), 'Hello') + }) + }) + + describe('security', () => { + it('prevents path traversal with .. in pathname', async () => { + createTestFile('secret.txt', 'Secret content') + + let publicDirName = 'public' + createTestFile(`${publicDirName}/allowed.txt`, 'Allowed content') + + let publicDir = path.join(tmpDir, publicDirName) + + let file = await findFile(publicDir, 'allowed.txt') + assert.ok(file) + assert.ok(file instanceof File) + assert.equal(file.name, path.join(path.resolve(publicDir), 'allowed.txt')) + assert.equal(await file.text(), 'Allowed content') + + let traversalFile = await findFile(publicDir, '../secret.txt') + assert.equal(traversalFile, null) + }) + + it('does not support absolute paths as the second argument', async () => { + let parentDir = path.dirname(tmpDir) + let secretFileName = 'secret-outside-root.txt' + let secretPath = path.join(parentDir, secretFileName) + fs.writeFileSync(secretPath, 'Secret content') + + try { + let result = await findFile(tmpDir, secretPath) + assert.equal(result, null) + } finally { + fs.unlinkSync(secretPath) + } + }) + }) + + describe('error handling', () => { + it('throws non-ENOENT errors', async () => { + // Return a path with invalid characters that will cause an error other than ENOENT + await assert.rejects( + async () => { + await findFile(tmpDir, '\x00invalid') + }, + (error: any) => { + return error.code !== 'ENOENT' + }, + ) + }) + }) +}) diff --git a/packages/fetch-router/src/lib/find-file.ts b/packages/fetch-router/src/lib/find-file.ts new file mode 100644 index 00000000000..f20236aa5a4 --- /dev/null +++ b/packages/fetch-router/src/lib/find-file.ts @@ -0,0 +1,54 @@ +import * as path from 'node:path' + +import { openFile } from '@remix-run/lazy-file/fs' + +/** + * Finds a file on the filesystem within the given root directory. + * + * This function handles filesystem-specific concerns like opening files, + * handling ENOENT errors, and dealing with directories. + * + * The returned File instance will have its `name` property set to the full + * absolute path on the server (not just the basename). + * + * @param root - The root directory to serve files from (absolute or relative to cwd) + * @param relativePath - The relative path from the root to the file + * @returns The resolved file with full path in `file.name`, or null if not found + * + * @example + * let file = await findFile('./public', 'styles.css') + * if (file) { + * return sendFile(file, context) + * } + * return new Response('Not Found', { status: 404 }) + */ +export async function findFile(root: string, relativePath: string): Promise { + // Ensure root is an absolute path + root = path.resolve(root) + + let filePath = path.join(root, relativePath) + + // Security check: ensure the resolved path is within the root directory + if (!filePath.startsWith(root + path.sep) && filePath !== root) { + return null + } + + try { + // Set file.name to the full absolute path for server-side use + let file = await openFile(filePath, { name: filePath }) + return file + } catch (error) { + if (isNoEntityError(error) || isNotAFileError(error)) { + return null + } + throw error + } +} + +function isNoEntityError(error: unknown): error is NodeJS.ErrnoException & { code: 'ENOENT' } { + return error instanceof Error && 'code' in error && error.code === 'ENOENT' +} + +function isNotAFileError(error: unknown): boolean { + return error instanceof Error && error.message.includes('is not a file') +} diff --git a/packages/fetch-router/src/lib/fs-file-resolver.test.ts b/packages/fetch-router/src/lib/fs-file-resolver.test.ts deleted file mode 100644 index 078924a838e..00000000000 --- a/packages/fetch-router/src/lib/fs-file-resolver.test.ts +++ /dev/null @@ -1,201 +0,0 @@ -import * as assert from 'node:assert/strict' -import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' -import { describe, it, beforeEach, afterEach } from 'node:test' -import SuperHeaders, { type SuperHeadersInit } from '@remix-run/headers' - -import { createFsFileResolver } from './fs-file-resolver.ts' -import type { RequestContext } from './request-context.ts' -import { AppStorage } from './app-storage.ts' -import type { RequestMethod } from './request-methods.ts' - -describe('createFsFileResolver', () => { - let tmpDir: string - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fs-file-resolver-test-')) - }) - - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }) - }) - - function createTestFile(filename: string, content: string) { - let filePath = path.join(tmpDir, filename) - let dir = path.dirname(filePath) - - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }) - } - - fs.writeFileSync(filePath, content) - - return filePath - } - - function createContext( - path: string, - options: { - method?: RequestMethod - headers?: SuperHeadersInit - } = {}, - ): RequestContext { - let url = new URL(`http://localhost/${path}`) - let headers = new SuperHeaders(options.headers ?? {}) - return { - formData: undefined, - storage: new AppStorage(), - url: new URL(url), - files: null, - method: options.method || 'GET', - request: new Request(url, { - method: options.method || 'GET', - headers, - }), - params: {}, - headers, - } - } - - function requestPathnameResolver(requestContext: RequestContext<'GET', {}>) { - return new URL(requestContext.request.url).pathname.replace(/^\//, '') - } - - describe('basic functionality', () => { - it('resolves a file from the filesystem', async () => { - createTestFile('test.txt', 'Hello, World!') - - let resolver = createFsFileResolver(tmpDir, requestPathnameResolver) - let result = await resolver(createContext('test.txt')) - - assert.ok(result) - assert.ok(result.file instanceof File) - assert.equal(result.path, path.join(path.resolve(tmpDir), 'test.txt')) - assert.equal(await result.file.text(), 'Hello, World!') - assert.equal(result.file.type, 'text/plain') - }) - - it('resolves files from nested directories', async () => { - createTestFile('dir/subdir/file.txt', 'Nested file') - - let resolver = createFsFileResolver(tmpDir, requestPathnameResolver) - let result = await resolver(createContext('dir/subdir/file.txt')) - - assert.ok(result) - assert.ok(result.file instanceof File) - assert.equal(result.path, path.join(path.resolve(tmpDir), 'dir/subdir/file.txt')) - assert.equal(await result.file.text(), 'Nested file') - }) - - it('returns null for non-existent file', async () => { - let resolver = createFsFileResolver(tmpDir, requestPathnameResolver) - let result = await resolver(createContext('nonexistent.txt')) - - assert.equal(result, null) - }) - - it('returns null when directory is requested', async () => { - let dirPath = path.join(tmpDir, 'subdir') - fs.mkdirSync(dirPath) - - let resolver = createFsFileResolver(tmpDir, requestPathnameResolver) - let result = await resolver(createContext('subdir')) - - assert.equal(result, null) - }) - - it('returns null when path resolver returns null', async () => { - createTestFile('test.txt', 'Hello, World!') - - let resolver = createFsFileResolver(tmpDir, () => null) - let result = await resolver(createContext('test.txt')) - - assert.equal(result, null) - }) - }) - - describe('path resolution', () => { - it('resolves relative root paths', async () => { - let relativeTmpDir = path.relative(process.cwd(), tmpDir) - createTestFile('test.txt', 'Hello') - - let resolver = createFsFileResolver(relativeTmpDir, requestPathnameResolver) - let result = await resolver(createContext('test.txt')) - - assert.ok(result) - assert.ok(result.file instanceof File) - assert.equal(result.path, path.join(path.resolve(relativeTmpDir), 'test.txt')) - assert.equal(await result.file.text(), 'Hello') - }) - - it('resolves absolute root paths', async () => { - let absoluteTmpDir = path.resolve(tmpDir) - createTestFile('test.txt', 'Hello') - - let resolver = createFsFileResolver(absoluteTmpDir, requestPathnameResolver) - let result = await resolver(createContext('test.txt')) - - assert.ok(result) - assert.ok(result.file instanceof File) - assert.equal(result.path, path.join(absoluteTmpDir, 'test.txt')) - assert.equal(await result.file.text(), 'Hello') - }) - }) - - describe('security', () => { - it('prevents path traversal with .. in pathname', async () => { - createTestFile('secret.txt', 'Secret content') - - let publicDirName = 'public' - createTestFile(`${publicDirName}/allowed.txt`, 'Allowed content') - - let publicDir = path.join(tmpDir, publicDirName) - let resolver = createFsFileResolver(publicDir, requestPathnameResolver) - - let result = await resolver(createContext('allowed.txt')) - assert.ok(result) - assert.ok(result.file instanceof File) - assert.equal(result.path, path.join(path.resolve(publicDir), 'allowed.txt')) - assert.equal(await result.file.text(), 'Allowed content') - - let traversalResult = await resolver(createContext('../secret.txt')) - assert.equal(traversalResult, null) - }) - - it('does not support absolute paths in the resolved path', async () => { - let parentDir = path.dirname(tmpDir) - let secretFileName = 'secret-outside-root.txt' - let secretPath = path.join(parentDir, secretFileName) - fs.writeFileSync(secretPath, 'Secret content') - - let resolver = createFsFileResolver(tmpDir, () => secretPath) - - try { - let result = await resolver(createContext('anything')) - assert.equal(result, null) - } finally { - fs.unlinkSync(secretPath) - } - }) - }) - - describe('error handling', () => { - it('throws non-ENOENT errors', async () => { - // Create a resolver with a path that will trigger a permission error or similar - let resolver = createFsFileResolver(tmpDir, () => { - // Return a path with invalid characters that will cause an error other than ENOENT - return '\x00invalid' - }) - - await assert.rejects( - async () => { - await resolver(createContext('test')) - }, - (error: any) => { - return error.code !== 'ENOENT' - }, - ) - }) - }) -}) diff --git a/packages/fetch-router/src/lib/fs-file-resolver.ts b/packages/fetch-router/src/lib/fs-file-resolver.ts deleted file mode 100644 index dd2e4877bb0..00000000000 --- a/packages/fetch-router/src/lib/fs-file-resolver.ts +++ /dev/null @@ -1,68 +0,0 @@ -import * as path from 'node:path' - -import { openFile } from '@remix-run/lazy-file/fs' - -import type { FileResolver } from './file-handler.ts' -import type { RequestContext } from './request-context.ts' -import type { RequestMethod } from './request-methods.ts' - -export type PathResolver< - Method extends RequestMethod | 'ANY' = RequestMethod | 'ANY', - Params extends Record = {}, -> = (context: RequestContext) => string | null | Promise - -/** - * Creates a file resolver that resolves files from the filesystem using the lazy-file API. - * - * This resolver handles filesystem-specific concerns like opening files, - * handling ENOENT errors, and dealing with directories. - * - * @param root - The root directory to serve files from (absolute or relative to cwd) - * @param pathResolver - Function that resolves the relative path for a given request - * @returns A file resolver function that can be passed to `createFileHandler` - * - * @example - * import { createFileHandler } from '@remix-run/fetch-router/file-handler' - * import { createFsFileResolver } from '@remix-run/fetch-router/fs-file-resolver' - * - * let handler = createFileHandler( - * createFsFileResolver('/files', (context) => context.params.path) - * ) - * - * router.get('/files/*path', handler) - */ -export function createFsFileResolver< - Method extends RequestMethod | 'ANY', - Params extends Record = {}, ->(root: string, pathResolver: PathResolver): FileResolver { - // Ensure root is an absolute path - root = path.resolve(root) - - return async (context) => { - let relativePath = await pathResolver(context) - - if (relativePath === null) { - return null - } - - let filePath = path.join(root, relativePath) - - try { - let file = await openFile(filePath) - return { file, path: filePath } - } catch (error) { - if (isNoEntityError(error) || isNotAFileError(error)) { - return null - } - throw error - } - } -} - -function isNoEntityError(error: unknown): error is NodeJS.ErrnoException & { code: 'ENOENT' } { - return error instanceof Error && 'code' in error && error.code === 'ENOENT' -} - -function isNotAFileError(error: unknown): boolean { - return error instanceof Error && error.message.includes('is not a file') -} diff --git a/packages/fetch-router/src/lib/middleware/static.ts b/packages/fetch-router/src/lib/middleware/static.ts index 69d154a528d..0d8c1120091 100644 --- a/packages/fetch-router/src/lib/middleware/static.ts +++ b/packages/fetch-router/src/lib/middleware/static.ts @@ -1,7 +1,5 @@ -import type { FileHandlerOptions } from '../file-handler.ts' -import { createFileHandler } from '../file-handler.ts' -import type { PathResolver } from '../fs-file-resolver.ts' -import { createFsFileResolver } from '../fs-file-resolver.ts' +import { findFile } from '../find-file.ts' +import { file, type FileResponseInit } from '../response-helpers/file.ts' import type { Middleware } from '../middleware.ts' import type { RequestContext } from '../request-context.ts' import type { RequestMethod } from '../request-methods.ts' @@ -9,8 +7,8 @@ import type { RequestMethod } from '../request-methods.ts' export type StaticFilesOptions< Method extends RequestMethod | 'ANY', Params extends Record, -> = FileHandlerOptions & { - path?: PathResolver +> = FileResponseInit & { + path?: (context: RequestContext) => string | null | Promise } /** @@ -54,20 +52,26 @@ export function staticFiles< Method extends RequestMethod | 'ANY', Params extends Record, >(root: string, options: StaticFilesOptions = {}): Middleware { - let { path: pathResolver = requestPathnameResolver, ...fileHandlerOptions } = options - - let handler = createFileHandler( - createFsFileResolver(root, pathResolver), - fileHandlerOptions, - ) + let { path: pathResolver = requestPathnameResolver, ...fileResponseInit } = options return async (context, next) => { - let response = await handler(context) + // Only handle GET and HEAD requests + if (context.request.method !== 'GET' && context.request.method !== 'HEAD') { + return next() + } + + let relativePath = await pathResolver(context) + + if (relativePath === null) { + return next() + } + + let fileToServe = await findFile(root, relativePath) - if (response.status === 404 || response.status === 405) { + if (!fileToServe) { return next() } - return response + return file(fileToServe, context, fileResponseInit) } } diff --git a/packages/fetch-router/src/lib/response-helpers/file.ts b/packages/fetch-router/src/lib/response-helpers/file.ts new file mode 100644 index 00000000000..b4e9a9df495 --- /dev/null +++ b/packages/fetch-router/src/lib/response-helpers/file.ts @@ -0,0 +1,429 @@ +import SuperHeaders from '@remix-run/headers' + +import type { RequestContext } from '../request-context.ts' +import type { RequestMethod } from '../request-methods.ts' + +/** + * Custom function for computing file digests. + * + * @param file - The file to hash + * @returns The computed digest as a string + * + * @example + * async (file) => { + * let buffer = await file.arrayBuffer() + * return customHash(buffer) + * } + */ +export type FileDigestFunction = (file: File) => Promise + +/** + * Function to generate cache keys for digest storage. + * + * @param params - Object containing the file path and File object + * @returns The cache key as a string + * + * @example + * ({ path, file }) => `${path}:${file.lastModified}` + * @example + * ({ path, file }) => `v2:${path}:${file.lastModified}` + */ +export type FileDigestCacheKeyFunction = (params: { path: string; file: File }) => string + +/** + * Hash algorithm name for SubtleCrypto.digest() or custom digest function. + */ +type DigestAlgorithm = 'SHA-256' | 'SHA-512' | 'SHA-384' | 'SHA-1' | (string & {}) // Allows any string while providing autocomplete for common algorithms + +/** + * Cache interface for storing computed file digests. + */ +interface DigestCache { + get(key: string): Promise | string | undefined + set(key: string, digest: string): void +} + +export interface FileResponseInit { + /** + * Cache-Control header value. If not provided, no Cache-Control header will be set. + * + * @example 'public, max-age=31536000, immutable' // for hashed assets + * @example 'public, max-age=3600' // 1 hour + * @example 'no-cache' // always revalidate + */ + cacheControl?: string + + /** + * ETag generation strategy. + * + * - `'weak'`: Generates weak ETags based on file size and last modified time (`W/"-"`) + * - `'strong'`: Generates strong ETags by hashing file content (requires digest computation) + * - `false`: Disables ETag generation + * + * @default 'weak' + */ + etag?: false | 'weak' | 'strong' + + /** + * Hash algorithm or custom digest function for strong ETags. + * + * When `etag` is `'strong'`, this determines how the file content is hashed. + * - String: Algorithm name for SubtleCrypto.digest() (e.g., 'SHA-256', 'SHA-512') + * - 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 'SHA-512' + * @example async (file) => customHash(await file.arrayBuffer()) + */ + digest?: DigestAlgorithm | FileDigestFunction + + /** + * Cache for storing computed file digests to avoid re-hashing files. + * + * Only used when `etag: 'strong'`. Since hashing file content is expensive, + * providing a cache is strongly recommended for production use. + * + * Any object with `get(key)` and `set(key, digest)` methods can be used. + * If not provided, digests will be computed on every request. + * + * @example new Map() + */ + digestCache?: DigestCache + + /** + * Function to generate cache keys for digest storage. + * + * The default includes file path and last modified time to ensure cache invalidation + * when files change: `${path}:${file.lastModified}` + * + * The `path` is provided by the file resolver. + * + * Only used when `etag: 'strong'` and `digestCache` is provided. + * + * @default ({ path, file }) => `${path}:${file.lastModified}` + * @example ({ path, file }) => `prefix:${path}:${file.lastModified}` + */ + digestCacheKey?: FileDigestCacheKeyFunction + + /** + * Whether to include Last-Modified headers. + * + * @default true + */ + lastModified?: boolean + + /** + * Whether to support HTTP Range requests for partial content. + * + * When enabled, includes Accept-Ranges header and handles Range requests + * with 206 Partial Content responses. + * + * @default true + */ + acceptRanges?: boolean +} + +/** + * A helper for working with file [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response)s. + * + * Returns a Response with full HTTP semantics including ETags, Last-Modified, + * conditional requests, and Range support. + * + * The file's `name` property should contain the full absolute path on the server. + * + * @param file - The file to send + * @param context - The request context + * @param init - Optional configuration for HTTP headers and features + * @returns A Response with appropriate headers and body + * + * @example + * let result = await findFile('./public', 'image.jpg') + * if (result) { + * return file(result, context, { + * cacheControl: 'public, max-age=3600' + * }) + * } + */ +export async function file< + Method extends RequestMethod | 'ANY' = RequestMethod | 'ANY', + Params extends Record = {}, +>( + fileToSend: File, + context: RequestContext, + init: FileResponseInit = {}, +): Promise { + let { request } = context + let path = fileToSend.name + + let { + cacheControl, + etag: etagStrategy = 'weak', + digest: digestOption = 'SHA-256', + digestCache, + digestCacheKey = ({ path, file }: { path: string; file: File }) => + `${path}:${file.lastModified}`, + lastModified: lastModifiedEnabled = true, + acceptRanges: acceptRangesEnabled = true, + } = init + + // Only support GET and HEAD methods + if (request.method !== 'GET' && request.method !== 'HEAD') { + return new Response('Method Not Allowed', { + status: 405, + headers: new SuperHeaders({ + allow: ['GET', 'HEAD'], + }), + }) + } + + let contentType = fileToSend.type + let contentLength = fileToSend.size + + let etag: string | undefined + if (etagStrategy === 'weak') { + etag = generateWeakETag(fileToSend) + } else if (etagStrategy === 'strong') { + let digest = await computeDigest(path, fileToSend, digestOption, digestCache, digestCacheKey) + etag = `"${digest}"` + } + + let lastModified: number | undefined + if (lastModifiedEnabled) { + lastModified = fileToSend.lastModified + } + + let acceptRanges: 'bytes' | undefined + if (acceptRangesEnabled) { + acceptRanges = 'bytes' + } + + let hasIfMatch = context.headers.has('If-Match') + + // If-Match support: https://httpwg.org/specs/rfc9110.html#field.if-match + if (etag && hasIfMatch && !context.headers.ifMatch.matches(etag)) { + return new Response('Precondition Failed', { + status: 412, + headers: new SuperHeaders({ + etag, + lastModified, + acceptRanges, + }), + }) + } + + // If-Unmodified-Since support: https://httpwg.org/specs/rfc9110.html#field.if-unmodified-since + if (lastModified && !hasIfMatch) { + let ifUnmodifiedSinceDate = context.headers.ifUnmodifiedSince + if (ifUnmodifiedSinceDate != null) { + let ifUnmodifiedSinceTime = ifUnmodifiedSinceDate.getTime() + if (roundToSecond(lastModified) > roundToSecond(ifUnmodifiedSinceTime)) { + return new Response('Precondition Failed', { + status: 412, + headers: new SuperHeaders({ + etag, + lastModified, + acceptRanges, + }), + }) + } + } + } + + // If-None-Match support: https://httpwg.org/specs/rfc9110.html#field.if-none-match + // If-Modified-Since support: https://httpwg.org/specs/rfc9110.html#field.if-modified-since + if (etag || lastModified) { + let shouldReturnNotModified = false + + if (etag && context.headers.ifNoneMatch.matches(etag)) { + shouldReturnNotModified = true + } else if (lastModified && context.headers.ifNoneMatch.tags.length === 0) { + let ifModifiedSinceDate = context.headers.ifModifiedSince + if (ifModifiedSinceDate != null) { + let ifModifiedSinceTime = ifModifiedSinceDate.getTime() + if (roundToSecond(lastModified) <= roundToSecond(ifModifiedSinceTime)) { + shouldReturnNotModified = true + } + } + } + + if (shouldReturnNotModified) { + return new Response(null, { + status: 304, + headers: new SuperHeaders({ + etag, + lastModified, + acceptRanges, + }), + }) + } + } + + // Range support: https://httpwg.org/specs/rfc9110.html#field.range + // If-Range support: https://httpwg.org/specs/rfc9110.html#field.if-range + if (acceptRanges && request.method === 'GET' && context.headers.has('Range')) { + let range = context.headers.range + + // Check if the Range header was sent but parsing resulted in no valid ranges (malformed) + if (range.ranges.length === 0) { + return new Response('Bad Request', { + status: 400, + }) + } + + let shouldProcessRange = true + + let ifRange = request.headers.get('If-Range') + if (ifRange != null) { + // Since we only use weak ETags, we can only compare Last-Modified timestamps + let ifRangeTime = parseHttpDate(ifRange) + shouldProcessRange = Boolean( + lastModified && ifRangeTime && roundToSecond(lastModified) === roundToSecond(ifRangeTime), + ) + } + + if (shouldProcessRange) { + if (!range.canSatisfy(fileToSend.size)) { + return new Response('Range Not Satisfiable', { + status: 416, + headers: new SuperHeaders({ + contentRange: { unit: 'bytes', size: fileToSend.size }, + }), + }) + } + + let normalized = range.normalize(fileToSend.size) + + // We only support single ranges (not multipart) + if (normalized.length > 1) { + return new Response('Range Not Satisfiable', { + status: 416, + headers: new SuperHeaders({ + contentRange: { unit: 'bytes', size: fileToSend.size }, + }), + }) + } + + let { start, end } = normalized[0] + let { size } = fileToSend + + return new Response(fileToSend.slice(start, end + 1), { + status: 206, + headers: new SuperHeaders({ + contentType, + contentLength: end - start + 1, + contentRange: { unit: 'bytes', start, end, size }, + etag, + lastModified, + cacheControl, + acceptRanges, + }), + }) + } + } + + return new Response(request.method === 'HEAD' ? null : fileToSend, { + status: 200, + headers: new SuperHeaders({ + contentType, + contentLength, + etag, + lastModified, + cacheControl, + acceptRanges, + }), + }) +} + +function generateWeakETag(file: File): string { + return `W/"${file.size}-${file.lastModified}"` +} + +/** + * Computes a digest (hash) for a file, with optional caching. + * + * @param path - The file path (for cache key generation) + * @param file - The file to hash + * @param digestOption - Algorithm name or custom digest function + * @param cache - Optional cache for storing computed digests + * @param getCacheKey - Function to generate cache key from path and file + * @returns The computed digest as a hex string + */ +async function computeDigest( + path: string, + file: File, + digestOption: DigestAlgorithm | FileDigestFunction, + cache: DigestCache | undefined, + getCacheKey: FileDigestCacheKeyFunction, +): Promise { + // Check cache first if provided + if (cache) { + let key = getCacheKey({ path, file }) + let cached = await cache.get(key) + if (cached) { + return cached + } + } + + // Compute digest + let digest: string + if (typeof digestOption === 'function') { + // Custom digest function + digest = await digestOption(file) + } else { + // Use SubtleCrypto with algorithm name + digest = await hashFile(file, digestOption) + } + + // Store in cache if provided + if (cache) { + let key = getCacheKey({ path, file }) + await cache.set(key, digest) + } + + return digest +} + +/** + * Hashes a file using SubtleCrypto. + * + * @param file - The file to hash + * @param algorithm - Hash algorithm name (e.g., 'SHA-256') + * @returns The hash as a hex string + */ +async function hashFile(file: File, algorithm: string): Promise { + let buffer = await file.arrayBuffer() + let hashBuffer = await crypto.subtle.digest(algorithm, buffer) + let hashArray = Array.from(new Uint8Array(hashBuffer)) + return hashArray.map((byte) => byte.toString(16).padStart(2, '0')).join('') +} + +/** + * Rounds a timestamp to the nearest second for comparison. + */ +function roundToSecond(time: number): number { + return Math.floor(time / 1000) +} + +const imfFixdatePattern = + /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), (\d{2}) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (\d{4}) (\d{2}):(\d{2}):(\d{2}) GMT$/ + +/** + * Parses an HTTP date header value. + * HTTP dates must follow RFC 7231 IMF-fixdate format: + * "Day, DD Mon YYYY HH:MM:SS GMT" (e.g., "Wed, 21 Oct 2015 07:28:00 GMT") + * Returns the timestamp in milliseconds, or null if invalid. + */ +function parseHttpDate(dateString: string): number | null { + if (!imfFixdatePattern.test(dateString)) { + return null + } + + let timestamp = Date.parse(dateString) + if (isNaN(timestamp)) { + return null + } + + return timestamp +} diff --git a/packages/fetch-router/src/response-helpers.ts b/packages/fetch-router/src/response-helpers.ts index 32aab038f10..b589f2ba434 100644 --- a/packages/fetch-router/src/response-helpers.ts +++ b/packages/fetch-router/src/response-helpers.ts @@ -1,3 +1,9 @@ +export { + file, + type FileDigestCacheKeyFunction, + type FileDigestFunction, + type FileResponseInit, +} from './lib/response-helpers/file.ts' export { html } from './lib/response-helpers/html.ts' export { json } from './lib/response-helpers/json.ts' export { redirect } from './lib/response-helpers/redirect.ts' From 323054dbcc45dc810389e8955dfb096e242cb616 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Mon, 10 Nov 2025 10:56:18 +1100 Subject: [PATCH 12/19] Move findFile util into lazy-file --- demos/bookstore/app/router.ts | 2 +- packages/fetch-router/README.md | 41 ++-- packages/fetch-router/package.json | 5 - packages/fetch-router/src/find-file.ts | 2 - .../fetch-router/src/lib/find-file.test.ts | 146 ------------ packages/fetch-router/src/lib/find-file.ts | 54 ----- .../fetch-router/src/lib/middleware/static.ts | 3 +- .../src/lib/response-helpers/file.ts | 60 +++-- packages/lazy-file/README.md | 19 +- packages/lazy-file/src/fs.test.ts | 224 ++++++++++++++++++ packages/lazy-file/src/fs.ts | 70 +++++- packages/lazy-file/src/lib/lazy-file.ts | 8 + 12 files changed, 367 insertions(+), 267 deletions(-) delete mode 100644 packages/fetch-router/src/find-file.ts delete mode 100644 packages/fetch-router/src/lib/find-file.test.ts delete mode 100644 packages/fetch-router/src/lib/find-file.ts create mode 100644 packages/lazy-file/src/fs.test.ts diff --git a/demos/bookstore/app/router.ts b/demos/bookstore/app/router.ts index d72d56c106c..1752afb1c1e 100644 --- a/demos/bookstore/app/router.ts +++ b/demos/bookstore/app/router.ts @@ -1,11 +1,11 @@ import { createRouter } from '@remix-run/fetch-router' import { asyncContext } from '@remix-run/fetch-router/async-context-middleware' -import { findFile } from '@remix-run/fetch-router/find-file' 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 { file } from '@remix-run/fetch-router/response-helpers' import { staticFiles } from '@remix-run/fetch-router/static-middleware' +import { findFile } from '@remix-run/lazy-file/fs' import { routes } from '../routes.ts' import { uploadHandler } from './utils/uploads.ts' diff --git a/packages/fetch-router/README.md b/packages/fetch-router/README.md index 0251adef835..2b4e8e09764 100644 --- a/packages/fetch-router/README.md +++ b/packages/fetch-router/README.md @@ -667,11 +667,10 @@ See the [`@remix-run/html-template` documentation](https://github.com/remix-run/ ### Working with Files -The router provides several tools for serving files, built as layers that compose together: +The router provides a couple of tools for serving files: - **`file()` response helper** - The primitive for returning file responses with full HTTP semantics -- **`findFile()` function** - Helps map route patterns to files on disk -- **`staticFiles()` middleware** - Convenience middleware that combines both +- **`staticFiles()` middleware** - Convenience middleware for serving files from a directory #### The `file()` Response Helper @@ -686,13 +685,13 @@ The `file()` response helper returns a `Response` for a file with full HTTP sema - **HEAD** request support ```ts +import * as res from '@remix-run/fetch-router/response-helpers' import { openFile } from '@remix-run/lazy-file/fs' -import { file } from '@remix-run/fetch-router/response-helpers' -router.get('/downloads/:filename', async (context) => { - let downloadFile = await openFile(`./downloads/${context.params.filename}`) +router.get('/assets/:filename', async (context) => { + let file = await openFile(`./assets/${context.params.filename}`) - return file(downloadFile, context) + return res.file(file, context) }) ``` @@ -701,7 +700,7 @@ router.get('/downloads/:filename', async (context) => { The `file()` helper accepts an optional third argument with configuration options: ```ts -return file(downloadFile, context, { +return res.file(file, context, { // Cache-Control header value. // Defaults to `undefined` (no Cache-Control header). cacheControl: 'public, max-age=3600', @@ -728,7 +727,7 @@ return file(downloadFile, context, { 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 file(assetFile, context, { +return res.file(file, context, { etag: 'strong', }) ``` @@ -736,7 +735,7 @@ return file(assetFile, context, { 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 file(assetFile, context, { +return res.file(file, context, { etag: 'strong', // Specify a different SubtleCrypto.digest() algorithm @@ -747,7 +746,7 @@ return file(assetFile, context, { Or provide a custom digest function: ```ts -return file(assetFile, context, { +return res.file(file, context, { etag: 'strong', // Custom digest function @@ -761,7 +760,7 @@ return file(assetFile, context, { When using strong ETags, you can provide a cache to avoid re-hashing files on every request: ```ts -return file(assetFile, context, { +return res.file(file, context, { etag: 'strong', // Provide a cache to avoid re-hashing files on every request. @@ -769,34 +768,32 @@ return file(assetFile, context, { digestCache: new Map(), // Customize the logic for generating the cache key. - // Defaults to `({ path, file }) => `${path}:${file.lastModified}``. - digestCacheKey: (context) => context.params.path, + // Defaults to `(file) => `${file.path ?? file.name}:${file.size}:${file.lastModified}``. + digestCacheKey: (file) => `${file.path ?? file.name}:${file.size}:${file.lastModified}`, }) ``` #### Using `findFile()` with `file()` -When you need to map a route pattern to a directory of files on disk, use `findFile()` to resolve files before sending them with the `file()` response helper: +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 { findFile } from '@remix-run/fetch-router/find-file' -import { file } from '@remix-run/fetch-router/response-helpers' +import * as res from '@remix-run/fetch-router/response-helpers' +import { findFile } from '@remix-run/lazy-file/fs' router.get('/assets/*path', async (context) => { - let assetFile = await findFile('./public/assets', context.params.path) + let file = await findFile('./public/assets', context.params.path) - if (!assetFile) { + if (!file) { return new Response('Not Found', { status: 404 }) } - return file(assetFile, context, { + return res.file(file, context, { cacheControl: 'public, max-age=3600', }) }) ``` -The `findFile()` function returns a `File` object with its `name` property set to the full absolute path on the server, or `null` if the file doesn't exist or is outside the specified root directory. - #### The `staticFiles()` Middleware For convenience, the `staticFiles()` middleware combines `findFile()` and `file()` into a single middleware: diff --git a/packages/fetch-router/package.json b/packages/fetch-router/package.json index b2e9e51320e..f3e4646227a 100644 --- a/packages/fetch-router/package.json +++ b/packages/fetch-router/package.json @@ -22,7 +22,6 @@ "exports": { ".": "./src/index.ts", "./async-context-middleware": "./src/async-context-middleware.ts", - "./find-file": "./src/find-file.ts", "./form-data-middleware": "./src/form-data-middleware.ts", "./logger-middleware": "./src/logger-middleware.ts", "./method-override-middleware": "./src/method-override-middleware.ts", @@ -40,10 +39,6 @@ "types": "./dist/async-context-middleware.d.ts", "default": "./dist/async-context-middleware.js" }, - "./find-file": { - "types": "./dist/find-file.d.ts", - "default": "./dist/find-file.js" - }, "./form-data-middleware": { "types": "./dist/form-data-middleware.d.ts", "default": "./dist/form-data-middleware.js" diff --git a/packages/fetch-router/src/find-file.ts b/packages/fetch-router/src/find-file.ts deleted file mode 100644 index 6ded30b4101..00000000000 --- a/packages/fetch-router/src/find-file.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { findFile } from './lib/find-file.ts' - diff --git a/packages/fetch-router/src/lib/find-file.test.ts b/packages/fetch-router/src/lib/find-file.test.ts deleted file mode 100644 index 7c9deb337e9..00000000000 --- a/packages/fetch-router/src/lib/find-file.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import * as assert from 'node:assert/strict' -import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' -import { describe, it, beforeEach, afterEach } from 'node:test' - -import { findFile } from './find-file.ts' - -describe('findFile', () => { - let tmpDir: string - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'find-file-test-')) - }) - - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }) - }) - - function createTestFile(filename: string, content: string) { - let filePath = path.join(tmpDir, filename) - let dir = path.dirname(filePath) - - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }) - } - - fs.writeFileSync(filePath, content) - - return filePath - } - - describe('basic functionality', () => { - it('finds a file from the filesystem', async () => { - createTestFile('test.txt', 'Hello, World!') - - let file = await findFile(tmpDir, 'test.txt') - - assert.ok(file) - assert.ok(file instanceof File) - assert.equal(file.name, path.join(path.resolve(tmpDir), 'test.txt')) - assert.equal(await file.text(), 'Hello, World!') - assert.equal(file.type, 'text/plain') - }) - - it('finds files from nested directories', async () => { - createTestFile('dir/subdir/file.txt', 'Nested file') - - let file = await findFile(tmpDir, 'dir/subdir/file.txt') - - assert.ok(file) - assert.ok(file instanceof File) - assert.equal(file.name, path.join(path.resolve(tmpDir), 'dir/subdir/file.txt')) - assert.equal(await file.text(), 'Nested file') - }) - - it('returns null for non-existent file', async () => { - let file = await findFile(tmpDir, 'nonexistent.txt') - - assert.equal(file, null) - }) - - it('returns null when directory is requested', async () => { - let dirPath = path.join(tmpDir, 'subdir') - fs.mkdirSync(dirPath) - - let file = await findFile(tmpDir, 'subdir') - - assert.equal(file, null) - }) - }) - - describe('path resolution', () => { - it('resolves relative root paths', async () => { - let relativeTmpDir = path.relative(process.cwd(), tmpDir) - createTestFile('test.txt', 'Hello') - - let file = await findFile(relativeTmpDir, 'test.txt') - - assert.ok(file) - assert.ok(file instanceof File) - assert.equal(file.name, path.join(path.resolve(relativeTmpDir), 'test.txt')) - assert.equal(await file.text(), 'Hello') - }) - - it('resolves absolute root paths', async () => { - let absoluteTmpDir = path.resolve(tmpDir) - createTestFile('test.txt', 'Hello') - - let file = await findFile(absoluteTmpDir, 'test.txt') - - assert.ok(file) - assert.ok(file instanceof File) - assert.equal(file.name, path.join(absoluteTmpDir, 'test.txt')) - assert.equal(await file.text(), 'Hello') - }) - }) - - describe('security', () => { - it('prevents path traversal with .. in pathname', async () => { - createTestFile('secret.txt', 'Secret content') - - let publicDirName = 'public' - createTestFile(`${publicDirName}/allowed.txt`, 'Allowed content') - - let publicDir = path.join(tmpDir, publicDirName) - - let file = await findFile(publicDir, 'allowed.txt') - assert.ok(file) - assert.ok(file instanceof File) - assert.equal(file.name, path.join(path.resolve(publicDir), 'allowed.txt')) - assert.equal(await file.text(), 'Allowed content') - - let traversalFile = await findFile(publicDir, '../secret.txt') - assert.equal(traversalFile, null) - }) - - it('does not support absolute paths as the second argument', async () => { - let parentDir = path.dirname(tmpDir) - let secretFileName = 'secret-outside-root.txt' - let secretPath = path.join(parentDir, secretFileName) - fs.writeFileSync(secretPath, 'Secret content') - - try { - let result = await findFile(tmpDir, secretPath) - assert.equal(result, null) - } finally { - fs.unlinkSync(secretPath) - } - }) - }) - - describe('error handling', () => { - it('throws non-ENOENT errors', async () => { - // Return a path with invalid characters that will cause an error other than ENOENT - await assert.rejects( - async () => { - await findFile(tmpDir, '\x00invalid') - }, - (error: any) => { - return error.code !== 'ENOENT' - }, - ) - }) - }) -}) diff --git a/packages/fetch-router/src/lib/find-file.ts b/packages/fetch-router/src/lib/find-file.ts deleted file mode 100644 index f20236aa5a4..00000000000 --- a/packages/fetch-router/src/lib/find-file.ts +++ /dev/null @@ -1,54 +0,0 @@ -import * as path from 'node:path' - -import { openFile } from '@remix-run/lazy-file/fs' - -/** - * Finds a file on the filesystem within the given root directory. - * - * This function handles filesystem-specific concerns like opening files, - * handling ENOENT errors, and dealing with directories. - * - * The returned File instance will have its `name` property set to the full - * absolute path on the server (not just the basename). - * - * @param root - The root directory to serve files from (absolute or relative to cwd) - * @param relativePath - The relative path from the root to the file - * @returns The resolved file with full path in `file.name`, or null if not found - * - * @example - * let file = await findFile('./public', 'styles.css') - * if (file) { - * return sendFile(file, context) - * } - * return new Response('Not Found', { status: 404 }) - */ -export async function findFile(root: string, relativePath: string): Promise { - // Ensure root is an absolute path - root = path.resolve(root) - - let filePath = path.join(root, relativePath) - - // Security check: ensure the resolved path is within the root directory - if (!filePath.startsWith(root + path.sep) && filePath !== root) { - return null - } - - try { - // Set file.name to the full absolute path for server-side use - let file = await openFile(filePath, { name: filePath }) - return file - } catch (error) { - if (isNoEntityError(error) || isNotAFileError(error)) { - return null - } - throw error - } -} - -function isNoEntityError(error: unknown): error is NodeJS.ErrnoException & { code: 'ENOENT' } { - return error instanceof Error && 'code' in error && error.code === 'ENOENT' -} - -function isNotAFileError(error: unknown): boolean { - return error instanceof Error && error.message.includes('is not a file') -} diff --git a/packages/fetch-router/src/lib/middleware/static.ts b/packages/fetch-router/src/lib/middleware/static.ts index 0d8c1120091..a32417c9e6e 100644 --- a/packages/fetch-router/src/lib/middleware/static.ts +++ b/packages/fetch-router/src/lib/middleware/static.ts @@ -1,4 +1,5 @@ -import { findFile } from '../find-file.ts' +import { findFile } from '@remix-run/lazy-file/fs' + import { file, type FileResponseInit } from '../response-helpers/file.ts' import type { Middleware } from '../middleware.ts' import type { RequestContext } from '../request-context.ts' diff --git a/packages/fetch-router/src/lib/response-helpers/file.ts b/packages/fetch-router/src/lib/response-helpers/file.ts index b4e9a9df495..c9b507350f5 100644 --- a/packages/fetch-router/src/lib/response-helpers/file.ts +++ b/packages/fetch-router/src/lib/response-helpers/file.ts @@ -15,20 +15,18 @@ import type { RequestMethod } from '../request-methods.ts' * return customHash(buffer) * } */ -export type FileDigestFunction = (file: File) => Promise +export type FileDigestFunction = (file: F) => Promise /** * Function to generate cache keys for digest storage. * - * @param params - Object containing the file path and File object + * @param file - The File object * @returns The cache key as a string * * @example - * ({ path, file }) => `${path}:${file.lastModified}` - * @example - * ({ path, file }) => `v2:${path}:${file.lastModified}` + * (file) => `${file.path ?? file.name}:${file.size}:${file.lastModified}` */ -export type FileDigestCacheKeyFunction = (params: { path: string; file: File }) => string +export type FileDigestCacheKeyFunction = (file: F) => string /** * Hash algorithm name for SubtleCrypto.digest() or custom digest function. @@ -43,7 +41,7 @@ interface DigestCache { set(key: string, digest: string): void } -export interface FileResponseInit { +export interface FileResponseInit { /** * Cache-Control header value. If not provided, no Cache-Control header will be set. * @@ -77,7 +75,7 @@ export interface FileResponseInit { * @example 'SHA-512' * @example async (file) => customHash(await file.arrayBuffer()) */ - digest?: DigestAlgorithm | FileDigestFunction + digest?: DigestAlgorithm | FileDigestFunction /** * Cache for storing computed file digests to avoid re-hashing files. @@ -95,17 +93,15 @@ export interface FileResponseInit { /** * Function to generate cache keys for digest storage. * - * The default includes file path and last modified time to ensure cache invalidation - * when files change: `${path}:${file.lastModified}` - * - * The `path` is provided by the file resolver. + * The default includes file path (using `file.path` if available, otherwise + * `file.name`), size, and last modified time to ensure cache invalidation + * when files change. * * Only used when `etag: 'strong'` and `digestCache` is provided. * - * @default ({ path, file }) => `${path}:${file.lastModified}` - * @example ({ path, file }) => `prefix:${path}:${file.lastModified}` + * @default (file) => `${file.path ?? file.name}:${file.size}:${file.lastModified}` */ - digestCacheKey?: FileDigestCacheKeyFunction + digestCacheKey?: FileDigestCacheKeyFunction /** * Whether to include Last-Modified headers. @@ -131,7 +127,9 @@ export interface FileResponseInit { * Returns a Response with full HTTP semantics including ETags, Last-Modified, * conditional requests, and Range support. * - * The file's `name` property should contain the full absolute path on the server. + * The file can optionally include an additional `path` property containing the + * full absolute path on disk. If provided, it will be used for generating cache + * keys; otherwise `file.name` is used. * * @param file - The file to send * @param context - The request context @@ -147,23 +145,23 @@ export interface FileResponseInit { * } */ export async function file< + F extends File = File, Method extends RequestMethod | 'ANY' = RequestMethod | 'ANY', Params extends Record = {}, >( - fileToSend: File, + fileToSend: F, context: RequestContext, - init: FileResponseInit = {}, + init: FileResponseInit = {}, ): Promise { let { request } = context - let path = fileToSend.name let { cacheControl, etag: etagStrategy = 'weak', digest: digestOption = 'SHA-256', digestCache, - digestCacheKey = ({ path, file }: { path: string; file: File }) => - `${path}:${file.lastModified}`, + digestCacheKey = (file: F & { path?: string }) => + `${file.path ?? file.name}:${file.size}:${file.lastModified}`, lastModified: lastModifiedEnabled = true, acceptRanges: acceptRangesEnabled = true, } = init @@ -185,7 +183,7 @@ export async function file< if (etagStrategy === 'weak') { etag = generateWeakETag(fileToSend) } else if (etagStrategy === 'strong') { - let digest = await computeDigest(path, fileToSend, digestOption, digestCache, digestCacheKey) + let digest = await computeDigest(fileToSend, digestOption, digestCache, digestCacheKey) etag = `"${digest}"` } @@ -343,23 +341,21 @@ function generateWeakETag(file: File): string { /** * Computes a digest (hash) for a file, with optional caching. * - * @param path - The file path (for cache key generation) - * @param file - The file to hash + * @param file - The file to hash (may include an additional `path` property) * @param digestOption - Algorithm name or custom digest function * @param cache - Optional cache for storing computed digests - * @param getCacheKey - Function to generate cache key from path and file + * @param getCacheKey - Function to generate cache key from file * @returns The computed digest as a hex string */ -async function computeDigest( - path: string, - file: File, - digestOption: DigestAlgorithm | FileDigestFunction, +async function computeDigest( + file: F, + digestOption: DigestAlgorithm | FileDigestFunction, cache: DigestCache | undefined, - getCacheKey: FileDigestCacheKeyFunction, + getCacheKey: FileDigestCacheKeyFunction, ): Promise { // Check cache first if provided if (cache) { - let key = getCacheKey({ path, file }) + let key = getCacheKey(file) let cached = await cache.get(key) if (cached) { return cached @@ -378,7 +374,7 @@ async function computeDigest( // Store in cache if provided if (cache) { - let key = getCacheKey({ path, file }) + let key = getCacheKey(file) await cache.set(key, digest) } diff --git a/packages/lazy-file/README.md b/packages/lazy-file/README.md index a3d4f981dec..8f489fd5b4a 100644 --- a/packages/lazy-file/README.md +++ b/packages/lazy-file/README.md @@ -70,12 +70,17 @@ file.type // "text/plain" The `lazy-file/fs` export provides functions for reading from and writing to the local filesystem using the `File` API. ```ts -import { openFile, writeFile } from '@remix-run/lazy-file/fs' +import { openFile, findFile, writeFile } from '@remix-run/lazy-file/fs' // No data is read at this point, it's just a reference to a // file on the local filesystem let file = openFile('./path/to/file.json') +// Alternatively, find a file within a root directory, returning +// null if the file is not found +let foundFile = await findFile('./public', 'favicon.ico') +console.log(foundFile ? `Found file at ${foundFile.path}` : 'File not found') + // Data is read when you call file.text() (or any of the // other Blob methods, like file.bytes(), file.stream(), etc.) let json = JSON.parse(await file.text()) @@ -98,6 +103,18 @@ let blob = imageFile.slice(100) All file contents are read on-demand and nothing is ever buffered. +Files loaded via `lazy-file/fs` include an additional `path` property containing the full absolute path. + +```ts +import { openFile, findFile, type FsFile } from '@remix-run/lazy-file/fs' + +let file: FsFile = openFile('./config.json') +console.log(file.path) + +let foundFile: FsFile = await findFile('./public', 'favicon.ico') +console.log(foundFile ? `Found file at ${foundFile.path}` : 'File not found') +``` + ## Related Packages - [`file-storage`](https://github.com/remix-run/remix/tree/main/packages/file-storage) - Uses `lazy-file/fs` internally to create streaming `File` objects from storage on disk diff --git a/packages/lazy-file/src/fs.test.ts b/packages/lazy-file/src/fs.test.ts new file mode 100644 index 00000000000..4e6673e412f --- /dev/null +++ b/packages/lazy-file/src/fs.test.ts @@ -0,0 +1,224 @@ +import * as assert from 'node:assert' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import { describe, it } from 'node:test' + +import { findFile, openFile, type FsFile } from './fs.ts' + +describe('openFile', () => { + let tmpDir: string + + function setup() { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lazy-file-test-')) + } + + function teardown() { + if (tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true }) + } + } + + function createTestFile(filename: string, content: string = 'test content'): string { + let filePath = path.join(tmpDir, filename) + let dir = path.dirname(filePath) + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }) + } + fs.writeFileSync(filePath, content) + return filePath + } + + it('returns a file with path property', async () => { + setup() + let filePath = createTestFile('test.txt', 'hello world') + + let result: FsFile = openFile(filePath) + + assert.equal(result.name, 'test.txt') + assert.equal(result.size, 11) + assert.equal(result.path, path.resolve(filePath)) + assert.equal(await result.text(), 'hello world') + + teardown() + }) + + it('returns a file with absolute path', async () => { + setup() + let filePath = createTestFile('test.txt', 'hello world') + + let result: FsFile = openFile(filePath) + + assert.ok(path.isAbsolute(result.path)) + assert.equal(result.path, path.resolve(filePath)) + + teardown() + }) +}) + +describe('findFile', () => { + let tmpDir: string + + function setup() { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lazy-file-test-')) + } + + function teardown() { + if (tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true }) + } + } + + function createTestFile(filename: string, content: string = 'test content'): void { + let filePath = path.join(tmpDir, filename) + let dir = path.dirname(filePath) + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }) + } + fs.writeFileSync(filePath, content) + } + + it('finds a file in the root directory', async () => { + setup() + createTestFile('test.txt', 'hello world') + + let result = await findFile(tmpDir, 'test.txt') + + assert.ok(result) + assert.equal(result.name, 'test.txt') + assert.equal(result.size, 11) + assert.equal(result.path, path.join(path.resolve(tmpDir), 'test.txt')) + assert.equal(await result.text(), 'hello world') + + teardown() + }) + + it('finds a file in a nested directory', async () => { + setup() + createTestFile('assets/styles.css', 'body { color: red; }') + + let result = await findFile(tmpDir, 'assets/styles.css') + + assert.ok(result) + assert.equal(result.name, 'styles.css') + assert.equal(result.path, path.join(path.resolve(tmpDir), 'assets', 'styles.css')) + + teardown() + }) + + it('returns null for non-existent file', async () => { + setup() + + let result = await findFile(tmpDir, 'nonexistent.txt') + + assert.equal(result, null) + + teardown() + }) + + it('returns null when requesting a directory', async () => { + setup() + createTestFile('assets/test.txt', 'content') + + let result = await findFile(tmpDir, 'assets') + + assert.equal(result, null) + + teardown() + }) + + it('prevents path traversal with .. in pathname', async () => { + setup() + createTestFile('secret.txt', 'Secret content') + + let publicDir = path.join(tmpDir, 'public') + fs.mkdirSync(publicDir, { recursive: true }) + createTestFile('public/allowed.txt', 'Allowed content') + + // Try to access file outside of public directory + let result = await findFile(publicDir, '../secret.txt') + + assert.equal(result, null) + + teardown() + }) + + it('prevents path traversal with absolute path', async () => { + setup() + createTestFile('secret.txt', 'Secret content') + + let publicDir = path.join(tmpDir, 'public') + fs.mkdirSync(publicDir, { recursive: true }) + createTestFile('public/allowed.txt', 'Allowed content') + + // Try to access file with absolute path + let secretPath = path.join(tmpDir, 'secret.txt') + let result = await findFile(publicDir, secretPath) + + assert.equal(result, null) + + teardown() + }) + + it('handles relative root paths', async () => { + setup() + createTestFile('test.txt', 'hello') + + let cwd = process.cwd() + try { + process.chdir(tmpDir) + let result = await findFile('.', 'test.txt') + + assert.ok(result) + assert.equal(result.name, 'test.txt') + } finally { + process.chdir(cwd) + } + + teardown() + }) + + it('handles absolute root paths', async () => { + setup() + createTestFile('test.txt', 'hello') + + let absoluteTmpDir = path.resolve(tmpDir) + let result = await findFile(absoluteTmpDir, 'test.txt') + + assert.ok(result) + assert.equal(result.name, 'test.txt') + assert.equal(result.path, path.join(absoluteTmpDir, 'test.txt')) + assert.equal(await result.text(), 'hello') + + teardown() + }) + + it('returns file with correct path property', async () => { + setup() + createTestFile('test.txt', 'content') + + let result = await findFile(tmpDir, 'test.txt') + + assert.ok(result) + assert.ok(path.isAbsolute(result.path)) + assert.equal(result.path, path.join(path.resolve(tmpDir), 'test.txt')) + + teardown() + }) + + it('throws non-ENOENT errors', async () => { + setup() + + // Use invalid characters that will cause an error other than ENOENT + await assert.rejects( + async () => { + await findFile(tmpDir, '\x00invalid') + }, + (error: any) => { + return error.code !== 'ENOENT' + }, + ) + + teardown() + }) +}) diff --git a/packages/lazy-file/src/fs.ts b/packages/lazy-file/src/fs.ts index 9a264490e03..f0ddcc3d658 100644 --- a/packages/lazy-file/src/fs.ts +++ b/packages/lazy-file/src/fs.ts @@ -4,6 +4,12 @@ import { lookup } from 'mrmime' import { type LazyContent, LazyFile } from './lib/lazy-file.ts' +/** + * A `File` from the filesystem with an additional `path` property containing + * the full absolute path to the file on disk. + */ +export type FsFile = File & { path: string } + export interface OpenFileOptions { /** * Overrides the name of the file. Default is the name of the file on disk. @@ -22,13 +28,17 @@ export interface OpenFileOptions { /** * Returns a `File` from the local filesytem. * + * The returned file includes an additional `path` property containing the full + * absolute path to the file on disk. This is useful for file handling where the + * full path is often needed (e.g., for caching or logging). + * * [MDN `File` Reference](https://developer.mozilla.org/en-US/docs/Web/API/File) * * @param filename The path to the file * @param options Options to override the file's metadata - * @returns A `File` object + * @returns A `File` object with an additional `path` property */ -export function openFile(filename: string, options?: OpenFileOptions): File { +export function openFile(filename: string, options?: OpenFileOptions): FsFile { let stats = fs.statSync(filename) if (!stats.isFile()) { @@ -42,10 +52,13 @@ export function openFile(filename: string, options?: OpenFileOptions): File { }, } + let absolutePath = path.resolve(filename) + return new LazyFile(content, options?.name ?? path.basename(filename), { type: options?.type ?? lookup(filename), lastModified: options?.lastModified ?? stats.mtimeMs, - }) as File + path: absolutePath, + }) as FsFile } function streamFile( @@ -68,6 +81,57 @@ function streamFile( }) } +/** + * Finds a file on the filesystem within the given root directory. + * + * Returns `null` if the file doesn't exist, is not a file, or is outside the + * specified root directory. + * + * The returned file includes an additional `path` property containing the full + * absolute path to the file on disk. This is useful for file handling where the + * full path is often needed (e.g., for caching or logging). + * + * @param root - The root directory to serve files from (absolute or relative to cwd) + * @param relativePath - The relative path from the root to the file + * @returns A `File` with an additional `path` property, or null if not found + * + * @example + * let file = await findFile('./public', 'styles.css') + * if (file) { + * // file.path contains the full absolute path + * console.log(file.path) // e.g., /Users/you/project/public/styles.css + * } + */ +export async function findFile(root: string, relativePath: string): Promise { + // Ensure root is an absolute path + root = path.resolve(root) + + let filePath = path.join(root, relativePath) + + // Security check: ensure the resolved path is within the root directory + if (!filePath.startsWith(root + path.sep) && filePath !== root) { + return null + } + + try { + let file = await openFile(filePath) + return file + } catch (error) { + if (isNoEntityError(error) || isNotAFileError(error)) { + return null + } + throw error + } +} + +function isNoEntityError(error: unknown): error is NodeJS.ErrnoException & { code: 'ENOENT' } { + return error instanceof Error && 'code' in error && error.code === 'ENOENT' +} + +function isNotAFileError(error: unknown): boolean { + return error instanceof Error && error.message.includes('is not a file') +} + // Preserve backwards compat with v3.0 export { type OpenFileOptions as GetFileOptions, openFile as getFile } diff --git a/packages/lazy-file/src/lib/lazy-file.ts b/packages/lazy-file/src/lib/lazy-file.ts index d052858d9d3..8bd580e8a1a 100644 --- a/packages/lazy-file/src/lib/lazy-file.ts +++ b/packages/lazy-file/src/lib/lazy-file.ts @@ -114,6 +114,11 @@ export interface LazyFileOptions extends LazyBlobOptions { * [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/File/File#lastmodified) */ lastModified?: number + /** + * The full path to the file on disk. This property is automatically set when + * loading files from disk via `lazy-file/fs`. + */ + path?: string } /** @@ -125,6 +130,7 @@ export interface LazyFileOptions extends LazyBlobOptions { * * - 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 + * - The constructor may accent a `path` in the options to specify the full path to the file on disk * * 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. @@ -133,10 +139,12 @@ export interface LazyFileOptions extends LazyBlobOptions { */ export class LazyFile extends File { readonly #content: BlobContent + readonly path: string | undefined constructor(parts: BlobPart[] | LazyContent, name: string, options?: LazyFileOptions) { super([], name, options) this.#content = new BlobContent(parts, options) + this.path = options?.path } /** From cc504e36d2d743806c38b148e27aa717ac22a6ed Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Mon, 10 Nov 2025 17:37:12 +1100 Subject: [PATCH 13/19] Add `matches` to If-Range header, refactor, update docs --- demos/bookstore/app/router.ts | 10 +- .../src/lib/response-helpers/file.ts | 69 +++----- packages/headers/README.md | 116 ++++++++++++- packages/headers/src/index.ts | 1 + packages/headers/src/lib/if-range.test.ts | 158 ++++++++++++++++++ packages/headers/src/lib/if-range.ts | 88 ++++++++++ .../headers/src/lib/super-headers.test.ts | 24 ++- packages/headers/src/lib/super-headers.ts | 22 +++ packages/headers/src/lib/utils.ts | 38 +++++ packages/lazy-file/src/fs.test.ts | 12 -- packages/lazy-file/src/fs.ts | 5 +- 11 files changed, 466 insertions(+), 77 deletions(-) create mode 100644 packages/headers/src/lib/if-range.test.ts create mode 100644 packages/headers/src/lib/if-range.ts diff --git a/demos/bookstore/app/router.ts b/demos/bookstore/app/router.ts index 1752afb1c1e..11e9011ce27 100644 --- a/demos/bookstore/app/router.ts +++ b/demos/bookstore/app/router.ts @@ -3,7 +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 { file } from '@remix-run/fetch-router/response-helpers' +import * as res from '@remix-run/fetch-router/response-helpers' import { staticFiles } from '@remix-run/fetch-router/static-middleware' import { findFile } from '@remix-run/lazy-file/fs' @@ -42,11 +42,11 @@ middleware.push( export let router = createRouter({ middleware }) router.get(routes.assets, async (context) => { - let assetFile = await findFile('./public/assets', context.params.path) - if (!assetFile) { + let file = await findFile('./public/assets', context.params.path) + if (!file) { return new Response('Not Found', { status: 404 }) } - return file(assetFile, context, { + return res.file(file, context, { cacheControl: 'no-store, must-revalidate', etag: false, lastModified: false, @@ -59,7 +59,7 @@ router.get(routes.images, async (context) => { if (!imageFile) { return new Response('Not Found', { status: 404 }) } - return file(imageFile, context, { + return res.file(imageFile, context, { cacheControl: 'no-store, must-revalidate', etag: false, lastModified: false, diff --git a/packages/fetch-router/src/lib/response-helpers/file.ts b/packages/fetch-router/src/lib/response-helpers/file.ts index c9b507350f5..7b4f4e220de 100644 --- a/packages/fetch-router/src/lib/response-helpers/file.ts +++ b/packages/fetch-router/src/lib/response-helpers/file.ts @@ -213,10 +213,9 @@ export async function file< // If-Unmodified-Since support: https://httpwg.org/specs/rfc9110.html#field.if-unmodified-since if (lastModified && !hasIfMatch) { - let ifUnmodifiedSinceDate = context.headers.ifUnmodifiedSince - if (ifUnmodifiedSinceDate != null) { - let ifUnmodifiedSinceTime = ifUnmodifiedSinceDate.getTime() - if (roundToSecond(lastModified) > roundToSecond(ifUnmodifiedSinceTime)) { + let ifUnmodifiedSince = context.headers.ifUnmodifiedSince + if (ifUnmodifiedSince != null) { + if (roundToSecond(lastModified) > roundToSecond(ifUnmodifiedSince)) { return new Response('Precondition Failed', { status: 412, headers: new SuperHeaders({ @@ -237,10 +236,9 @@ export async function file< if (etag && context.headers.ifNoneMatch.matches(etag)) { shouldReturnNotModified = true } else if (lastModified && context.headers.ifNoneMatch.tags.length === 0) { - let ifModifiedSinceDate = context.headers.ifModifiedSince - if (ifModifiedSinceDate != null) { - let ifModifiedSinceTime = ifModifiedSinceDate.getTime() - if (roundToSecond(lastModified) <= roundToSecond(ifModifiedSinceTime)) { + let ifModifiedSince = context.headers.ifModifiedSince + if (ifModifiedSince != null) { + if (roundToSecond(lastModified) <= roundToSecond(ifModifiedSince)) { shouldReturnNotModified = true } } @@ -270,18 +268,13 @@ export async function file< }) } - let shouldProcessRange = true - - let ifRange = request.headers.get('If-Range') - if (ifRange != null) { - // Since we only use weak ETags, we can only compare Last-Modified timestamps - let ifRangeTime = parseHttpDate(ifRange) - shouldProcessRange = Boolean( - lastModified && ifRangeTime && roundToSecond(lastModified) === roundToSecond(ifRangeTime), - ) - } - - if (shouldProcessRange) { + // If-Range support: https://httpwg.org/specs/rfc9110.html#field.if-range + if ( + context.headers.ifRange.matches({ + etag, + lastModified, + }) + ) { if (!range.canSatisfy(fileToSend.size)) { return new Response('Range Not Satisfiable', { status: 416, @@ -291,10 +284,10 @@ export async function file< }) } - let normalized = range.normalize(fileToSend.size) + let normalizedRanges = range.normalize(fileToSend.size) // We only support single ranges (not multipart) - if (normalized.length > 1) { + if (normalizedRanges.length > 1) { return new Response('Range Not Satisfiable', { status: 416, headers: new SuperHeaders({ @@ -303,7 +296,7 @@ export async function file< }) } - let { start, end } = normalized[0] + let { start, end } = normalizedRanges[0] let { size } = fileToSend return new Response(fileToSend.slice(start, end + 1), { @@ -396,30 +389,10 @@ async function hashFile(file: File, algorithm: string): Promise { } /** - * Rounds a timestamp to the nearest second for comparison. + * Rounds a timestamp to the nearest second. + * HTTP dates only have second precision, so this is useful for date comparisons. */ -function roundToSecond(time: number): number { - return Math.floor(time / 1000) -} - -const imfFixdatePattern = - /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), (\d{2}) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (\d{4}) (\d{2}):(\d{2}):(\d{2}) GMT$/ - -/** - * Parses an HTTP date header value. - * HTTP dates must follow RFC 7231 IMF-fixdate format: - * "Day, DD Mon YYYY HH:MM:SS GMT" (e.g., "Wed, 21 Oct 2015 07:28:00 GMT") - * Returns the timestamp in milliseconds, or null if invalid. - */ -function parseHttpDate(dateString: string): number | null { - if (!imfFixdatePattern.test(dateString)) { - return null - } - - let timestamp = Date.parse(dateString) - if (isNaN(timestamp)) { - return null - } - - return timestamp +function roundToSecond(time: number | Date): number { + let timestamp = time instanceof Date ? time.getTime() : time + return Math.floor(timestamp / 1000) } diff --git a/packages/headers/README.md b/packages/headers/README.md index a35fc42a978..a83d6391994 100644 --- a/packages/headers/README.md +++ b/packages/headers/README.md @@ -72,6 +72,10 @@ headers.acceptLanguage.getPreferred(['en', 'fr']) // 'en' // Accept-Ranges headers.acceptRanges = 'bytes' +// Allow +headers.allow = ['GET', 'POST', 'PUT'] +headers.get('Allow') // 'GET, POST, PUT' + // Connection headers.connection = 'close' @@ -106,18 +110,42 @@ headers.get('Cookie') // 'session_id=abc123; user_id=12345; theme=dark' // Host headers.host = 'example.com' +// If-Match +headers.ifMatch = ['67ab43', '54ed21'] +headers.get('If-Match') // '"67ab43", "54ed21"' + +headers.ifMatch.matches('67ab43') // true +headers.ifMatch.matches('abc123') // false + // If-None-Match headers.ifNoneMatch = ['67ab43', '54ed21'] headers.get('If-None-Match') // '"67ab43", "54ed21"' +headers.ifNoneMatch.matches('67ab43') // true +headers.ifNoneMatch.matches('abc123') // false + +// If-Range +headers.ifRange = new Date('2021-01-01T00:00:00Z') +headers.get('If-Range') // 'Fri, 01 Jan 2021 00:00:00 GMT' + +headers.ifRange.matches({ lastModified: 1609459200000 }) // true (timestamp) +headers.ifRange.matches({ lastModified: new Date('2021-01-01T00:00:00Z') }) // true (Date) + // Last-Modified -headers.lastModified = new Date() -// or headers.lastModified = new Date().getTime(); -headers.get('Last-Modified') // 'Fri, 20 Dec 2024 08:08:05 GMT' +headers.lastModified = new Date('2021-01-01T00:00:00Z') +// or headers.lastModified = new Date('2021-01-01T00:00:00Z').getTime(); +headers.get('Last-Modified') // 'Fri, 01 Jan 2021 00:00:00 GMT' // Location headers.location = 'https://example.com' +// Range +headers.range = 'bytes=200-1000' + +headers.range.unit // "bytes" +headers.range.ranges // [{ start: 200, end: 1000 }] +headers.range.canSatisfy(2000) // true + // Referer headers.referer = 'https://example.com/' @@ -421,6 +449,31 @@ let header = new Cookie([ ]) ``` +### If-Match + +```ts +import { IfMatch } from '@remix-run/headers' + +let header = new IfMatch('"67ab43", "54ed21"') + +header.has('67ab43') // true +header.has('21ba69') // false + +// Check if precondition passes +header.matches('"67ab43"') // true +header.matches('"abc123"') // false + +// Note: Uses strong comparison only (weak ETags never match) +let weakHeader = new IfMatch('W/"67ab43"') +weakHeader.matches('W/"67ab43"') // false + +// Alternative init styles +let header = new IfMatch(['67ab43', '54ed21']) +let header = new IfMatch({ + tags: ['67ab43', '54ed21'], +}) +``` + ### If-None-Match ```ts @@ -433,13 +486,68 @@ header.has('21ba69') // false header.matches('"67ab43"') // true -// Alternative init style +// Alternative init styles let header = new IfNoneMatch(['67ab43', '54ed21']) let header = new IfNoneMatch({ tags: ['67ab43', '54ed21'], }) ``` +### If-Range + +```ts +import { IfRange } from '@remix-run/headers' + +// Initialize with HTTP date +let header = new IfRange('Fri, 01 Jan 2021 00:00:00 GMT') +header.matches({ lastModified: 1609459200000 }) // true +header.matches({ lastModified: new Date('2021-01-01T00:00:00Z') }) // true (Date also supported) + +// Initialize with Date object +let header = new IfRange(new Date('2021-01-01T00:00:00Z')) +header.matches({ lastModified: 1609459200000 }) // true + +// Initialize with strong ETag +let header = new IfRange('"67ab43"') +header.matches({ etag: '"67ab43"' }) // true + +// Never matches weak ETags +let weakHeader = new IfRange('W/"67ab43"') +header.matches({ etag: 'W/"67ab43"' }) // false + +// Returns true if header is not present (range should proceed unconditionally) +let emptyHeader = new IfRange('') +emptyHeader.matches({ etag: '"67ab43"' }) // true +``` + +### Range + +```ts +import { Range } from '@remix-run/headers' + +let header = new Range('bytes=200-1000') + +header.unit // "bytes" +header.ranges // [{ start: 200, end: 1000 }] + +// Check if ranges can be satisfied for a given file size +header.canSatisfy(2000) // true +header.canSatisfy(500) // false (end is beyond file size) + +// Multiple ranges +let header = new Range('bytes=0-499, 1000-1499') +header.ranges.length // 2 + +// Alternative init style +let header = new Range({ + unit: 'bytes', + ranges: [ + { start: 200, end: 1000 }, + { start: 2000, end: 2999 }, + ], +}) +``` + ### Set-Cookie ```ts diff --git a/packages/headers/src/index.ts b/packages/headers/src/index.ts index 686d6f11bea..e4b88cf6619 100644 --- a/packages/headers/src/index.ts +++ b/packages/headers/src/index.ts @@ -8,6 +8,7 @@ export { type ContentTypeInit, ContentType } from './lib/content-type.ts' export { type CookieInit, Cookie } from './lib/cookie.ts' export { type IfMatchInit, IfMatch } from './lib/if-match.ts' export { type IfNoneMatchInit, IfNoneMatch } from './lib/if-none-match.ts' +export { IfRange } from './lib/if-range.ts' export { type RangeInit, Range } from './lib/range.ts' export { type CookieProperties, type SetCookieInit, SetCookie } from './lib/set-cookie.ts' diff --git a/packages/headers/src/lib/if-range.test.ts b/packages/headers/src/lib/if-range.test.ts new file mode 100644 index 00000000000..c7c0d8ff74c --- /dev/null +++ b/packages/headers/src/lib/if-range.test.ts @@ -0,0 +1,158 @@ +import * as assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { IfRange } from './if-range.ts' + +describe('IfRange', () => { + let testDate = new Date('2021-01-01T00:00:00Z') + let testDateString = testDate.toUTCString() // 'Fri, 01 Jan 2021 00:00:00 GMT' + let testTimestamp = testDate.getTime() // 1609459200000 + + it('initializes with an empty string', () => { + let header = new IfRange('') + assert.equal(header.value, '') + }) + + it('initializes with a string (HTTP date)', () => { + let header = new IfRange(testDateString) + assert.equal(header.value, testDateString) + }) + + it('initializes with a string (ETag)', () => { + let header = new IfRange('"67ab43"') + assert.equal(header.value, '"67ab43"') + }) + + it('initializes with a Date', () => { + let header = new IfRange(testDate) + assert.equal(header.value, testDateString) + }) + + it('converts to a string', () => { + let header = new IfRange(testDateString) + assert.equal(header.toString(), testDateString) + + let header2 = new IfRange('"67ab43"') + assert.equal(header2.toString(), '"67ab43"') + }) + + describe('matches()', () => { + describe('with HTTP dates', () => { + it('matches when lastModified matches the date (timestamp)', () => { + let header = new IfRange(testDateString) + assert.ok(header.matches({ lastModified: testTimestamp })) + }) + + it('matches when lastModified matches the date (Date object)', () => { + let header = new IfRange(testDateString) + assert.ok(header.matches({ lastModified: testDate })) + }) + + it('does not match when lastModified is later', () => { + let header = new IfRange(testDateString) + assert.ok(!header.matches({ lastModified: testTimestamp + 1000 })) + }) + + it('does not match when lastModified is earlier', () => { + let header = new IfRange(testDateString) + assert.ok(!header.matches({ lastModified: testTimestamp - 1000 })) + }) + + it('handles fractional seconds (rounds to second)', () => { + let header = new IfRange(testDateString) + // Same second, different milliseconds + assert.ok(header.matches({ lastModified: testTimestamp + 123 })) + assert.ok(header.matches({ lastModified: testTimestamp + 999 })) + }) + + it('does not match when lastModified is null', () => { + let header = new IfRange(testDateString) + assert.ok(!header.matches({ lastModified: null })) + }) + + it('does not match when lastModified is missing', () => { + let header = new IfRange(testDateString) + assert.ok(!header.matches({})) + }) + + it('does not match invalid HTTP dates', () => { + let header = new IfRange('not-a-date') + assert.ok(!header.matches({ lastModified: testTimestamp })) + }) + }) + + describe('with ETags', () => { + it('matches when etag matches (strong ETag)', () => { + let header = new IfRange('"67ab43"') + assert.ok(header.matches({ etag: '"67ab43"' })) + }) + + it('matches when etag matches (without quotes)', () => { + let header = new IfRange('67ab43') + assert.ok(header.matches({ etag: '67ab43' })) + }) + + it('does not match when etag differs', () => { + let header = new IfRange('"67ab43"') + assert.ok(!header.matches({ etag: '"54ed21"' })) + }) + + it('does not match when etag is null', () => { + let header = new IfRange('"67ab43"') + assert.ok(!header.matches({ etag: null })) + }) + + it('does not match when etag is missing', () => { + let header = new IfRange('"67ab43"') + assert.ok(!header.matches({})) + }) + + it('does not match weak ETags (per RFC 7233)', () => { + let header = new IfRange('W/"67ab43"') + assert.ok(!header.matches({ etag: 'W/"67ab43"' })) + }) + + it('does not match when resource ETag is weak', () => { + let header = new IfRange('"67ab43"') + assert.ok(!header.matches({ etag: 'W/"67ab43"' })) + }) + + it('does not match when If-Range is weak and resource is strong', () => { + let header = new IfRange('W/"67ab43"') + assert.ok(!header.matches({ etag: '"67ab43"' })) + }) + }) + + describe('with both etag and lastModified', () => { + it('matches date when value is a date', () => { + let header = new IfRange(testDateString) + assert.ok( + header.matches({ + etag: '"67ab43"', + lastModified: testTimestamp, + }), + ) + }) + + it('matches etag when value is an ETag', () => { + let header = new IfRange('"67ab43"') + assert.ok( + header.matches({ + etag: '"67ab43"', + lastModified: testTimestamp, + }), + ) + }) + }) + + describe('with empty value', () => { + it('matches unconditionally (condition passes when header is not present)', () => { + let header = new IfRange('') + assert.ok(header.matches({ etag: '"67ab43"' })) + assert.ok(header.matches({ lastModified: testTimestamp })) + assert.ok(header.matches({ etag: '"67ab43"', lastModified: testTimestamp })) + assert.ok(header.matches({})) + }) + }) + }) +}) diff --git a/packages/headers/src/lib/if-range.ts b/packages/headers/src/lib/if-range.ts new file mode 100644 index 00000000000..dc9f713ce87 --- /dev/null +++ b/packages/headers/src/lib/if-range.ts @@ -0,0 +1,88 @@ +import { type HeaderValue } from './header-value.ts' +import { parseHttpDate, roundToSecond } from './utils.ts' +import { quoteEtag } from './utils.ts' + +/** + * The value of an `If-Range` HTTP header. + * + * The `If-Range` header can contain either an entity tag (ETag) or an HTTP date. + * + * [MDN `If-Range` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Range) + * + * [HTTP/1.1 Specification](https://datatracker.ietf.org/doc/html/rfc7233#section-3.2) + */ +export class IfRange implements HeaderValue { + value: string = '' + + constructor(init?: string | Date) { + if (init) { + if (typeof init === 'string') { + this.value = init + } else { + this.value = init.toUTCString() + } + } + } + + /** + * Checks if the `If-Range` condition is satisfied for the current resource state. + * + * This method always returns `true` if the `If-Range` header is not present, + * meaning the range request should proceed unconditionally. + * + * The `If-Range` header can contain either: + * - An HTTP date (RFC 7231 IMF-fixdate format) + * - An entity tag (ETag) + * + * When comparing ETags, only strong entity tags are matched as per RFC 7233. + * Weak entity tags (prefixed with `W/`) are never considered a match. + * + * @param resource The current resource state to compare against + * @returns `true` if the condition is satisfied, `false` otherwise + * + * @example + * ```ts + * let ifRange = new IfRange('Wed, 21 Oct 2015 07:28:00 GMT') + * ifRange.matches({ lastModified: 1445412480000 }) // true if dates match + * ifRange.matches({ lastModified: new Date('2015-10-21T07:28:00Z') }) // true + * + * let ifRange2 = new IfRange('"abc123"') + * ifRange2.matches({ etag: '"abc123"' }) // true + * ifRange2.matches({ etag: 'W/"abc123"' }) // false (weak ETag) + * ``` + */ + matches(resource: { etag?: string | null; lastModified?: number | Date | null }): boolean { + if (!this.value) { + return true + } + + // Try parsing as HTTP date first + let dateTimestamp = parseHttpDate(this.value) + if (dateTimestamp !== null && resource.lastModified != null) { + let resourceTimestamp = + resource.lastModified instanceof Date + ? resource.lastModified.getTime() + : resource.lastModified + return roundToSecond(dateTimestamp) === roundToSecond(resourceTimestamp) + } + + // Otherwise treat as ETag + if (resource.etag != null) { + let normalizedTag = quoteEtag(this.value) + let normalizedResourceTag = quoteEtag(resource.etag) + + // Weak tags never match in If-Range (strong comparison only, per RFC 7233) + if (normalizedTag.startsWith('W/') || normalizedResourceTag.startsWith('W/')) { + return false + } + + return normalizedTag === normalizedResourceTag + } + + return false + } + + toString() { + return this.value + } +} diff --git a/packages/headers/src/lib/super-headers.test.ts b/packages/headers/src/lib/super-headers.test.ts index caaaf2540fe..88a68251940 100644 --- a/packages/headers/src/lib/super-headers.test.ts +++ b/packages/headers/src/lib/super-headers.test.ts @@ -11,6 +11,7 @@ import { ContentType } from './content-type.ts' import { Cookie } from './cookie.ts' import { IfMatch } from './if-match.ts' import { IfNoneMatch } from './if-none-match.ts' +import { IfRange } from './if-range.ts' import { Range } from './range.ts' import { SuperHeaders } from './super-headers.ts' @@ -705,12 +706,25 @@ describe('SuperHeaders', () => { headers.ifNoneMatch = ['67ab43', '54ed21'] assert.deepEqual(headers.ifNoneMatch.tags, ['"67ab43"', '"54ed21"']) - headers.ifNoneMatch = { tags: ['67ab43', '54ed21'] } - assert.deepEqual(headers.ifNoneMatch.tags, ['"67ab43"', '"54ed21"']) + assert.equal(headers.ifNoneMatch.toString(), '"67ab43", "54ed21"') + }) - headers.ifNoneMatch = null - assert.ok(headers.ifNoneMatch instanceof IfNoneMatch) - assert.equal(headers.ifNoneMatch.toString(), '') + it('supports the ifRange property', () => { + let headers = new SuperHeaders() + + assert.ok(headers.ifRange instanceof IfRange) + assert.equal(headers.ifRange.value, '') + + headers.ifRange = 'Fri, 01 Jan 2021 00:00:00 GMT' + assert.equal(headers.ifRange.value, 'Fri, 01 Jan 2021 00:00:00 GMT') + + headers.ifRange = new Date('2021-01-01T00:00:00Z') + assert.equal(headers.ifRange.value, 'Fri, 01 Jan 2021 00:00:00 GMT') + + headers.ifRange = '"67ab43"' + assert.equal(headers.ifRange.value, '"67ab43"') + + assert.equal(headers.ifRange.toString(), '"67ab43"') }) it('supports the ifUnmodifiedSince property', () => { diff --git a/packages/headers/src/lib/super-headers.ts b/packages/headers/src/lib/super-headers.ts index 8cface1f93e..93948de19a4 100644 --- a/packages/headers/src/lib/super-headers.ts +++ b/packages/headers/src/lib/super-headers.ts @@ -10,6 +10,7 @@ import { canonicalHeaderName } from './header-names.ts' import { type HeaderValue } from './header-value.ts' import { type IfMatchInit, IfMatch } from './if-match.ts' import { type IfNoneMatchInit, IfNoneMatch } from './if-none-match.ts' +import { IfRange } from './if-range.ts' import { type RangeInit, Range } from './range.ts' import { type SetCookieInit, SetCookie } from './set-cookie.ts' import { isIterable, quoteEtag } from './utils.ts' @@ -105,6 +106,10 @@ interface SuperHeadersPropertyInit { * The [`If-None-Match`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match) header value. */ ifNoneMatch?: string | string[] | IfNoneMatchInit + /** + * The [`If-Range`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Range) header value. + */ + ifRange?: string | Date /** * The [`If-Unmodified-Since`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Unmodified-Since) header value. */ @@ -159,6 +164,7 @@ const HostKey = 'host' const IfMatchKey = 'if-match' const IfModifiedSinceKey = 'if-modified-since' const IfNoneMatchKey = 'if-none-match' +const IfRangeKey = 'if-range' const IfUnmodifiedSinceKey = 'if-unmodified-since' const LastModifiedKey = 'last-modified' const LocationKey = 'location' @@ -705,6 +711,22 @@ export class SuperHeaders extends Headers { this.#setHeaderValue(IfNoneMatchKey, IfNoneMatch, value) } + /** + * The `If-Range` header makes a range request conditional on the resource state. + * Can contain either an entity tag (ETag) or an HTTP date. + * + * [MDN `If-Range` Reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Range) + * + * [HTTP/1.1 Specification](https://datatracker.ietf.org/doc/html/rfc7233#section-3.2) + */ + get ifRange(): IfRange { + return this.#getHeaderValue(IfRangeKey, IfRange) + } + + set ifRange(value: string | Date | undefined | null) { + this.#setHeaderValue(IfRangeKey, IfRange, value) + } + /** * The `If-Unmodified-Since` header makes a request conditional on the last modification date of the * requested resource. diff --git a/packages/headers/src/lib/utils.ts b/packages/headers/src/lib/utils.ts index b572143564b..2663343b53d 100644 --- a/packages/headers/src/lib/utils.ts +++ b/packages/headers/src/lib/utils.ts @@ -13,3 +13,41 @@ export function isValidDate(date: unknown): boolean { export function quoteEtag(tag: string): string { return tag === '*' ? tag : /^(W\/)?".*"$/.test(tag) ? tag : `"${tag}"` } + +/** + * Rounds a timestamp to the nearest second (removes milliseconds). + * HTTP dates only have second precision, so this is useful for date comparisons. + * + * @param time The timestamp in milliseconds + * @returns The timestamp rounded to seconds (in seconds, not milliseconds) + */ +export function roundToSecond(time: number): number { + return Math.floor(time / 1000) +} + +const imfFixdatePattern = + /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), (\d{2}) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (\d{4}) (\d{2}):(\d{2}):(\d{2}) GMT$/ + +/** + * Parses an HTTP date header value. + * + * HTTP dates must follow RFC 7231 IMF-fixdate format: + * "Day, DD Mon YYYY HH:MM:SS GMT" (e.g., "Wed, 21 Oct 2015 07:28:00 GMT") + * + * [RFC 7231 Section 7.1.1.1](https://datatracker.ietf.org/doc/html/rfc7231#section-7.1.1.1) + * + * @param dateString The HTTP date string to parse + * @returns The timestamp in milliseconds, or null if invalid + */ +export function parseHttpDate(dateString: string): number | null { + if (!imfFixdatePattern.test(dateString)) { + return null + } + + let timestamp = Date.parse(dateString) + if (isNaN(timestamp)) { + return null + } + + return timestamp +} diff --git a/packages/lazy-file/src/fs.test.ts b/packages/lazy-file/src/fs.test.ts index 4e6673e412f..fd07cc77c04 100644 --- a/packages/lazy-file/src/fs.test.ts +++ b/packages/lazy-file/src/fs.test.ts @@ -42,18 +42,6 @@ describe('openFile', () => { teardown() }) - - it('returns a file with absolute path', async () => { - setup() - let filePath = createTestFile('test.txt', 'hello world') - - let result: FsFile = openFile(filePath) - - assert.ok(path.isAbsolute(result.path)) - assert.equal(result.path, path.resolve(filePath)) - - teardown() - }) }) describe('findFile', () => { diff --git a/packages/lazy-file/src/fs.ts b/packages/lazy-file/src/fs.ts index f0ddcc3d658..0efc468cf79 100644 --- a/packages/lazy-file/src/fs.ts +++ b/packages/lazy-file/src/fs.ts @@ -96,10 +96,9 @@ function streamFile( * @returns A `File` with an additional `path` property, or null if not found * * @example - * let file = await findFile('./public', 'styles.css') + * let file = await findFile('./public', 'favicon.ico') * if (file) { - * // file.path contains the full absolute path - * console.log(file.path) // e.g., /Users/you/project/public/styles.css + * console.log(file.path) // "/path/to/public/favicon.ico" * } */ export async function findFile(root: string, relativePath: string): Promise { From 61db8e3429dc08d533ba63e52ccb56feae41752e Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Tue, 11 Nov 2025 16:38:26 +1100 Subject: [PATCH 14/19] Address feedback, refactor --- demos/bookstore/app/router.ts | 30 +-- demos/bookstore/public/{root => }/favicon.ico | Bin demos/bookstore/routes.ts | 1 - packages/fetch-router/README.md | 50 +--- .../src/lib/middleware/static.test.ts | 118 +++------ .../fetch-router/src/lib/middleware/static.ts | 58 ++--- .../src/lib/response-helpers/file.ts | 242 +++++++----------- packages/fetch-router/src/response-helpers.ts | 1 - packages/headers/src/lib/if-range.ts | 8 +- .../headers/src/lib/super-headers.test.ts | 13 - packages/headers/src/lib/super-headers.ts | 4 +- packages/headers/src/lib/utils.ts | 10 +- 12 files changed, 166 insertions(+), 369 deletions(-) rename demos/bookstore/public/{root => }/favicon.ico (100%) diff --git a/demos/bookstore/app/router.ts b/demos/bookstore/app/router.ts index 11e9011ce27..dab013905e3 100644 --- a/demos/bookstore/app/router.ts +++ b/demos/bookstore/app/router.ts @@ -3,9 +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 * as res from '@remix-run/fetch-router/response-helpers' import { staticFiles } from '@remix-run/fetch-router/static-middleware' -import { findFile } from '@remix-run/lazy-file/fs' import { routes } from '../routes.ts' import { uploadHandler } from './utils/uploads.ts' @@ -31,7 +29,7 @@ middleware.push(methodOverride()) middleware.push(asyncContext()) middleware.push( - staticFiles('./public/root', { + staticFiles('./public', { cacheControl: 'no-store, must-revalidate', etag: false, lastModified: false, @@ -41,32 +39,6 @@ middleware.push( export let router = createRouter({ middleware }) -router.get(routes.assets, async (context) => { - let file = await findFile('./public/assets', context.params.path) - if (!file) { - return new Response('Not Found', { status: 404 }) - } - return res.file(file, context, { - cacheControl: 'no-store, must-revalidate', - etag: false, - lastModified: false, - acceptRanges: false, - }) -}) - -router.get(routes.images, async (context) => { - let imageFile = await findFile('./public/images', context.params.path) - if (!imageFile) { - return new Response('Not Found', { status: 404 }) - } - return res.file(imageFile, context, { - cacheControl: 'no-store, must-revalidate', - etag: false, - lastModified: false, - acceptRanges: false, - }) -}) - router.get(routes.uploads, uploadsHandler) router.map(routes.home, marketingHandlers.home) diff --git a/demos/bookstore/public/root/favicon.ico b/demos/bookstore/public/favicon.ico similarity index 100% rename from demos/bookstore/public/root/favicon.ico rename to demos/bookstore/public/favicon.ico diff --git a/demos/bookstore/routes.ts b/demos/bookstore/routes.ts index bdac35d2d64..358bb9ec06e 100644 --- a/demos/bookstore/routes.ts +++ b/demos/bookstore/routes.ts @@ -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 diff --git a/packages/fetch-router/README.md b/packages/fetch-router/README.md index 2b4e8e09764..4a7bfb5f589 100644 --- a/packages/fetch-router/README.md +++ b/packages/fetch-router/README.md @@ -605,7 +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, context, init?)` - returns a `Response` for a file with full HTTP semantics (ETags, Range requests, etc.) — see [Working with Files](#working-with-files) +- `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 @@ -691,7 +691,7 @@ 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) + return res.file(file, context.request) }) ``` @@ -700,7 +700,7 @@ router.get('/assets/:filename', async (context) => { The `file()` helper accepts an optional third argument with configuration options: ```ts -return res.file(file, context, { +return res.file(file, request, { // Cache-Control header value. // Defaults to `undefined` (no Cache-Control header). cacheControl: 'public, max-age=3600', @@ -727,7 +727,7 @@ return res.file(file, context, { 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, context, { +return res.file(file, request, { etag: 'strong', }) ``` @@ -735,7 +735,7 @@ return res.file(file, context, { 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, context, { +return res.file(file, request, { etag: 'strong', // Specify a different SubtleCrypto.digest() algorithm @@ -746,7 +746,7 @@ return res.file(file, context, { Or provide a custom digest function: ```ts -return res.file(file, context, { +return res.file(file, request, { etag: 'strong', // Custom digest function @@ -757,22 +757,6 @@ return res.file(file, context, { }) ``` -When using strong ETags, you can provide a cache to avoid re-hashing files on every request: - -```ts -return res.file(file, context, { - etag: 'strong', - - // Provide a cache to avoid re-hashing files on every request. - // Defaults to `undefined`, meaning that there is no cache. - digestCache: new Map(), - - // Customize the logic for generating the cache key. - // Defaults to `(file) => `${file.path ?? file.name}:${file.size}:${file.lastModified}``. - digestCacheKey: (file) => `${file.path ?? file.name}:${file.size}:${file.lastModified}`, -}) -``` - #### 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: @@ -781,14 +765,14 @@ When you need to map a route pattern to a directory of files on disk, you can us import * as res from '@remix-run/fetch-router/response-helpers' import { findFile } from '@remix-run/lazy-file/fs' -router.get('/assets/*path', async (context) => { - let file = await findFile('./public/assets', context.params.path) +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, context, { + return res.file(file, request, { cacheControl: 'public, max-age=3600', }) }) @@ -796,7 +780,7 @@ router.get('/assets/*path', async (context) => { #### The `staticFiles()` Middleware -For convenience, the `staticFiles()` middleware combines `findFile()` and `file()` into a single 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' @@ -815,24 +799,16 @@ let router = createRouter({ staticFiles('./public', { cacheControl: 'public, max-age=3600', etag: 'strong', - digestCache: new Map(), }), ], }) ``` -By default, the middleware uses the full request pathname to resolve files. You can customize this with a `path` resolver function: +You can provide a `filter` function to determine which files to serve: ```ts -router.get('/assets/*path', { - middleware: [ - staticFiles('./public', { - path: (context) => context.params.path, - }), - ], - handler() { - return new Response('Not Found', { status: 404 }) - }, +staticFiles('./images', { + filter: (path) => /\.(png|jpg|gif|svg)$/i.test(path), }) ``` diff --git a/packages/fetch-router/src/lib/middleware/static.test.ts b/packages/fetch-router/src/lib/middleware/static.test.ts index 7692dcde463..dfd2f813ccc 100644 --- a/packages/fetch-router/src/lib/middleware/static.test.ts +++ b/packages/fetch-router/src/lib/middleware/static.test.ts @@ -104,68 +104,6 @@ describe('staticFiles middleware', () => { assert.equal(await response.text(), 'Custom Fallback Handler') }) - it('supports custom path resolver using params', async () => { - createTestFile('custom/file.txt', 'Custom path content') - - let router = createRouter() - router.get('/assets/*path', { - middleware: [ - staticFiles(tmpDir, { - path: ({ params }) => `custom/${params.path}`, - }), - ], - handler() { - return new Response('Not Found', { status: 404 }) - }, - }) - - let response = await router.fetch('http://localhost/assets/file.txt') - - assert.equal(response.status, 200) - assert.equal(await response.text(), 'Custom path content') - }) - - it('supports custom path resolver using request URL', async () => { - createTestFile('file.txt', 'File content') - - let router = createRouter() - router.get('/*', { - middleware: [ - staticFiles(tmpDir, { - path: ({ request }) => { - return new URL(request.url).pathname.replace(/^\/prefix\//, '') - }, - }), - ], - handler() { - return new Response('Not Found', { status: 404 }) - }, - }) - - let response = await router.fetch('http://localhost/prefix/file.txt') - - assert.equal(response.status, 200) - assert.equal(await response.text(), 'File content') - }) - - it('enforces type safety for params in path resolver', async () => { - let router = createRouter() - router.get('/assets/*path', { - middleware: [ - staticFiles(tmpDir, { - // @ts-expect-error - 'nonexistent' does not exist on params - path: ({ params }) => params.nonexistent, - }), - ], - handler() { - return new Response('Not Found', { status: 404 }) - }, - }) - - // This is just a compile-time test, no runtime assertion needed - assert.ok(router) - }) - it('falls through to handler when requesting a directory', async () => { let dirPath = path.join(tmpDir, 'subdir') fs.mkdirSync(dirPath) @@ -325,32 +263,12 @@ describe('staticFiles middleware', () => { }) it('works with multiple static middleware instances', async () => { - let assetsDirName = 'assets' - let imagesDirName = 'images' - - createTestFile(`${assetsDirName}/style.css`, 'body {}') - createTestFile(`${imagesDirName}/logo.png`, 'PNG data') - - let assetsDir = path.join(tmpDir, assetsDirName) - let imagesDir = path.join(tmpDir, imagesDirName) + createTestFile('assets/style.css', 'body {}') + createTestFile('images/logo.png', 'PNG data') let router = createRouter() - router.get('/assets/*path', { - middleware: [ - staticFiles(assetsDir, { - path: ({ params }) => params.path, - }), - ], - handler() { - return new Response('Fallback Handler', { status: 404 }) - }, - }) - router.get('/images/*path', { - middleware: [ - staticFiles(imagesDir, { - path: ({ params }) => params.path, - }), - ], + router.get('/*', { + middleware: [staticFiles(tmpDir)], handler() { return new Response('Fallback Handler', { status: 404 }) }, @@ -448,4 +366,32 @@ describe('staticFiles middleware', () => { fs.unlinkSync(secretPath) } }) + + describe('filter option', () => { + it('filters files based on custom filter function', async () => { + createTestFile('index.html', '

Home

') + createTestFile('secret.txt', 'Secret') + createTestFile('public.txt', 'Public') + + let router = createRouter() + router.get('/*', { + middleware: [ + staticFiles(tmpDir, { + filter: (path) => !path.includes('secret'), + }), + ], + handler() { + return new Response('Fallback Handler', { status: 404 }) + }, + }) + + let secretResponse = await router.fetch('http://localhost/secret.txt') + assert.equal(secretResponse.status, 404) + assert.equal(await secretResponse.text(), 'Fallback Handler') + + let publicResponse = await router.fetch('http://localhost/public.txt') + assert.equal(publicResponse.status, 200) + assert.equal(await publicResponse.text(), 'Public') + }) + }) }) diff --git a/packages/fetch-router/src/lib/middleware/static.ts b/packages/fetch-router/src/lib/middleware/static.ts index a32417c9e6e..04ecadc39c1 100644 --- a/packages/fetch-router/src/lib/middleware/static.ts +++ b/packages/fetch-router/src/lib/middleware/static.ts @@ -2,58 +2,42 @@ import { findFile } from '@remix-run/lazy-file/fs' import { file, type FileResponseInit } from '../response-helpers/file.ts' import type { Middleware } from '../middleware.ts' -import type { RequestContext } from '../request-context.ts' -import type { RequestMethod } from '../request-methods.ts' -export type StaticFilesOptions< - Method extends RequestMethod | 'ANY', - Params extends Record, -> = FileResponseInit & { - path?: (context: RequestContext) => string | null | Promise -} - -/** - * Resolver that extracts the pathname from the request context, without a - * leading slash so it's suitable for use as a relative file path. - * - * @example - * // Request to http://example.com/assets/style.css - * // Returns: "assets/style.css" - */ -function requestPathnameResolver(context: RequestContext): string { - return context.url.pathname.replace(/^\/+/, '') +export type StaticFilesOptions = FileResponseInit & { + /** + * Filter function to determine which files should be served. + * + * @param path - The relative path being requested + * @returns Whether to serve the file + */ + filter?: (path: string) => boolean } /** * Creates a middleware that serves static files from the filesystem. * - * By default, uses the URL pathname to resolve files. Optionally accepts a - * custom `path` resolver to customize file resolution (e.g., to use route params). - * The middleware always falls through to the handler if the file is not found or an error occurs. + * Uses the URL pathname to resolve files, removing the leading slash to make it + * a relative path. The middleware always falls through to the handler if the file + * is not found or an error occurs. * * @param root - The root directory to serve files from (absolute or relative to cwd) - * @param options - Optional configuration + * @param options - Optional configuration for file responses * * @example - * // Use URL pathname * let router = createRouter({ * middleware: [staticFiles('./public')], * }) * * @example - * // Custom path resolver using route params - * router.get('/assets/*path', { - * middleware: [staticFiles('./assets', { - * path: ({ params }) => params.path, + * // With cache control + * let router = createRouter({ + * middleware: [staticFiles('./public', { + * cacheControl: 'public, max-age=3600', * })], - * handler() { return new Response('Not Found', { status: 404 }) } * }) */ -export function staticFiles< - Method extends RequestMethod | 'ANY', - Params extends Record, ->(root: string, options: StaticFilesOptions = {}): Middleware { - let { path: pathResolver = requestPathnameResolver, ...fileResponseInit } = options +export function staticFiles(root: string, options: StaticFilesOptions = {}): Middleware { + let { filter, ...fileOptions } = options return async (context, next) => { // Only handle GET and HEAD requests @@ -61,9 +45,9 @@ export function staticFiles< return next() } - let relativePath = await pathResolver(context) + let relativePath = context.url.pathname.replace(/^\/+/, '') - if (relativePath === null) { + if (filter && !filter(relativePath)) { return next() } @@ -73,6 +57,6 @@ export function staticFiles< return next() } - return file(fileToServe, context, fileResponseInit) + return file(fileToServe, context.request, fileOptions) } } diff --git a/packages/fetch-router/src/lib/response-helpers/file.ts b/packages/fetch-router/src/lib/response-helpers/file.ts index 7b4f4e220de..9d325b3c29d 100644 --- a/packages/fetch-router/src/lib/response-helpers/file.ts +++ b/packages/fetch-router/src/lib/response-helpers/file.ts @@ -1,8 +1,5 @@ import SuperHeaders from '@remix-run/headers' -import type { RequestContext } from '../request-context.ts' -import type { RequestMethod } from '../request-methods.ts' - /** * Custom function for computing file digests. * @@ -15,33 +12,14 @@ import type { RequestMethod } from '../request-methods.ts' * return customHash(buffer) * } */ -export type FileDigestFunction = (file: F) => Promise - -/** - * Function to generate cache keys for digest storage. - * - * @param file - The File object - * @returns The cache key as a string - * - * @example - * (file) => `${file.path ?? file.name}:${file.size}:${file.lastModified}` - */ -export type FileDigestCacheKeyFunction = (file: F) => string +export type FileDigestFunction = (file: File) => Promise /** * Hash algorithm name for SubtleCrypto.digest() or custom digest function. */ type DigestAlgorithm = 'SHA-256' | 'SHA-512' | 'SHA-384' | 'SHA-1' | (string & {}) // Allows any string while providing autocomplete for common algorithms -/** - * Cache interface for storing computed file digests. - */ -interface DigestCache { - get(key: string): Promise | string | undefined - set(key: string, digest: string): void -} - -export interface FileResponseInit { +export interface FileResponseInit { /** * Cache-Control header value. If not provided, no Cache-Control header will be set. * @@ -75,33 +53,7 @@ export interface FileResponseInit { * @example 'SHA-512' * @example async (file) => customHash(await file.arrayBuffer()) */ - digest?: DigestAlgorithm | FileDigestFunction - - /** - * Cache for storing computed file digests to avoid re-hashing files. - * - * Only used when `etag: 'strong'`. Since hashing file content is expensive, - * providing a cache is strongly recommended for production use. - * - * Any object with `get(key)` and `set(key, digest)` methods can be used. - * If not provided, digests will be computed on every request. - * - * @example new Map() - */ - digestCache?: DigestCache - - /** - * Function to generate cache keys for digest storage. - * - * The default includes file path (using `file.path` if available, otherwise - * `file.name`), size, and last modified time to ensure cache invalidation - * when files change. - * - * Only used when `etag: 'strong'` and `digestCache` is provided. - * - * @default (file) => `${file.path ?? file.name}:${file.size}:${file.lastModified}` - */ - digestCacheKey?: FileDigestCacheKeyFunction + digest?: DigestAlgorithm | FileDigestFunction /** * Whether to include Last-Modified headers. @@ -127,41 +79,28 @@ export interface FileResponseInit { * Returns a Response with full HTTP semantics including ETags, Last-Modified, * conditional requests, and Range support. * - * The file can optionally include an additional `path` property containing the - * full absolute path on disk. If provided, it will be used for generating cache - * keys; otherwise `file.name` is used. - * * @param file - The file to send - * @param context - The request context + * @param request - The request object * @param init - Optional configuration for HTTP headers and features * @returns A Response with appropriate headers and body * * @example * let result = await findFile('./public', 'image.jpg') * if (result) { - * return file(result, context, { + * return file(result, request, { * cacheControl: 'public, max-age=3600' * }) * } */ -export async function file< - F extends File = File, - Method extends RequestMethod | 'ANY' = RequestMethod | 'ANY', - Params extends Record = {}, ->( - fileToSend: F, - context: RequestContext, - init: FileResponseInit = {}, +export async function file( + fileToSend: File, + request: Request, + init: FileResponseInit = {}, ): Promise { - let { request } = context - let { cacheControl, etag: etagStrategy = 'weak', digest: digestOption = 'SHA-256', - digestCache, - digestCacheKey = (file: F & { path?: string }) => - `${file.path ?? file.name}:${file.size}:${file.lastModified}`, lastModified: lastModifiedEnabled = true, acceptRanges: acceptRangesEnabled = true, } = init @@ -176,6 +115,8 @@ export async function file< }) } + let headers = new SuperHeaders(request.headers) + let contentType = fileToSend.type let contentLength = fileToSend.size @@ -183,7 +124,7 @@ export async function file< if (etagStrategy === 'weak') { etag = generateWeakETag(fileToSend) } else if (etagStrategy === 'strong') { - let digest = await computeDigest(fileToSend, digestOption, digestCache, digestCacheKey) + let digest = await computeDigest(fileToSend, digestOption) etag = `"${digest}"` } @@ -197,32 +138,36 @@ export async function file< acceptRanges = 'bytes' } - let hasIfMatch = context.headers.has('If-Match') + let hasIfMatch = headers.has('If-Match') // If-Match support: https://httpwg.org/specs/rfc9110.html#field.if-match - if (etag && hasIfMatch && !context.headers.ifMatch.matches(etag)) { + if (etag && hasIfMatch && !headers.ifMatch.matches(etag)) { return new Response('Precondition Failed', { status: 412, - headers: new SuperHeaders({ - etag, - lastModified, - acceptRanges, - }), + headers: new SuperHeaders( + omitNullableValues({ + etag, + lastModified, + acceptRanges, + }), + ), }) } // If-Unmodified-Since support: https://httpwg.org/specs/rfc9110.html#field.if-unmodified-since if (lastModified && !hasIfMatch) { - let ifUnmodifiedSince = context.headers.ifUnmodifiedSince + let ifUnmodifiedSince = headers.ifUnmodifiedSince if (ifUnmodifiedSince != null) { - if (roundToSecond(lastModified) > roundToSecond(ifUnmodifiedSince)) { + if (removeMilliseconds(lastModified) > removeMilliseconds(ifUnmodifiedSince)) { return new Response('Precondition Failed', { status: 412, - headers: new SuperHeaders({ - etag, - lastModified, - acceptRanges, - }), + headers: new SuperHeaders( + omitNullableValues({ + etag, + lastModified, + acceptRanges, + }), + ), }) } } @@ -233,12 +178,12 @@ export async function file< if (etag || lastModified) { let shouldReturnNotModified = false - if (etag && context.headers.ifNoneMatch.matches(etag)) { + if (etag && headers.ifNoneMatch.matches(etag)) { shouldReturnNotModified = true - } else if (lastModified && context.headers.ifNoneMatch.tags.length === 0) { - let ifModifiedSince = context.headers.ifModifiedSince + } else if (lastModified && headers.ifNoneMatch.tags.length === 0) { + let ifModifiedSince = headers.ifModifiedSince if (ifModifiedSince != null) { - if (roundToSecond(lastModified) <= roundToSecond(ifModifiedSince)) { + if (removeMilliseconds(lastModified) <= removeMilliseconds(ifModifiedSince)) { shouldReturnNotModified = true } } @@ -247,19 +192,21 @@ export async function file< if (shouldReturnNotModified) { return new Response(null, { status: 304, - headers: new SuperHeaders({ - etag, - lastModified, - acceptRanges, - }), + headers: new SuperHeaders( + omitNullableValues({ + etag, + lastModified, + acceptRanges, + }), + ), }) } } // Range support: https://httpwg.org/specs/rfc9110.html#field.range // If-Range support: https://httpwg.org/specs/rfc9110.html#field.if-range - if (acceptRanges && request.method === 'GET' && context.headers.has('Range')) { - let range = context.headers.range + if (acceptRanges && request.method === 'GET' && headers.has('Range')) { + let range = headers.range // Check if the Range header was sent but parsing resulted in no valid ranges (malformed) if (range.ranges.length === 0) { @@ -270,7 +217,7 @@ export async function file< // If-Range support: https://httpwg.org/specs/rfc9110.html#field.if-range if ( - context.headers.ifRange.matches({ + headers.ifRange.matches({ etag, lastModified, }) @@ -301,29 +248,33 @@ export async function file< return new Response(fileToSend.slice(start, end + 1), { status: 206, - headers: new SuperHeaders({ - contentType, - contentLength: end - start + 1, - contentRange: { unit: 'bytes', start, end, size }, - etag, - lastModified, - cacheControl, - acceptRanges, - }), + headers: new SuperHeaders( + omitNullableValues({ + contentType, + contentLength: end - start + 1, + contentRange: { unit: 'bytes', start, end, size }, + etag, + lastModified, + cacheControl, + acceptRanges, + }), + ), }) } } return new Response(request.method === 'HEAD' ? null : fileToSend, { status: 200, - headers: new SuperHeaders({ - contentType, - contentLength, - etag, - lastModified, - cacheControl, - acceptRanges, - }), + headers: new SuperHeaders( + omitNullableValues({ + contentType, + contentLength, + etag, + lastModified, + cacheControl, + acceptRanges, + }), + ), }) } @@ -331,47 +282,36 @@ function generateWeakETag(file: File): string { return `W/"${file.size}-${file.lastModified}"` } +type OmitNullableValues = { + [K in keyof T as T[K] extends null | undefined ? never : K]: NonNullable +} + +function omitNullableValues>(headers: T): OmitNullableValues { + let result: any = {} + for (let key in headers) { + if (headers[key] != null) { + result[key] = headers[key] + } + } + return result +} + /** - * Computes a digest (hash) for a file, with optional caching. + * Computes a digest (hash) for a file. * - * @param file - The file to hash (may include an additional `path` property) + * @param file - The file to hash * @param digestOption - Algorithm name or custom digest function - * @param cache - Optional cache for storing computed digests - * @param getCacheKey - Function to generate cache key from file * @returns The computed digest as a hex string */ -async function computeDigest( - file: F, - digestOption: DigestAlgorithm | FileDigestFunction, - cache: DigestCache | undefined, - getCacheKey: FileDigestCacheKeyFunction, +async function computeDigest( + file: File, + digestOption: DigestAlgorithm | FileDigestFunction, ): Promise { - // Check cache first if provided - if (cache) { - let key = getCacheKey(file) - let cached = await cache.get(key) - if (cached) { - return cached - } - } - - // Compute digest - let digest: string - if (typeof digestOption === 'function') { - // Custom digest function - digest = await digestOption(file) - } else { - // Use SubtleCrypto with algorithm name - digest = await hashFile(file, digestOption) - } - - // Store in cache if provided - if (cache) { - let key = getCacheKey(file) - await cache.set(key, digest) - } - - return digest + return typeof digestOption === 'function' + ? // Custom digest function + await digestOption(file) + : // Use SubtleCrypto with algorithm name + await hashFile(file, digestOption) } /** @@ -389,10 +329,10 @@ async function hashFile(file: File, algorithm: string): Promise { } /** - * Rounds a timestamp to the nearest second. + * Removes milliseconds from a timestamp, returning seconds. * HTTP dates only have second precision, so this is useful for date comparisons. */ -function roundToSecond(time: number | Date): number { +function removeMilliseconds(time: number | Date): number { let timestamp = time instanceof Date ? time.getTime() : time return Math.floor(timestamp / 1000) } diff --git a/packages/fetch-router/src/response-helpers.ts b/packages/fetch-router/src/response-helpers.ts index b589f2ba434..e8c7825836a 100644 --- a/packages/fetch-router/src/response-helpers.ts +++ b/packages/fetch-router/src/response-helpers.ts @@ -1,6 +1,5 @@ export { file, - type FileDigestCacheKeyFunction, type FileDigestFunction, type FileResponseInit, } from './lib/response-helpers/file.ts' diff --git a/packages/headers/src/lib/if-range.ts b/packages/headers/src/lib/if-range.ts index dc9f713ce87..1fd24529a52 100644 --- a/packages/headers/src/lib/if-range.ts +++ b/packages/headers/src/lib/if-range.ts @@ -1,5 +1,5 @@ import { type HeaderValue } from './header-value.ts' -import { parseHttpDate, roundToSecond } from './utils.ts' +import { parseHttpDate, removeMilliseconds } from './utils.ts' import { quoteEtag } from './utils.ts' /** @@ -59,11 +59,7 @@ export class IfRange implements HeaderValue { // Try parsing as HTTP date first let dateTimestamp = parseHttpDate(this.value) if (dateTimestamp !== null && resource.lastModified != null) { - let resourceTimestamp = - resource.lastModified instanceof Date - ? resource.lastModified.getTime() - : resource.lastModified - return roundToSecond(dateTimestamp) === roundToSecond(resourceTimestamp) + return removeMilliseconds(dateTimestamp) === removeMilliseconds(resource.lastModified) } // Otherwise treat as ETag diff --git a/packages/headers/src/lib/super-headers.test.ts b/packages/headers/src/lib/super-headers.test.ts index 88a68251940..807f5a9e7d6 100644 --- a/packages/headers/src/lib/super-headers.test.ts +++ b/packages/headers/src/lib/super-headers.test.ts @@ -321,19 +321,6 @@ describe('SuperHeaders', () => { let headers = new SuperHeaders({ unknown: 42 }) assert.equal(headers.get('Unknown'), '42') }) - - it('handles undefined values by not setting headers', () => { - let headers = new SuperHeaders({ - contentType: 'text/plain', - contentLength: undefined, - etag: undefined, - cacheControl: 'public', - }) - assert.equal(headers.get('Content-Type'), 'text/plain') - assert.equal(headers.get('Content-Length'), null) - assert.equal(headers.get('ETag'), null) - assert.equal(headers.get('Cache-Control'), 'public') - }) }) describe('property getters and setters', () => { diff --git a/packages/headers/src/lib/super-headers.ts b/packages/headers/src/lib/super-headers.ts index 93948de19a4..4650a1841a0 100644 --- a/packages/headers/src/lib/super-headers.ts +++ b/packages/headers/src/lib/super-headers.ts @@ -138,7 +138,7 @@ interface SuperHeadersPropertyInit { export type SuperHeadersInit = | Iterable<[string, string]> - | (SuperHeadersPropertyInit & Record) + | (SuperHeadersPropertyInit & Record) const CRLF = '\r\n' @@ -208,7 +208,7 @@ export class SuperHeaders extends Headers { let descriptor = Object.getOwnPropertyDescriptor(SuperHeaders.prototype, name) if (descriptor?.set) { descriptor.set.call(this, value) - } else if (value !== undefined) { + } else { this.set(name, value.toString()) } } diff --git a/packages/headers/src/lib/utils.ts b/packages/headers/src/lib/utils.ts index 2663343b53d..efabf87b584 100644 --- a/packages/headers/src/lib/utils.ts +++ b/packages/headers/src/lib/utils.ts @@ -15,14 +15,12 @@ export function quoteEtag(tag: string): string { } /** - * Rounds a timestamp to the nearest second (removes milliseconds). + * Removes milliseconds from a timestamp, returning seconds. * HTTP dates only have second precision, so this is useful for date comparisons. - * - * @param time The timestamp in milliseconds - * @returns The timestamp rounded to seconds (in seconds, not milliseconds) */ -export function roundToSecond(time: number): number { - return Math.floor(time / 1000) +export function removeMilliseconds(time: number | Date): number { + let timestamp = time instanceof Date ? time.getTime() : time + return Math.floor(timestamp / 1000) } const imfFixdatePattern = From 5061e94743f1ae2d540dcdf36af64c2d64268142 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Tue, 11 Nov 2025 17:01:28 +1100 Subject: [PATCH 15/19] Refactor --- packages/fetch-router/src/lib/middleware/static.ts | 5 ++--- .../fetch-router/src/lib/response-helpers/file.ts | 11 +++++------ packages/fetch-router/src/response-helpers.ts | 2 +- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/fetch-router/src/lib/middleware/static.ts b/packages/fetch-router/src/lib/middleware/static.ts index 04ecadc39c1..5613e32acf3 100644 --- a/packages/fetch-router/src/lib/middleware/static.ts +++ b/packages/fetch-router/src/lib/middleware/static.ts @@ -1,9 +1,9 @@ import { findFile } from '@remix-run/lazy-file/fs' -import { file, type FileResponseInit } from '../response-helpers/file.ts' +import { file, type FileResponseOptions } from '../response-helpers/file.ts' import type { Middleware } from '../middleware.ts' -export type StaticFilesOptions = FileResponseInit & { +export type StaticFilesOptions = FileResponseOptions & { /** * Filter function to determine which files should be served. * @@ -40,7 +40,6 @@ export function staticFiles(root: string, options: StaticFilesOptions = {}): Mid let { filter, ...fileOptions } = options return async (context, next) => { - // Only handle GET and HEAD requests if (context.request.method !== 'GET' && context.request.method !== 'HEAD') { return next() } diff --git a/packages/fetch-router/src/lib/response-helpers/file.ts b/packages/fetch-router/src/lib/response-helpers/file.ts index 9d325b3c29d..2ce42467e5e 100644 --- a/packages/fetch-router/src/lib/response-helpers/file.ts +++ b/packages/fetch-router/src/lib/response-helpers/file.ts @@ -19,7 +19,7 @@ export type FileDigestFunction = (file: File) => Promise */ type DigestAlgorithm = 'SHA-256' | 'SHA-512' | 'SHA-384' | 'SHA-1' | (string & {}) // Allows any string while providing autocomplete for common algorithms -export interface FileResponseInit { +export interface FileResponseOptions { /** * Cache-Control header value. If not provided, no Cache-Control header will be set. * @@ -81,8 +81,8 @@ export interface FileResponseInit { * * @param file - The file to send * @param request - The request object - * @param init - Optional configuration for HTTP headers and features - * @returns A Response with appropriate headers and body + * @param options - Optional configuration + * @returns A `Response` object containing the file * * @example * let result = await findFile('./public', 'image.jpg') @@ -95,7 +95,7 @@ export interface FileResponseInit { export async function file( fileToSend: File, request: Request, - init: FileResponseInit = {}, + options: FileResponseOptions = {}, ): Promise { let { cacheControl, @@ -103,9 +103,8 @@ export async function file( digest: digestOption = 'SHA-256', lastModified: lastModifiedEnabled = true, acceptRanges: acceptRangesEnabled = true, - } = init + } = options - // Only support GET and HEAD methods if (request.method !== 'GET' && request.method !== 'HEAD') { return new Response('Method Not Allowed', { status: 405, diff --git a/packages/fetch-router/src/response-helpers.ts b/packages/fetch-router/src/response-helpers.ts index e8c7825836a..ffa9426fec3 100644 --- a/packages/fetch-router/src/response-helpers.ts +++ b/packages/fetch-router/src/response-helpers.ts @@ -1,7 +1,7 @@ export { file, type FileDigestFunction, - type FileResponseInit, + type FileResponseOptions, } from './lib/response-helpers/file.ts' export { html } from './lib/response-helpers/html.ts' export { json } from './lib/response-helpers/json.ts' From ef68ecf395b34ab1775fe9f5ed90815e80aec0bd Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Tue, 11 Nov 2025 17:21:52 +1100 Subject: [PATCH 16/19] Reduce test diff --- packages/headers/src/lib/super-headers.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/headers/src/lib/super-headers.test.ts b/packages/headers/src/lib/super-headers.test.ts index 807f5a9e7d6..a6e64f4933a 100644 --- a/packages/headers/src/lib/super-headers.test.ts +++ b/packages/headers/src/lib/super-headers.test.ts @@ -693,7 +693,14 @@ describe('SuperHeaders', () => { headers.ifNoneMatch = ['67ab43', '54ed21'] assert.deepEqual(headers.ifNoneMatch.tags, ['"67ab43"', '"54ed21"']) + headers.ifNoneMatch = { tags: ['67ab43', '54ed21'] } + assert.deepEqual(headers.ifNoneMatch.tags, ['"67ab43"', '"54ed21"']) + assert.equal(headers.ifNoneMatch.toString(), '"67ab43", "54ed21"') + + headers.ifNoneMatch = null + assert.ok(headers.ifNoneMatch instanceof IfNoneMatch) + assert.equal(headers.ifNoneMatch.toString(), '') }) it('supports the ifRange property', () => { From c0c1c0a75e506528456e58a18cd5d6012f07dd54 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Tue, 11 Nov 2025 17:22:28 +1100 Subject: [PATCH 17/19] Add docs for Range `normalize` --- packages/headers/README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/headers/README.md b/packages/headers/README.md index a83d6391994..934dbb7c1a8 100644 --- a/packages/headers/README.md +++ b/packages/headers/README.md @@ -538,6 +538,11 @@ header.canSatisfy(500) // false (end is beyond file size) let header = new Range('bytes=0-499, 1000-1499') header.ranges.length // 2 +// Normalize to concrete start/end values for a given file size +let header = new Range('bytes=1000-') +header.normalize(2000) +// [{ start: 1000, end: 1999 }] + // Alternative init style let header = new Range({ unit: 'bytes', From 3f57c5db1ead0f0cb375bd05f35148cab77d5cb9 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Thu, 13 Nov 2025 12:51:26 +1100 Subject: [PATCH 18/19] Update changelogs, address feedback --- packages/fetch-router/CHANGELOG.md | 25 ++++++ packages/fetch-router/README.md | 11 ++- .../src/lib/response-helpers/file.ts | 50 +++++------- packages/headers/CHANGELOG.md | 80 +++++++++++++++++++ packages/lazy-file/CHANGELOG.md | 29 +++++++ packages/lazy-file/README.md | 19 +---- packages/lazy-file/src/fs.test.ts | 23 +++--- packages/lazy-file/src/fs.ts | 40 ++++------ packages/lazy-file/src/lib/lazy-file.ts | 8 -- 9 files changed, 187 insertions(+), 98 deletions(-) diff --git a/packages/fetch-router/CHANGELOG.md b/packages/fetch-router/CHANGELOG.md index 6abe2dcf21e..d8ea06554e3 100644 --- a/packages/fetch-router/CHANGELOG.md +++ b/packages/fetch-router/CHANGELOG.md @@ -19,6 +19,31 @@ This is the changelog for [`fetch-router`](https://github.com/remix-run/remix/tr }) ``` +- Add `file` response helper for serving files + + ```tsx + import * as res from '@remix-run/fetch-router/response-helpers' + import { findFile } from '@remix-run/lazy-file/fs' + + router.get('/assets/:filename', async ({ request, params }) => { + let file = await findFile('./public/assets', params.filename) + if (!file) { + return new Response('Not Found', { status: 404 }) + } + return res.file(file, request) + }) + ``` + +- Add `staticFiles` middleware for serving static files + + ```tsx + import { staticFiles } from '@remix-run/fetch-router/static-middleware' + + let router = createRouter({ + middleware: [staticFiles('./public')], + }) + ``` + ## v0.8.0 (2025-11-03) - BREAKING CHANGE: Rework how middleware works in the router. This change has far-reaching implications. diff --git a/packages/fetch-router/README.md b/packages/fetch-router/README.md index 4a7bfb5f589..ea6975598c9 100644 --- a/packages/fetch-router/README.md +++ b/packages/fetch-router/README.md @@ -732,14 +732,14 @@ return res.file(file, request, { }) ``` -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: +By default, strong ETags are generated using Node's [`crypto.createHash()`](https://nodejs.org/api/crypto.html#cryptocreatehashalgorithm-options) with the `'sha256'` algorithm. You can customize this: ```ts return res.file(file, request, { etag: 'strong', - // Specify a different SubtleCrypto.digest() algorithm - digest: 'SHA-512', + // Specify a different hash algorithm (availability depends on platform) + digest: 'sha512', }) ``` @@ -750,9 +750,8 @@ return res.file(file, request, { etag: 'strong', // Custom digest function - digest: async (file) => { - let buffer = await file.arrayBuffer() - return customHash(buffer) + async digest(file) { + return await customHash(file) }, }) ``` diff --git a/packages/fetch-router/src/lib/response-helpers/file.ts b/packages/fetch-router/src/lib/response-helpers/file.ts index 2ce42467e5e..44a996d3964 100644 --- a/packages/fetch-router/src/lib/response-helpers/file.ts +++ b/packages/fetch-router/src/lib/response-helpers/file.ts @@ -14,11 +14,6 @@ import SuperHeaders from '@remix-run/headers' */ export type FileDigestFunction = (file: File) => Promise -/** - * Hash algorithm name for SubtleCrypto.digest() or custom digest function. - */ -type DigestAlgorithm = 'SHA-256' | 'SHA-512' | 'SHA-384' | 'SHA-1' | (string & {}) // Allows any string while providing autocomplete for common algorithms - export interface FileResponseOptions { /** * Cache-Control header value. If not provided, no Cache-Control header will be set. @@ -43,17 +38,17 @@ export interface FileResponseOptions { /** * Hash algorithm or custom digest function for strong ETags. * - * When `etag` is `'strong'`, this determines how the file content is hashed. - * - String: Algorithm name for SubtleCrypto.digest() (e.g., 'SHA-256', 'SHA-512') + * - String: Algorithm name for Node.js crypto.createHash() (e.g., 'sha256', 'sha512'). + * Available algorithms depend on the platform. * - 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 'SHA-512' - * @example async (file) => customHash(await file.arrayBuffer()) + * @default 'sha256' + * @example 'sha512' + * @example async (file) => await customHash(file) */ - digest?: DigestAlgorithm | FileDigestFunction + digest?: string | FileDigestFunction /** * Whether to include Last-Modified headers. @@ -100,20 +95,11 @@ export async function file( let { cacheControl, etag: etagStrategy = 'weak', - digest: digestOption = 'SHA-256', + digest: digestOption = 'sha256', lastModified: lastModifiedEnabled = true, acceptRanges: acceptRangesEnabled = true, } = options - if (request.method !== 'GET' && request.method !== 'HEAD') { - return new Response('Method Not Allowed', { - status: 405, - headers: new SuperHeaders({ - allow: ['GET', 'HEAD'], - }), - }) - } - let headers = new SuperHeaders(request.headers) let contentType = fileToSend.type @@ -304,27 +290,33 @@ function omitNullableValues>(headers: T): OmitNull */ async function computeDigest( file: File, - digestOption: DigestAlgorithm | FileDigestFunction, + digestOption: string | FileDigestFunction, ): Promise { return typeof digestOption === 'function' ? // Custom digest function await digestOption(file) - : // Use SubtleCrypto with algorithm name + : // Use Node.js crypto with algorithm name await hashFile(file, digestOption) } /** - * Hashes a file using SubtleCrypto. + * Hashes a file using Node.js crypto streaming API. + * + * This streams the file in chunks to avoid loading the entire file into memory, + * which is important for large files. * * @param file - The file to hash - * @param algorithm - Hash algorithm name (e.g., 'SHA-256') + * @param algorithm - Hash algorithm name (e.g., 'sha256', 'sha512') * @returns The hash as a hex string */ async function hashFile(file: File, algorithm: string): Promise { - let buffer = await file.arrayBuffer() - let hashBuffer = await crypto.subtle.digest(algorithm, buffer) - let hashArray = Array.from(new Uint8Array(hashBuffer)) - return hashArray.map((byte) => byte.toString(16).padStart(2, '0')).join('') + // Avoid making node:crypto a static import in case it's not available + let { createHash } = await import('node:crypto') + let hash = createHash(algorithm) + for await (let chunk of file.stream()) { + hash.update(chunk) + } + return hash.digest('hex') } /** diff --git a/packages/headers/CHANGELOG.md b/packages/headers/CHANGELOG.md index 1d5ea1b8853..8e4832d7d9f 100644 --- a/packages/headers/CHANGELOG.md +++ b/packages/headers/CHANGELOG.md @@ -2,6 +2,86 @@ This is the changelog for [`headers`](https://github.com/remix-run/remix/tree/main/packages/headers). It follows [semantic versioning](https://semver.org/). +## Unreleased + +- Add `Range` support + +```ts +import { Range } from '@remix-run/headers' + +let header = new Range({ unit: 'bytes', ranges: [{ start: 0, end: 999 }] }) +header.toString() // "bytes=0-999" + +// Parse from string +let header = new Range('bytes=0-999,2000-2999') +header.ranges // [{ start: 0, end: 999 }, { start: 2000, end: 2999 }] + +// Check if range is satisfiable for a given file size +header.isSatisfiable(5000) // true + +// Normalize ranges to concrete start/end values for a given file size +let header = new Range('bytes=0-') +header.normalize(5000) // [{ start: 0, end: 4999 }] +``` + +- Add `Content-Range` support + +```ts +import { ContentRange } from '@remix-run/headers' + +let header = new ContentRange({ + unit: 'bytes', + start: 0, + end: 999, + size: 5000, +}) +header.toString() // "bytes 0-999/5000" + +// Parse from string +let header = new ContentRange('bytes 200-1000/67589') +header.start // 200 +header.end // 1000 +header.size // 67589 +``` + +- Add `If-Match` support + +```ts +import { IfMatch } from '@remix-run/headers' + +let header = new IfMatch(['"abc123"', '"def456"']) +header.has('"abc123"') // true + +// Check if precondition passes +header.matches('"abc123"') // true +header.matches('"xyz789"') // false +header.matches('W/"abc123"') // false (weak ETags never match) +``` + +- Add `If-Range` support + +```ts +import { IfRange } from '@remix-run/headers' + +// With ETag +let header = new IfRange('"abc123"') +header.matches({ etag: '"abc123"' }) // true +header.matches({ etag: 'W/"abc123"' }) // false (weak ETags never match) + +// With Last-Modified date +let header = new IfRange(new Date('2025-10-21T07:28:00Z')) +header.matches({ lastModified: new Date('2025-10-21T07:28:00Z') }) // true +``` + +- Add `Allow` support + +```ts +import { SuperHeaders } from '@remix-run/headers' + +let headers = new SuperHeaders({ allow: ['GET', 'POST', 'OPTIONS'] }) +headers.get('Allow') // "GET, POST, OPTIONS" +``` + ## v0.16.0 (2025-11-05) - Build using `tsc` instead of `esbuild`. This means modules in the `dist` directory now mirror the layout of modules in the `src` directory. diff --git a/packages/lazy-file/CHANGELOG.md b/packages/lazy-file/CHANGELOG.md index 2f45a8ecfc5..5ff1cd22610 100644 --- a/packages/lazy-file/CHANGELOG.md +++ b/packages/lazy-file/CHANGELOG.md @@ -2,6 +2,35 @@ This is the changelog for [`lazy-file`](https://github.com/remix-run/remix/tree/main/packages/lazy-file). It follows [semantic versioning](https://semver.org/). +## Unreleased + +- Add `findFile` function for finding files within a root directory + +```ts +import { findFile } from '@remix-run/lazy-file/fs' + +let file = await findFile('./public', 'assets/favicon.ico') +if (file) { + console.log(file.name) // "assets/favicon.ico" +} +``` + +- BREAKING CHANGE: `openFile()` now sets `file.name` to the `filename` argument as provided, instead of using `path.basename(filename)`. You can still override this with `options.name`. + +```ts +// before +let file = openFile('./public/assets/favicon.ico') +file.name // "favicon.ico" + +// after +let file = openFile('./public/assets/favicon.ico') +file.name // "./public/assets/favicon.ico" + +// You can still override the name +let file = openFile('./public/assets/favicon.ico', { name: 'favicon.ico' }) +file.name // "favicon.ico" +``` + ## v3.7.0 (2025-11-04) - Build using `tsc` instead of `esbuild`. This means modules in the `dist` directory now mirror the layout of modules in the `src` directory. diff --git a/packages/lazy-file/README.md b/packages/lazy-file/README.md index 8f489fd5b4a..36329dd4939 100644 --- a/packages/lazy-file/README.md +++ b/packages/lazy-file/README.md @@ -77,9 +77,10 @@ import { openFile, findFile, writeFile } from '@remix-run/lazy-file/fs' let file = openFile('./path/to/file.json') // Alternatively, find a file within a root directory, returning -// null if the file is not found -let foundFile = await findFile('./public', 'favicon.ico') -console.log(foundFile ? `Found file at ${foundFile.path}` : 'File not found') +// null if the file is not found. The file.name will be set to the +// relative path argument (e.g., 'assets/favicon.ico') +let foundFile = await findFile('./public', 'assets/favicon.ico') +console.log(foundFile ? `Found ${foundFile.name}` : 'File not found') // Data is read when you call file.text() (or any of the // other Blob methods, like file.bytes(), file.stream(), etc.) @@ -103,18 +104,6 @@ let blob = imageFile.slice(100) All file contents are read on-demand and nothing is ever buffered. -Files loaded via `lazy-file/fs` include an additional `path` property containing the full absolute path. - -```ts -import { openFile, findFile, type FsFile } from '@remix-run/lazy-file/fs' - -let file: FsFile = openFile('./config.json') -console.log(file.path) - -let foundFile: FsFile = await findFile('./public', 'favicon.ico') -console.log(foundFile ? `Found file at ${foundFile.path}` : 'File not found') -``` - ## Related Packages - [`file-storage`](https://github.com/remix-run/remix/tree/main/packages/file-storage) - Uses `lazy-file/fs` internally to create streaming `File` objects from storage on disk diff --git a/packages/lazy-file/src/fs.test.ts b/packages/lazy-file/src/fs.test.ts index fd07cc77c04..b2da48f751c 100644 --- a/packages/lazy-file/src/fs.test.ts +++ b/packages/lazy-file/src/fs.test.ts @@ -4,7 +4,7 @@ import * as os from 'node:os' import * as path from 'node:path' import { describe, it } from 'node:test' -import { findFile, openFile, type FsFile } from './fs.ts' +import { findFile, openFile } from './fs.ts' describe('openFile', () => { let tmpDir: string @@ -29,15 +29,14 @@ describe('openFile', () => { return filePath } - it('returns a file with path property', async () => { + it('returns a file', async () => { setup() let filePath = createTestFile('test.txt', 'hello world') - let result: FsFile = openFile(filePath) + let result = openFile(filePath) - assert.equal(result.name, 'test.txt') + assert.equal(result.name, filePath) assert.equal(result.size, 11) - assert.equal(result.path, path.resolve(filePath)) assert.equal(await result.text(), 'hello world') teardown() @@ -75,7 +74,6 @@ describe('findFile', () => { assert.ok(result) assert.equal(result.name, 'test.txt') assert.equal(result.size, 11) - assert.equal(result.path, path.join(path.resolve(tmpDir), 'test.txt')) assert.equal(await result.text(), 'hello world') teardown() @@ -88,8 +86,7 @@ describe('findFile', () => { let result = await findFile(tmpDir, 'assets/styles.css') assert.ok(result) - assert.equal(result.name, 'styles.css') - assert.equal(result.path, path.join(path.resolve(tmpDir), 'assets', 'styles.css')) + assert.equal(result.name, 'assets/styles.css') teardown() }) @@ -175,21 +172,19 @@ describe('findFile', () => { assert.ok(result) assert.equal(result.name, 'test.txt') - assert.equal(result.path, path.join(absoluteTmpDir, 'test.txt')) assert.equal(await result.text(), 'hello') teardown() }) - it('returns file with correct path property', async () => { + it('sets file.name to the relative path argument', async () => { setup() - createTestFile('test.txt', 'content') + createTestFile('nested/deep/file.txt', 'content') - let result = await findFile(tmpDir, 'test.txt') + let result = await findFile(tmpDir, 'nested/deep/file.txt') assert.ok(result) - assert.ok(path.isAbsolute(result.path)) - assert.equal(result.path, path.join(path.resolve(tmpDir), 'test.txt')) + assert.equal(result.name, 'nested/deep/file.txt') teardown() }) diff --git a/packages/lazy-file/src/fs.ts b/packages/lazy-file/src/fs.ts index 0efc468cf79..704c57c0727 100644 --- a/packages/lazy-file/src/fs.ts +++ b/packages/lazy-file/src/fs.ts @@ -4,15 +4,9 @@ import { lookup } from 'mrmime' import { type LazyContent, LazyFile } from './lib/lazy-file.ts' -/** - * A `File` from the filesystem with an additional `path` property containing - * the full absolute path to the file on disk. - */ -export type FsFile = File & { path: string } - export interface OpenFileOptions { /** - * Overrides the name of the file. Default is the name of the file on disk. + * Overrides the name of the file. Default is the filename argument as provided. */ name?: string /** @@ -28,17 +22,16 @@ export interface OpenFileOptions { /** * Returns a `File` from the local filesytem. * - * The returned file includes an additional `path` property containing the full - * absolute path to the file on disk. This is useful for file handling where the - * full path is often needed (e.g., for caching or logging). + * 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) * * @param filename The path to the file * @param options Options to override the file's metadata - * @returns A `File` object with an additional `path` property + * @returns A `File` object */ -export function openFile(filename: string, options?: OpenFileOptions): FsFile { +export function openFile(filename: string, options?: OpenFileOptions): File { let stats = fs.statSync(filename) if (!stats.isFile()) { @@ -52,13 +45,10 @@ export function openFile(filename: string, options?: OpenFileOptions): FsFile { }, } - let absolutePath = path.resolve(filename) - - return new LazyFile(content, options?.name ?? path.basename(filename), { + return new LazyFile(content, options?.name ?? filename, { type: options?.type ?? lookup(filename), lastModified: options?.lastModified ?? stats.mtimeMs, - path: absolutePath, - }) as FsFile + }) as File } function streamFile( @@ -87,21 +77,20 @@ function streamFile( * Returns `null` if the file doesn't exist, is not a file, or is outside the * specified root directory. * - * The returned file includes an additional `path` property containing the full - * absolute path to the file on disk. This is useful for file handling where the - * full path is often needed (e.g., for caching or logging). + * The returned file's `name` property will be set to the `relativePath` argument, + * preserving the path structure relative to the root. * * @param root - The root directory to serve files from (absolute or relative to cwd) * @param relativePath - The relative path from the root to the file - * @returns A `File` with an additional `path` property, or null if not found + * @returns A `File` object, or null if not found * * @example - * let file = await findFile('./public', 'favicon.ico') + * let file = await findFile('./public', 'assets/favicon.ico') * if (file) { - * console.log(file.path) // "/path/to/public/favicon.ico" + * console.log(file.name) // "assets/favicon.ico" * } */ -export async function findFile(root: string, relativePath: string): Promise { +export async function findFile(root: string, relativePath: string): Promise { // Ensure root is an absolute path root = path.resolve(root) @@ -113,8 +102,7 @@ export async function findFile(root: string, relativePath: string): Promise Date: Thu, 13 Nov 2025 14:11:46 +1100 Subject: [PATCH 19/19] Add `name` option to `findFile` --- packages/lazy-file/src/fs.test.ts | 13 +++++++++++++ packages/lazy-file/src/fs.ts | 29 ++++++++++++++++++++++++----- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/packages/lazy-file/src/fs.test.ts b/packages/lazy-file/src/fs.test.ts index b2da48f751c..1942da579aa 100644 --- a/packages/lazy-file/src/fs.test.ts +++ b/packages/lazy-file/src/fs.test.ts @@ -189,6 +189,19 @@ describe('findFile', () => { teardown() }) + it('allows overriding file.name with options.name', async () => { + setup() + createTestFile('assets/favicon.ico', 'icon content') + + let result = await findFile(tmpDir, 'assets/favicon.ico', { name: 'custom.ico' }) + + assert.ok(result) + assert.equal(result.name, 'custom.ico') + assert.equal(await result.text(), 'icon content') + + teardown() + }) + it('throws non-ENOENT errors', async () => { setup() diff --git a/packages/lazy-file/src/fs.ts b/packages/lazy-file/src/fs.ts index 704c57c0727..47ad602b4d5 100644 --- a/packages/lazy-file/src/fs.ts +++ b/packages/lazy-file/src/fs.ts @@ -71,6 +71,13 @@ function streamFile( }) } +export interface FindFileOptions { + /** + * Overrides the name of the file. Default is the relativePath argument. + */ + name?: string +} + /** * Finds a file on the filesystem within the given root directory. * @@ -78,19 +85,31 @@ function streamFile( * specified root directory. * * The returned file's `name` property will be set to the `relativePath` argument, - * preserving the path structure relative to the root. + * unless overridden via `options.name`. * * @param root - The root directory to serve files from (absolute or relative to cwd) * @param relativePath - The relative path from the root to the file + * @param options - Options to override the file's metadata * @returns A `File` object, or null if not found * * @example - * let file = await findFile('./public', 'assets/favicon.ico') + * let file = await findFile('./public', 'assets/logo.png') + * if (file) { + * console.log(file.name) // "assets/logo.png" + * } + * + * @example + * // Override the file name + * let file = await findFile('./public', 'assets/logo.png', { name: 'custom.png' }) * if (file) { - * console.log(file.name) // "assets/favicon.ico" + * console.log(file.name) // "custom.png" * } */ -export async function findFile(root: string, relativePath: string): Promise { +export async function findFile( + root: string, + relativePath: string, + options?: FindFileOptions, +): Promise { // Ensure root is an absolute path root = path.resolve(root) @@ -102,7 +121,7 @@ export async function findFile(root: string, relativePath: string): Promise