@lark-apaas/coding-static-server
v0.1.6
Published
Lightweight static file server for the miaoda-coding sandbox preview. Wraps serve-handler behind a bin and strips the runtime base-path prefix (CLIENT_BASE_PATH) so files under the workspace code dir are served as-is.
Readme
@lark-apaas/coding-static-server
miaoda-coding 沙箱预览用的轻量静态文件 server。把工作区代码目录(如 /home/gem/workspace/code)原样托管,供 agent / 浏览器经沙箱网关访问。
底层用 serve-handler(serve CLI 的引擎)兜底静态服务(文件解析 / MIME / 路径穿越防护 / Range / 条件请求),本包只做一层薄封装:剥 base 前缀 + 目录首页解析 + 关缓存 + 留扩展位。与 @lark-apaas/coding-html-devserver 零代码耦合。
用法
coding-static-server [--root <dir>] [--port <n>] [--host <addr>] [--base <path>]| flag | 默认 | 说明 |
|------|------|------|
| --root | .(cwd) | 静态根目录,沙箱传 /home/gem/workspace/code |
| --port | CLIENT_DEV_PORT env ‖ 8001 | 监听端口 |
| --host | 0.0.0.0 | 绑定 host |
| --base | CLIENT_BASE_PATH env ‖ 空 | 要剥离的前缀,如 /af/p/<appId>/ |
行为
- request-target 单点归一:进入任何处理之前,用 serve-handler 同款的 legacy
url.parse把req.url归一成pathname + search,之后全链路只认它。两套 URL 解析器就是绕过面——早期按第一个?切分,与 serve-handler 在#fragment(/.env#x)、反斜杠(/a\..\.git\config)、absolute-form request-target(GET http://h/build/x.js)三类输入上解析结果不同,而不同就意味着判定的路径 ≠ 实际读的文件。这里刻意用同一个已 deprecated 的 API:与 serve-handler 字节级一致,比换成更"现代"的 WHATWGURL更重要。 - 技术栈感知服务根(
.spark/meta.json):每次请求读<root>/.spark/meta.json的stack字段,stack === 'html'时实际服务根切到<root>/src(html 栈项目页面按约定在src/下,对齐coding-html-devserver默认--root src);其它 stack / 无 meta / 解析失败维持--root不变。不固化进启动配置(meta 是绑定应用后才写入的,与CLIENT_BASE_PATH同理),但带 mtime 校验缓存:每请求仅stat一次 meta,(mtimeMs, size)签名没变就复用上次解析结果,变了才重读——变更(含 html → 全栈升级)仍即时生效。 - 剥网关前缀(正则,不依赖 env):网关把完整路径
/app/<appId>/...(或旧形态/af/p/<appId>/...)透传过来。本包用正则^/(app|af/p)/[^/]+识别并剥掉这层前缀,再映射到--root下的文件。默认不依赖CLIENT_BASE_PATH——因为该 env 是绑定时才写入沙箱 env 文件的,进程启动(容器 boot / 池预热)时拿不到。若启动时确有--base/CLIENT_BASE_PATH则优先用它精确匹配。非网关前缀的路径原样透传。 - 每页注入
<base href>替代补尾斜杠:命中网关前缀的 HTML 一律 head-prepend<base href="<prefix>/">,相对引用锚到 code 根目录(对齐coding-html-devserver的baseTag口径)。裸 base 不能 301——沙箱网关拼后端 URL 走 Gopath.Join,转发路径的尾斜杠会被path.Clean吃掉,浏览器跳到/app/<id>/后经网关又变回/app/<id>,服务端再 301 就是ERR_TOO_MANY_REDIRECTS。bareBaseRedirect已@deprecated,仅为下游兼容保留一个版本。 - 托管面对齐 git 跟踪面(
.gitignore过滤):见下节。 - 目录首页解析:pathname 以
/结尾补index.html(/→/index.html,/sub/→/sub/index.html)。 - Clean URL(MPA 无扩展名路由):无扩展名、无尾斜杠的 pathname 按两种约定在 root 下查 html 并重写(fs 判断 + 重写,不发 301):
/about→about.html(平铺)或about/index.html(目录),两者都存在时优先平铺.html。 - 命中文件零 3xx:命中文件不产生任何重定向;尤其关掉 serve-handler 默认 cleanUrls(它会把
/index.html301 到/index且 Location 丢 base 前缀,网关下死循环)。 - 非 SPA:文件不存在返回 404,不做 history fallback。
- 关缓存:响应带
Cache-Control: no-store,预览改了即时生效。 - 安全:不列目录、不跟随软链(含下面 HTML 改写分支,见该节最后几条)。
- React prod → dev 改写:见下节。
.gitignore 过滤(托管面 = git 跟踪面)
不进 git 的文件不该被预览托管。 每个请求在 URL 归一之后、落到文件之前,拿「相对仓库根的路径」过一遍规则,命中就返回 404(text/plain,不解释原因——响应做成「这里没有这个文件」的样子,而不是「有但不给你」,后者等于给探测者确认了文件存在)。
- 规则源:
<root>/.gitignore(仓库根那一份)。gitignore 语义由ignore包实现——锚定(/buildvsbuild)、目录限定(dist/)、**、!否定及其与父目录排除的交互,全部与git check-ignore一致,不自己写正则近似。显式传ignorecase: false:node-ignore 默认大小写不敏感,那会让Build/把build/app.js也拦掉,而沙箱是大小写敏感的 Linux fs,git 在这里是区分大小写的。 - 匹配基准是仓库根,不是服务根:html 栈下服务根被切到
<root>/src,但.gitignore的规则永远相对仓库根解释。所以请求/index.html是拿src/index.html去匹配的——否则项目里写的/src/generated/一条也拦不住。 - 硬拒清单(与
.gitignore写没写无关,恒 404):路径里任意一段等于.git或node_modules。前者是同源的泄露面——.gitignore从不列.git,但读得到.git/config就等于拿走带 credential 的 remote URL 和完整源码历史。硬拒是独立于.gitignore的逐段字符串比较,不共用 matcher:一来项目里一条!node_modules/foo就不能把它翻掉,二来ignore实例会把每个查过的路径永久缓存在实例上(键完全由请求方控制,实测 20 万条不同路径 ≈ 50MB 不可回收),长驻进程不能拿它接不可信输入。语义等价于不带锚定的.git/node_modules,故不误伤.gitignore/.github//node_modules_backup/。 - 判定时机在 URL 归一之后、下游分叉之前:判的是最终会读的那个文件(
/about已解析成about.html、/priv/已成priv/index.html),且早于 html 自接管 / serve-handler 的分叉——否则被忽略的.html会从改写分支绕过去。无 base 前缀的直连路径、非 GET/HEAD 的 method 同样拦,passthrough 不是后门。 - 按 realpath 再判一次:软链会让「字符串路径」与「真正打开的文件」分叉,而
resolveUnderRoot的字符串校验、serve-handler 的symlinks: false、htmlTarget的 lstat 都只看最后一跳——ln -s dist pub后/pub/bundle.js、ln -s .git gitdir后/gitdir/config本可以把过滤和硬拒一起绕开(root 对 agent 完全可写)。真实路径逃出仓库根时直接拒。 - fail-open,但保留 last-known-good:没有
.gitignore时只保留硬拒(没有.gitignore的项目全拦会把预览整个打死,比漏拦糟得多);而读失败不等于没有规则——重读只发生在「有人刚改完.gitignore的那一刻」,恰是最容易撞上半写状态 / EMFILE / 权限抖动的时刻,此时退回上一次成功编译的规则,只有 ENOENT 才真的回到 fail-open。 - 缓存:与
.spark/meta.json同款签名校验——每请求仅stat一次.gitignore,签名没变复用编译好的 matcher,改.gitignore(含删除)立即生效。签名取(mtimeMs, ctimeMs, size, ino)四元组而非只看 mtime+size,等长同刻改写(sed -i之类)才不会漏判。matcher 每服务 5 万次查询强制重建一次,清掉 node-ignore 的无界 per-path 缓存。 - 命中时留一行 stderr:响应体一个字节不提原因(做成「这里没有这个文件」而非「有但不给你」),但服务端打
blocked (git-ignored): <path>。误伤不是假想——logs/dist/out这类无锚定规则会连src/pages/logs/index.html一起命中,没有这行日志时人和 agent 看到的都只是裸 404,无从诊断。 - 不覆盖:嵌套子目录的
.gitignore、.git/info/exclude、全局core.excludesFile。agent 生成的应用几乎只有仓库根一份.gitignore,为剩下的场景每请求多 stat 若干文件不划算。 - 没有开关:这个 server 只托管源码目录,被 git 忽略的东西本来就不该出现在预览里。
React prod → dev 改写
agent 生成的 HTML 引的是 production 资源(线上就该用 prod,这是对的)。但本 server 只服务沙箱预览 = 开发态,dev 版 React 才有可读的 warning(key 缺失、hook 误用、水合不一致)和非 minified 的报错栈——agent 读 console 排障、人看预览调试都依赖它。所以渲染 .html 时把 prod 换成 dev:
react@<ver>/umd/react.production.min.js → react@<ver>/umd/react.development.js
react-dom@<ver>/umd/react-dom.production.min.js → react-dom@<ver>/umd/react-dom.development.js- 只覆盖 react / react-dom——它们是
coding-unpkg-sdk里唯一镜像了 dev+prod 双版本的两个库;其余(@babel/standalone/echarts/gsap/popmotion)只镜像了.min.js,没有非压缩版可换,换了就 404。这是供给侧的硬边界,不是保守取舍。 - 版本段原样保留,
[email protected]/[email protected]都适用,不随 manifest 升级而失效。 - 改
src的同时摘掉该标签的integrity:SRI hash 是对 prod 文件字节算的,换 dev 文件后校验必然失败,而 SRI 失败浏览器是直接拒绝执行(不是降级)→ 白屏。crossorigin保留(无害)。摘除是属性级的:引号内的内容整段跳过,data-note="see integrity = docs"里的那个不会被误删(误删会吃掉右引号让属性不闭合、页面结构崩坏)。 - 只改 CDN 绝对 URL 上的完整 unpkg 形态
<scheme>//<host>/…/<pkg>@<ver>/umd/<pkg>.production.min.js,且包名左边界必须是/。因此这些都不会被改:agent 下载到本地的./vendor/react.production.min.js、按 unpkg 目录结构 vendor 的/vendor/react@18/umd/…(本地没有 dev 文件 → 改了就 404)、以及…/[email protected]/umd/react.production.min.js这类后缀恰为 react 的第三方包。 - 标签匹配是引号感知的(三分支 alternation:非引号非
>字符 | 双引号串 | 单引号串),不用[^>]*:属性值含>(data-x="a>b")时后者会截断,可能切出「含src但不含integrity」的片段 → 换了 src 却留着 prod 的 SRI hash → 白屏。开标签未闭合(或无引号值里出现引号)时该标签整段不改,不影响其它标签。 .html由本包自己读文件并改写,不经 serve-handler(改写会变响应体长度,从它的输出流里改会踩 Content-Length)。因此 HTML 放弃了 ETag / 条件请求 / Range——本包对所有响应都是no-store,无损。读文件失败(不存在 / 是目录 / 无权限)落回 serve-handler,404 形态与其它资源一致。- 软链
.html一律拒(404),与 serve-handler 的symlinks: false对齐。readFile会跟随软链,而路径字符串校验看不出「最终目标是指向 root 外的软链」;少了这道判断,agent 只要ln -s <任意文件> x.html(root 对 agent 完全可写)就能经预览 URL 读走 root 外的文件。 - 非 UTF-8 的
.html字节原样送出:改写为 no-op 时直接回原始 Buffer,不做 decode→encode 往返(否则 GBK 等编码会被换成 U+FFFD,违背「原样托管」)。命中改写的页面按 UTF-8 重编码——「非 UTF-8 且真的引了 React CDN」这种组合基本不共存,属有界残留。 - 接管
.html/.htm(大小写不敏感),只对 GET / HEAD 生效。其它 method 交给 serve-handler,而它 不区分 method(6.1.7 源码里没有 405 / method 判定),所以 POST/OPTIONS 拿到的是未改写的 prod 内容——浏览器取文档只用 GET,无实际影响。 - 幂等:已是 dev 版的原样返回。
改写无条件生效,没有 flag / env 开关——本 server 只服务沙箱预览(开发态),不存在该给 prod 的场景;线上是另一条链路。
不覆盖(已知边界):<script type="importmap"> 里的 URL(那是标签 children 不是属性)、.js / .mjs 文件里的 URL(只改写 html 响应)。无编译 HTML 场景主流是 UMD + @babel/standalone,这两种形态少见。
编程接口
import { startServer } from '@lark-apaas/coding-static-server';
const server = await startServer({ root: '/home/gem/workspace/code', port: 8001, base: '/af/p/app_x/' });亦导出 resolveConfig / resolvePrefix / stripBase / appendIndexHtml / bareBaseRedirect / prefixBaseHref / injectBaseHref / cleanUrlResolve / resolveServeRoot / gitRelPath / isBlockedPath / createRequestListener / normalizeBasePath / parseFlags / rewriteReactDev 供组合与测试。
