webapp-dual-delivery · 网页应用双形态交付
从零做一个网页应用,一次产出两个可交付物:
| 形态 | 产物 | 密钥在哪 | 适用 |
|---|---|---|---|
| 在线版 | online/(server.py + public/ + .env) |
服务端环境变量 / .env |
公网发布、多人共用 |
| 单页版 | single/(index.html + css + js) |
使用者自己填,存 localStorage | 双击即用、离线单机、发给同事 |
同一份前端代码,参数化生成两种形态。 不维护两套。
零、先问清三件事(别急着写代码)
- 要不要调外部 API? 要 → 必须双形态;不要 → 只出单页版即可
- 谁会拿到密钥? 只有自己 → 单页版硬编码也行;给别人 → 必须走代理
- 发布目标是公网还是内网? 公网 → 必须有后端;内网 → 可只出单页
用户说"随便你定"时,按最保守方案来:双形态都出,密钥留空。
一、为什么必须分两个形态(这是本 skill 存在的理由)
核心风险:密钥泄露。
多数 AI / SaaS 接口的 CORS 是完全开放的——实测过连 https://evil.example.com
和 Origin: null(file:// 场景)都回显放行。这意味着:
> 把 API Key 写进前端 HTML,页面一旦传到公网,任何人按 F12 就能看到并盗用,
> 账单直接记在部署者头上。
所以:
- 要分享给别人 / 要公网访问 → 在线版,密钥只在服务端
- 自己本机用 / 离线 → 单页版,密钥可以内置或手填
绝不存在"既要公网又要密钥安全且没有后端"的方案。
二、生成脚手架
python scripts/scaffold.py \
--out <输出目录> \
--title "我的应用" \
--api-base "https://api.example.com/v1/chat" \
--mode both
产出:
<输出目录>/
online/ # 在线版
server.py # 标准库 HTTP 代理:SSE 透传 / 限流 / 白名单 / 目录穿越防护
.env # 服务端配置(密钥只在这里)
public/
index.html # 只放结构
theme.css # 全部样式,语义化变量驱动明暗双主题
app.js # 全部逻辑
single/ # 单页版
index.html
theme.css
app.js # 直连上游,密钥由使用者填
README.md
常用参数:
| 参数 | 说明 |
|---|---|
--mode online\|single\|both |
只要一种就指定,能省一半文件 |
--allow-upstream "/v1/chat,/v1/models" |
上游路径白名单,逗号分隔。公网部署必填 |
--rate-limit 20 --rate-window 60 |
限流:同 IP 每 60 秒 20 次 |
--upstream-timeout 300 |
上游超时(长回答要调大) |
--subtitle --logo --placeholder --hint |
文案定制 |
生成后必须替换 app.js 里的业务逻辑。脚手架给的是"能跑的骨架",不是成品。
三、在线版:server.py 要点
只用标准库,无需 pip install。发布平台要求:必须监听 0.0.0.0 的 $PORT,已满足。
内置的四道防护(都已实现,不要删):
| 防护 | 实现 |
|---|---|
| SSE 真流式 | text/event-stream 时逐块 write + flush,并发 X-Accel-Buffering: no 关掉 nginx 缓冲 |
| 目录穿越 | 规范化路径后校验是否仍在 public/ 内,否则 403 |
| 限流 | 内存记录同 IP 请求时间戳,超阈值 429 |
| 上游白名单 | ALLOW_UPSTREAM 限制可转发路径,防 SSRF |
★★ .env 优先于环境变量(反直觉,必须知道)
_conf() 的实现是 .env 优先:
def _conf(key, default=""):
v = _DOTENV.get(key) # 先读 .env
if not v: v = os.environ.get(key) # 再退环境变量
return v
为什么不用常见的"环境变量优先"?
实测踩过:本机 shell 里存在一个 API_KEY 环境变量,值是 35 字符、以 sk- 开头的
无关 token(平台注入的)。它覆盖了 .env 里的正确 Key,导致所有请求 401,
而直连上游却是好的。排查花了不少时间。
结论:显式写下的配置文件才是部署者的真实意图,应该压过环境变量。
以后凡是"配置文件 vs 环境变量"双来源,都要考虑注入污染。
四、前端 SSE 解析:必须带残余缓冲
分块传输时,一行 JSON 会被网络切成多块。"收到就解析"必然丢事件。
function makeParser(){
var buf = ""; // 未消费的残余文本
function feed(chunk){
buf += chunk;
var evs = [], idx;
while(true){
idx = buf.indexOf("\n");
if(idx < 0) break; // 行尾没到齐 → 等下一块
var line = buf.slice(0, idx);
buf = buf.slice(idx + 1);
line = line.replace(/\r$/, "");
if(!line) continue; // 空行 = 事件分隔
if(line.indexOf("data:") === 0){
var p = line.slice(5).trim();
if(!p) continue;
try { evs.push(JSON.parse(p)); }
catch(e){ buf = p + "\n" + buf; break; } // 极少数跨行 data
}
}
return evs;
}
function flush(){ /* 结束时把残余里完整的 JSON 补捞 */ }
return { feed: feed, flush: flush };
}
验收方法(必做):用真实抓取的数据做三档对拍——整块 / 随机分块 / 单字节分块,
三者的事件签名必须完全一致。单字节分块是最严格的边界。
五、发布到公网
用 sites 能力发布(workbuddy_sites_deploy),参数:
{
"action": "deploy",
"directory": "<online 目录的绝对路径>",
"language": "python",
"startCmd": "python server.py",
"port": 8080,
"domainPrefix": "<短英文 slug>",
"appName": "<中文展示名>",
"userAskedToPublish": true
}
注意:发布只上传源码目录,不继承本机环境变量。 所以 API_KEY 必须写进
online/.env,让它随源码进沙箱。.gitignore 里排除 .env 只影响 git,不影响上传。
发布后必查四项:
U=https://<slug>.app.workbuddy.host
curl -s "$U/api/health" # hasKey 必须为 true
curl -s -o /dev/null -w "%{http_code}\n" "$U/" # 200
curl -s "$U/app.js" | grep -c "<你的key>" # 必须是 0
curl -s -N -X POST "$U/api/proxy/chat" \
-H "Content-Type: application/json" -d '{"messages":[{"role":"user","content":"测试"}]}'
# 最后一问:真发一条,看有没有 answer 事件
第 3 项是安全底线——线上前端绝不能出现密钥字面量。
六、发布前自检清单
□ node --check public/app.js 前端语法
□ python -m py_compile server.py 后端语法
□ 本地起服务,三档分块对拍解析器 不能丢事件
□ curl 首页 / theme.css / app.js 静态资源 200
□ curl /api/health hasKey=true
□ 前端 grep 密钥 0 处
□ 真发一条问答 有 answer 事件
□ 错误路径:空 body / 坏 JSON / 目录穿越 都有合理报错
七、踩过的坑(都实测过)
1. .env 被环境变量里的同名变量覆盖 → 401
见第三节。.env 优先。
2. 中文文案里直接写英文双引号 → JS 语法错误
'<p>—— A" + (i+1) + " ——</p>' // ❌ 引号不闭合
'<p>—— A' + (i+1) + ' ——</p>' // ✅
Node --check 能抓到,浏览器上表现为整页白屏。
3. Git Bash 里 B / P 是内置变量
for f in ...; curl "$B$f" 会被展开成 PortableGit 路径,全部 502。
URL 变量至少写两个字母(BASE_URL),别用单字母。
4. t=t[5:].trim() 不是合法 JS
JS 不支持这种切片写法,必须 t.slice(5).trim()。node --check 能抓到。
5. Shell 吃掉 \u0001
用 heredoc 写 JS 时 \u0001 会被 shell 当转义处理掉,占位符变成裸数字。
写进 .py 文件用 chr(1) 构造真实控制字符注入,别在 shell 里写。
6. CSS 变量写错值很难肉眼发现
写完扫一遍变量定义。本次手误写了 --brand-b:#274karma;(无效值)。
7. .env 里 "Bearer " 的尾随空格被 strip 掉 → 认证失败
写 AUTH_PREFIX="Bearer " 想保住空格,但朴素的 .strip().strip('"') 会连空格一起去掉,
最终拼出 Authorization: Bearer(无空格),上游一律 401。
正确写法:判断"是否为空"用 .strip(),取值时保留原始空白,只脱配对引号:
if len(v) >= 2 and v[0] == v[-1] and v[0] in ("'", '"'):
v = v[1:-1] # 保留引号内的空格
需要干净值的字段(API_KEY / API_BASE)在使用处单独 .strip()。
生成后务必实测:repr(_conf('AUTH_PREFIX')) 应为 'Bearer '。
8. 生成器里 {{ 转义与 CSS 天然大括号冲突
用 str.format() 生成含大量 CSS 的文件时,CSS 的 }} 会被误判成模板残留。
排查时先确认是不是误报:@keyframes x{50%{opacity:0}} 结尾的 }} 完全合法。
真残留的特征是孤立出现的 {{ / }}(前文不是 CSS 语法)。
八、参考
references/api-integration.md—— 各家 API 的 CORS 行为、messages 条数限制、
会话 ID 透传等实测结论references/frontend-patterns.md—— SSE 解析、Markdown 渲染、引用标签还原、
深色主题、导出 Word 的实现片段
九、与既有 skill 的边界
| skill | 做什么 | 与本 skill 的区别 |
|---|---|---|
site-clone-static |
复刻需登录的 Web 后台 | 那个是扒已有站,本 skill 是从零建新站 |
webpage-offline-replica |
复刻在线网页为离线静态页 | 同上,且那个只产出离线形态 |
any-site-to-offline |
在线站整站镜像 | 同上 |
demo-site-config |
demo 换客户配置化 | 那个改已有 demo,本 skill 建新项目 |
本 skill 独有:同时产出「公网在线版 + 单页版」两种交付形态,并解决密钥安全。