remix 前端资源服务器深度指南:@remix-run/assets 的 createAssetServer 演进与完整实践
【免费下载链接】remix The fully-stacked web framework 项目地址: https://gitcode.com/GitHub_Trending/re/remix
导读
@remix-run/assets 是 remix 仓库(The fully-stacked web framework)中负责按需编译并托管浏览器端 JS/TS 脚本与 CSS 样式的核心包:它基于 fetch 接口工作,可以无缝挂载到 fetch-router 等路由系统中,同时提供目录挂载、访问控制、指纹缓存、文件资源托管、HMR 等一系列开箱即用的能力。本指南以 packages/assets/CHANGELOG.md 为骨架,完整梳理 createAssetServer 从 v0.1.0 到 v0.6.0 的 API 演进与破坏性变更,并结合 packages/assets/src 源码与仓库内多个 demo 的真实配置,帮助你掌握配置迁移路径、每个选项的默认值与边界约束,以及 getAssets() / getAssetDetails() 等诊断接口的实际用法。
一、包定位:一个「编译型」资源服务器
在深入配置之前,先明确这个包的职责边界。根据 packages/assets/package.json 中的描述,它是:
Fetch-based server for compiling browser JS/TS and CSS assets on demand
即:一个基于 fetch 的、按需编译浏览器 JS/TS 与 CSS 资源的服务器。入口位于 packages/assets/src/assets.ts,对外只暴露少数几个符号:
- createAssetServer():核心工厂函数,创建一个 AssetServer 实例;
- defineFileTransform():定义可用于文件资源 URL 的请求时变换(见第四节);
- 若干类型导出:AssetServer、AssetServerOptions、AssetAccessDetails、AssetDetails、AssetKind、AssetStatus、ModuleLoader、BrowserHmrChannel 等。
AssetServer 实例的核心接口(定义在 packages/assets/src/lib/asset-server.ts#L289-L319)非常精简:
| fetch(request) | 处理脚本/样式/文件资源请求,返回 Response;无法处理时返回 null,让外层路由继续向下匹配(最终可落到 404) |
| getHref(filePath) | 返回某个已服务资源文件对应的请求 URL |
| getPreloads(filePath) | 返回一个或多个资源文件的 preload URL,按“浅层优先”排序 |
| getAssetDetails(input) | 返回某个公开 URL 或文件路径的诊断信息(v0.6.0 新增) |
| getAssets() | 返回当前可被浏览器访问的全部资源清单(v0.6.0 新增) |
| close() | 关闭文件系统 watcher 与浏览器 HMR channel |
一个最小可用的示例(取自 asset-server.ts 源码注释):
let assetServer = createAssetServer({
basePath: '/assets',
allowFiles: ['app/routes.ts', 'app/**/public/**'],
allowPackages: ['remix'],
denyFiles: ['app/**/*.test.*'],
})
route('/assets/*path', ({ request }) => assetServer.fetch(request))
二、v0.3.0:basePath 成为必填项
变更内容
v0.3.0 是一个破坏性变更:createAssetServer() 现在必须提供 basePath,且 fileMap 中的 URL 模式改为相对于该 base path 解析。
// Before:
createAssetServer({
fileMap: {
'/assets/app/*path': 'app/*path',
'/assets/npm/*path': 'node_modules/*path',
},
allow: ['app/**', 'node_modules/**'],
})
// After:
createAssetServer({
basePath: '/assets',
fileMap: {
'/app/*path': 'app/*path',
'/npm/*path': 'node_modules/*path',
},
allow: ['app/**', 'node_modules/**'],
})
源码佐证
在 resolveAssetServerOptions 中,basePath 会经过 normalizeBasePath 归一化(asset-server.ts#L1137-L1143):必须是字符串,basePath || '/' 后去除尾部斜杠,空值回落为 /。随后 basePath 会连同 mounts 一起被传入 compileRoutes,生成 URL 与文件系统路径之间的双向映射(asset-server.ts#L1065-L1071)。
同版本附带修复
- @oxc-project/runtime(为面向旧浏览器生成的代码提供运行时辅助函数)现在由资源服务器自动托管,无需手动安装——在 package.json 中可见其作为直接依赖存在。
三、v0.6.0:fileMap → mounts 的目录化演进
变更内容
v0.6.0 用可选的目录式 mounts 替换了 fileMap 选项。mounts 会递归保留每个 public 根与文件系统根之下的路径,从而使模块 URL 与用于包解析的文件系统层级保持一致。
- 省略 mounts 时,默认值为 { app: 'app', npm: 'node_modules' };
- 若应用原有的 fileMap 恰好等价于新默认值,直接删除 fileMap 即可;
- 若原来是自定义 fileMap 规则,则去掉两侧的尾部通配符,并把 fileMap 改名为 mounts。
迁移示例一:等价于默认值,直接删除
// before
createAssetServer({
basePath: '/assets',
fileMap: {
'/app/*path': 'app/*path',
'/npm/*path': 'node_modules/*path',
},
// …
})
// after
createAssetServer({
basePath: '/assets',
// …
})
迁移示例二:自定义层级
// before
createAssetServer({
basePath: '/assets',
fileMap: {
'/source/*path': 'app/*path',
'/vendor/*path': 'node_modules/*path',
},
// …
})
// after
createAssetServer({
basePath: '/assets',
mounts: {
source: 'app',
vendor: 'node_modules',
},
// …
})
源码佐证与约束
在 asset-server.ts#L176-L179 中可以看到默认值定义:
const defaultMounts = {
app: 'app',
npm: 'node_modules',
} as const
而 routes.ts 则完整实现了 mounts 的编译与校验逻辑:
- mounts 的 key 是公开 URL 路径,value 是相对 rootDir 的目录(asset-server.ts#L190-L194);
- value 不允许是绝对路径(compileMount 中会抛出 mounts values must be relative to rootDir);
- key 不允许包含 query string、fragment 或编码的点段(routes.ts#L123-L138);
- file root 之间不允许重叠、URL root 之间也不允许重叠(routes.ts#L168-L207);
- mounts 不能为空对象,否则抛出 mounts must include at least one entry(asset-server.ts#L1046-L1048);
- URL → 文件路径解析时会对 URL 路径做 decodeURIComponent(routes.ts#L69),因此 v0.4.4 修复的 percent-encoded 字符(如 scoped 包名 %40remix-run)在此得以正确解析;反向 matchFilePath 则对每个路径段做 encodeURIComponent(routes.ts#L89)。
仓库内的真实用法
在 demos/bookstore/app/utils/assets.ts 中可以看到一个完整的 monorepo 级 mounts 配置:
export const assets = createAssetServer({
basePath: assetsBase,
rootDir: path.resolve(import.meta.dirname, '../../../..'),
allowFiles: ['demos/bookstore/app/routes.ts', 'demos/bookstore/app/**/public/**'],
allowPackages: ['remix'],
denyFiles: ['demos/bookstore/app/**/*.test.*'],
mounts: {
app: 'demos/bookstore/app',
packages: 'packages',
},
sourceMaps: isDevelopment ? 'external' : undefined,
minify: !isDevelopment,
fingerprint: isDevelopment
? undefined
: { buildId: process.env.GITHUB_SHA || String(Date.now()) },
watch: isDevelopment,
hmr: isHmr
? async () => (await import('remix/node-hmr/runtime')).createBrowserHmrChannel()
: undefined,
scripts: {
loaders: isHmr ? [uiHmr()] : undefined,
},
})
注意:由于该 demo 位于仓库子目录 demos/bookstore,rootDir 指向仓库根,allowFiles/denyFiles 与 mounts 的 value 均以仓库根为基准。你自己的应用通常更简单——把 rootDir 指向应用根目录(默认是 process.cwd()),直接用 app、node_modules 这类相对目录即可。同样的配置模式也出现在 demos/frame-navigation/app/utils/assets.ts、demos/frames/app/utils/assets.ts、demos/sse/app/utils/assets.ts 中,可交叉参考。
四、v0.5.0:访问控制重构与三大新能力
1. allow/deny → allowFiles/denyFiles
v0.5.0 起,文件路径访问规则从 allow/deny 改名为 allowFiles/denyFiles:
import { createAssetServer } from 'remix/assets'
// Before:
export const assetServer = createAssetServer({
allow: ['app/routes.ts', 'app/**/public/**'],
deny: ['app/**/*.test.*'],
/* … */
})
// After:
export const assetServer = createAssetServer({
allowFiles: ['app/routes.ts', 'app/**/public/**'],
denyFiles: ['app/**/*.test.*'],
/* … */
})
从 access.ts 的实现看,访问策略遵循「先 allow 后 deny」:inspect() 先依次用 allowFiles 的 glob 匹配器寻找允许规则,未命中时再尝试 allowPackages 命名的包根路径;若仍无命中则直接 allowed: false;随后若命中任一 denyFiles 规则,则最终判定为拒绝(access.ts#L90-L114)。v0.2.0 还顺带修复了 allow/deny glob 对点前缀文件与目录的匹配问题,升级后该行为同样适用于 allowFiles/denyFiles。
2. 新增 allowPackages:包级访问控制
allowPackages 允许按包名放行资源,被允许的包及其依赖、已安装的 optional 依赖都会被自动允许。例如 allowPackages: ['remix'] 即可放行整个 remix 包及其依赖树。注意两个约束:
- 包文件仍必须位于某个已配置的 mount 之内(asset-server.ts#L204-L207);
- 包名必须合法:支持普通包名与 scoped 包名(@scope/name 两段式),拒绝 .、.. 及空段(access.ts#L437-L449)。
实现上,createAccessPolicy 会从每个 search root(默认是 rootDir 加上各 mount 的 file root,见 getPackageSearchRoots)向上逐层查找 package.json,找到后递归收集其 dependencies 与已安装的 optionalDependencies,把所有包根路径组织成一棵 Trie 用于 O(段数) 的命中判定(access.ts#L181-L291)。若在 watch 模式下 package-lock.json、pnpm-lock.yaml、yarn.lock、bun.lock 等包状态文件发生变化,Trie 会自动标记为 dirty 并在下次判定前重建(access.ts#L34-L42、access.ts#L125-L130)。测试用例可参考 packages/assets/src/lib/asset-server.test.ts 中围绕 allowPackages: ['@remix-run/__example'] 的用例(约 L4821、L4873)。
3. HMR 支持
createAssetServer 增加 hmr 选项:为 JS 资源提供 import.meta.hot API。相关要点:
- hmr 是工厂函数,签名见 BrowserHmrChannelFactory:返回 BrowserHmrChannel | undefined | Promise<…>;返回 undefined 表示 HMR 不激活;
- HMR 要求开启 watch 模式:源码在 asset-server.ts#L1043-L1045 强制校验 hmr requires watch mode;
- channel 必须提供 url、close()、onFileEvents()、updateWatchedFiles() 四个成员(asset-server.ts#L1119-L1135);
- 资源服务器会为浏览器注入一个 HMR client 脚本,其路径形如 ${basePath}/__remix_hmr/client.js,事件端点位于 ${basePath}/__remix_hmr/events(asset-server.ts#L903-L909);
- assetServer.close() 会同时关闭该 channel 与文件 watcher(asset-server.ts#L846-L858)。
bookstore demo 展示了标准接线方式(demos/bookstore/app/utils/assets.ts):
const isHmr = Boolean(isDevelopment && process.env.REMIX_NODE_HMR)
hmr: isHmr
? async () => (await import('remix/node-hmr/runtime')).createBrowserHmrChannel()
: undefined,
scripts: {
loaders: isHmr ? [uiHmr()] : undefined,
},
4. 新增 scripts.loaders:同步 JS 后处理
scripts.loaders 使用 Node 同步 load hook 签名对编译后的 JavaScript 做后处理。关键语义(源码注释见 asset-server.ts#L164-L173):
- 多个 loader 会互相包裹:[first, second] 中 second 先进入,委托给 first,最终落到默认行为;因此 nextLoad() 之后执行的变换按数组顺序生效;
- 执行时机:TS/JS 变换之后、HMR 分析与 minify 之前;
- 只支持 format: 'module',不支持 import attributes;
- 每个 loader 必须委托给 nextLoad 或返回 shortCircuit: true。
ModuleLoader 的完整类型定义在 packages/assets/src/lib/loaders.ts(含 ModuleLoadContext 与 ModuleLoadResult)。一个独特之处是 context 中提供了 moduleUrl——即稳定的公开 URL,而不是 Node 标准 load hook 中的私有 file: URL,便于 loader 使用浏览器侧模块身份(loaders.ts#L64-L70)。
5. v0.6.0 新增诊断 API:getAssets() 与 getAssetDetails()
这两个 API 用于列出浏览器可达的文件与检查 URL 映射、文件类型、访问规则与可达性状态。它们复用的是资源服务器自身配置的 mapping 与访问策略,因此诊断结果与真实请求处理一致(CHANGELOG 注明了对应 issue #11726)。
实现位于 packages/assets/src/lib/inspection.ts:
- getAssetDetails(input):接受 file:// URL、绝对/相对文件路径或公开 URL(含指纹后缀),返回 AssetDetails(inspection.ts#L92-L113);
- getAssets():扫描 allowFiles 静态前缀目录、allowPackages 包根与注入包根,收集所有文件,逐一映射到 URL 并过滤出 reachable 项,按 URL 再按文件路径排序(inspection.ts#L151-L209)。
AssetDetails 结构(inspection.ts#L30-L45):
interface AssetDetails {
access?: AssetAccessDetails // 访问判定与命中的规则(allowedBy/deniedBy)
filePath?: string // 绝对映射文件路径
fileRoot?: string // 命中的文件系统 mount 根
status: AssetStatus // 'reachable' | 'denied' | 'not-allowed' | 'missing' | 'unmapped' | 'unsupported'
type?: AssetKind // 'script' | 'style' | 'file' | 'unsupported'
url?: string // 稳定的公开 URL pathname
urlRoot?: string // 命中的公开 mount 根
}
测试中展示了典型断言方式(asset-server.test.ts):可达资源返回 status: 'reachable';denyFiles 命中的资源返回 status: 'denied';不存在的文件返回 status: 'missing';不在任何 mount 内的 URL 返回 status: 'unmapped';未被 allowFiles/allowPackages 覆盖但存在的文件返回 status: 'not-allowed';扩展名不支持的返回 status: 'unsupported'。
五、v0.4.x 系列:符号链接与包解析的真实路径
v0.4.x 的几个 patch 都围绕符号链接与真实路径展开:
- v0.4.1:从符号链接包解析 bare imports 时使用包的真实文件系统路径,使 pnpm virtual-store 依赖能够通过资源服务器被服务(对应 issue #11438);
- v0.4.2:重写脚本 import 时使用 canonical realpath 资产 URL,避免符号链接路径与其真实路径产生重复的浏览器模块;同时 Windows 上默认使用轮询式文件监听,避免原生文件系统 watcher 崩溃,同时仍允许显式 watch.poll 覆盖;
- v0.4.4:修复包含 percent-encoded 文件路径字符的 URL 的资产路由解析,包括 %40remix-run 这类 scoped 包名。
Windows 轮询默认值的源码佐证位于 packages/assets/src/lib/watch.ts#L95-L106:
return {
awaitWriteFinish: {
pollInterval: 10,
stabilityThreshold: 10,
},
depth: 0,
ignored: ['**/.git/**', …(options.ignore ?? [])],
interval: options.pollInterval ?? 100,
usePolling: options.poll ?? process.platform === 'win32',
}
即:usePolling 的默认值是 process.platform === 'win32',轮询间隔默认 100ms。v0.4.2 同时升级了 file-storage@0.13.6、headers@0.21.1、route-pattern@0.22.0。
六、v0.4.0:files 选项与 CSS url() 解析
v0.4.0 新增 files 选项,用于直接托管配置的叶文件资产,并且:
- 相对 CSS url() 引用现在通过资源服务器解析,将受支持的文件资产重写为资源服务器 URL;
- 对缺失或不受支持的文件抛出错误(而非静默失败)。
files 选项的完整结构(packages/assets/src/lib/files/config.ts#L94-L118):
| extensions | 作为叶资产暴露的文件扩展名列表,须带前导点,如 ['.png', '.svg', '.woff2'] | 必填;格式须匹配 ^\\.[A-Za-z0-9_-]+$ |
| transforms | 可从资产 URL 请求的命名变换 | key 须为 transform-name 格式 |
| maxRequestTransforms | 单个资产 URL 允许的最大请求变换数 | 默认 5,须为正整数 |
| globalTransforms | 对每个被服务文件资产按序运行的变换,可返回 null 跳过自身 | 可为函数或对象 |
| cache | 可选变换产物缓存,须实现 FileStorage 接口 | 可选 |
注意约束:files.extensions 不允许包含编译型资产扩展名(脚本模块扩展名、.css、.map 均被保留拦截,见 config.ts#L167 与 config.ts#L202-L206)。
一个真实且完整的 files 配置在 demos/assets/app/utils/assets.ts 中:它把 .svg 暴露为叶资产,用 globalTransforms 对每个 SVG 执行 svgo 多轮优化,并定义了带必选参数的 recolor 请求变换(把 SVG 中的 currentColor 替换为指定十六进制色值):
export const assetServer = createAssetServer({
basePath: assetsBase,
rootDir: path.resolve(import.meta.dirname, '../..'),
allowFiles: ['app/routes.ts', 'app/**/public/**'],
denyFiles: ['app/**/*.test.*'],
files: {
cache: createFsFileStorage(path.resolve(import.meta.dirname, '../../.tmp/assets-cache')),
extensions: ['.svg'],
globalTransforms: [
{
extensions: ['.svg'],
async transform(bytes) {
let svg = new TextDecoder().decode(bytes)
return optimizeSvg(svg, { multipass: true }).data
},
},
],
transforms: {
recolor: defineFileTransform({
extensions: ['.svg'],
param: true,
async transform(bytes, { param }) {
if (!/^#?(?:[\\da-f]{3,4}|[\\da-f]{6}(?:[\\da-f]{2})?)$/i.test(param)) {
throw new TypeError('Expected a hex color, with or without a leading #')
}
let svg = new TextDecoder().decode(bytes)
return svg.replaceAll('currentColor', `${!param.startsWith('#') ? '#' : ''}${param}`)
},
}),
},
},
watch: isDevelopment,
fingerprint: isDevelopment
? undefined
: { buildId: process.env.GITHUB_SHA || String(Date.now()) },
})
请求变换通过 URL query 传递(如 ?transform=recolor:%23ff0000),单个 URL 最多 maxRequestTransforms(默认 5)个,序列化与解析逻辑见 config.ts#L349-L479。defineFileTransform 用于获得强类型推导:param: true 表示必须提供参数、'optional' 表示可选、不设置表示不接受参数。
七、v0.2.0:target、共享编译选项与 CSS 支持
v0.2.0 是另一个重要的破坏性版本:
1. target 提升为顶层对象格式
target 现在支持 es 版本目标 + 浏览器版本目标的组合:
- 浏览器目标用字符串版本,如 target: { chrome: '109', safari: '16.4' };
- 脚本可指定 es 为 2015 及以上年份,如 target: { es: '2020' };
- 迁移:把 scripts: { target: 'es2020' } 改为 target: { es: '2020' }。
支持的浏览器键与取值约束(packages/assets/src/lib/target.ts):
- 浏览器键:chrome、edge、firefox、ie、ios、opera、safari、samsung;
- 版本格式:X、X.Y 或 X.Y.Z,每个分量 0~255;
- es 必须是四位年份数字且 ≥ 2015,最终序列化为 esYYYY 形式;
- 未知键会抛出 target.<key> is not a supported target。
脚本侧,es 与浏览器目标被合并为 oxc-transform 的目标数组;样式侧,浏览器目标被映射为 lightningcss 目标格式(ios → ios_saf),es 不影响样式(target.ts#L51-L86)。es 目标在样式配置中会被显式跳过(target.ts#L129-L131)。
2. 共享编译选项提升到顶层
sourceMaps、sourceMapSourcePaths、minify 从 scripts 下移到 createAssetServer() 顶层,从而同时作用于脚本与样式:
- sourceMaps:'external'(输出独立 .map 文件)或 'inline'(以 base64 data URL 内嵌);
- sourceMapSourcePaths:'url'(默认,使用稳定服务端路径如 /assets/app/entry.ts)或 'absolute'(使用磁盘上的原始文件系统路径);
- minify:布尔值。
迁移方式:把 scripts.minify、scripts.sourceMaps、scripts.sourceMapSourcePaths 移到顶层。在 resolveAssetServerOptions 中可以看到它们最终分别解析为 scriptsTarget 与 stylesTarget 两条独立管线。
3. CSS 编译与服务
createAssetServer() 现在与脚本一起编译和服务 .css 文件,包括:
- 本地 @import 重写;
- 指纹(fingerprinting);
- 共享编译选项(minify、source maps、浏览器兼容目标)。
请求处理中,.css 文件走 styleCompiler 管线(asset-server.ts#L633-L664),支持 If-None-Match/ETag 协商缓存与 304 响应;脚本与样式管线都支持 If-None-Match(asset-server.ts#L631-L727)。
八、v0.1.0 与依赖基线
- v0.1.0 是 @remix-run/assets 的初始发布,首个依赖基线为 route-pattern@0.20.1;
- 此后每次发版都会 bump 依赖:v0.3.0 引入 @oxc-project/runtime;v0.4.2 升级 file-storage@0.13.6、headers@0.21.1、route-pattern@0.22.0;v0.4.4 升级 file-storage@0.13.7、mime@0.4.2、route-pattern@0.23.0;v0.5.0 升级 route-pattern@0.24.0。这些依赖在 packages/assets/package.json 中均有体现。
当前版本为 0.6.0,语义化版本(semver)管理:Minor Changes 中的 BREAKING CHANGE 意味着对应主版本升级时需要按上文迁移。
九、演进脉络速查表
| v0.1.0 | Minor | @remix-run/assets 初始发布,依赖 route-pattern@0.20.1 |
| v0.2.0 | Breaking | target 改为顶层对象(es + 浏览器版本);sourceMaps/sourceMapSourcePaths/minify 提升到顶层;新增 CSS 编译与服务 |
| v0.3.0 | Breaking | basePath 必填;fileMap URL 模式相对 basePath;自动托管 @oxc-project/runtime |
| v0.4.0 | Minor | 新增 files 叶文件资产选项;CSS url() 经资源服务器解析重写 |
| v0.4.1 | Patch | 符号链接包 bare import 使用真实路径(pnpm 支持) |
| v0.4.2 | Patch | canonical realpath 资产 URL 避免重复模块;Windows 默认轮询监听 |
| v0.4.3 | Patch | 依赖 bump(route-pattern@0.22.1) |
| v0.4.4 | Patch | 修复 percent-encoded 路径字符的路由解析(含 scoped 包名) |
| v0.5.0 | Breaking | allowFiles/denyFiles 更名;新增 allowPackages、hmr、scripts.loaders |
| v0.6.0 | Breaking | fileMap → mounts(默认 { app: 'app', npm: 'node_modules' });新增 getAssets()/getAssetDetails() 诊断 API |
十、升级检查清单
结合上文,从旧版本升级到 v0.6.0 时可依次核对:
十一、进一步阅读
- 包入口与导出:packages/assets/src/assets.ts
- 核心工厂与全部选项定义:packages/assets/src/lib/asset-server.ts
- 访问策略(allowFiles/denyFiles/allowPackages):packages/assets/src/lib/access.ts
- mounts 编译与 URL/文件系统双向映射:packages/assets/src/lib/routes.ts
- 文件资产与请求变换配置:packages/assets/src/lib/files/config.ts
- 诊断 API 实现:packages/assets/src/lib/inspection.ts
- target 解析与校验:packages/assets/src/lib/target.ts
- 同步 loader 类型契约:packages/assets/src/lib/loaders.ts
- 测试用例(覆盖 mounts/allowPackages/诊断 API):packages/assets/src/lib/asset-server.test.ts
- 实战配置示例一(含 files、transforms):demos/assets/app/utils/assets.ts
- 实战配置示例二(含 mounts、hmr、loaders):demos/bookstore/app/utils/assets.ts </output文章>
【免费下载链接】remix The fully-stacked web framework 项目地址: https://gitcode.com/GitHub_Trending/re/remix
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

