npm.io
0.1.6 • Published 2 months agoCLI

@lark-apaas/coding-static-server

Licence
MIT
Version
0.1.6
Deps
2
Size
72 kB
Vulns
0
Weekly
0

@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 字节级一致,比换成更"现代"的 WHATWG URL 更重要。
  • 技术栈感知服务根(.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 走 Go path.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.html 301 到 /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 包实现——锚定(/build vs build)、目录限定(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。这是供给侧的硬边界,不是保守取舍。
  • 版本段原样保留,react@18.3.1 / react@17.0.2 都适用,不随 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)、以及 …/myreact@1.0.0/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 供组合与测试。

Keywords