diff --git a/docs/adr/ADR-0121-0-42-action-protocol-hardening-amendment.md b/docs/adr/ADR-0121-0-42-action-protocol-hardening-amendment.md index 384c7128d..9af3cc2d2 100644 --- a/docs/adr/ADR-0121-0-42-action-protocol-hardening-amendment.md +++ b/docs/adr/ADR-0121-0-42-action-protocol-hardening-amendment.md @@ -107,13 +107,15 @@ this at the architecture level. `preventDefault()` skips the default morph (the page handles the failure itself). Network and non-HTML failures keep the reload fallback. -12. **CSRF threat model (new; records the standing assumption).** The - framework's form/action loop relies on the browser `SameSite=Lax` - default for cookie-based authentication (sessions are 0.44 scope). - Applications using ambient authentication (Basic, mTLS, - `SameSite=None` cookies) must add Origin/Fetch-Metadata validation; - a documented middleware recipe ships in the guide. A built-in check - is deferred to the 0.44 session work. +12. **CSRF threat model (amended 0.42.0-alpha.7 / #611).** Generated + action POST handlers default to a fail-closed same-origin floor: + reject when `Sec-Fetch-Site: cross-site`, or when `Origin` is present + and does not match the request URL origin. Clients that omit both + headers (typical non-browser tools) are allowed. Opt out by setting + `OPEN_ELEMENT_DISABLE_CSRF=1` on the request `env` binding + (`c.env` / Nitro runtime env). Session-aware CSRF tokens and cookie + session primitives remain 0.44; this floor is the light-fullstack + browser default, not a full auth stack. ## Consequences diff --git a/docs/audit/2026-07-28-alpha6-production-review.md b/docs/audit/2026-07-28-alpha6-production-review.md new file mode 100644 index 000000000..b6b26e99b --- /dev/null +++ b/docs/audit/2026-07-28-alpha6-production-review.md @@ -0,0 +1,229 @@ +# openElement 生产级独立评估报告 + +- **审查日期**:2026-07-28 +- **审查对象**:main 分支,发布线 v0.42.0-alpha.6(0.41.0 已宣称 stable) +- **审查性质**:只读独立审查(未修改任何 git 跟踪文件,结束 `git status --porcelain` 为空) +- **方法**:10 路并行审查代理(8 维度只读审查 + 新用户旅程实测 + 门禁复现)+ 主审对最承重声称的亲自复核。下文标注 **〔主审实测〕** 的条目是主审在本机运行/逐行确认的证据;其余为代理证据,均带文件:行号。 +- **证据纪律声明**:两轮前审(alpha.5/alpha.6 的 53 个 issue)仅作线索,不作证据;仓库文档中每条"已验证/stable/生产级"声称均按待复核处理。门禁复现与精确单测计数见文末补报。 +- **归档复核**:2026-07-28 归档时在 dev 分支对 8 条承重声称做二次抽查(README:61-64 残句、`legacyDsdPolyfill` 幽灵 API、deno.json:88-89 firefox/webkit grep 子集、`injectClientScript` 无条件注入(postprocess.ts:198-204 + build-postprocess.ts:49 调用侧无 island 存在性守卫)、`__scanSubmitRoots` :180 无条件引用、`PageRenderingMode` 'static' 占位枚举(authoring.ts:25)、JSX `IntrinsicElements` 全放行(jsx-runtime.ts:20-22)、`serializeAttrs` 属性名零校验(render-ir.ts:109-148,boolean 分支 :127 属性名裸拼))——**全部仍然成立**。正文"147×3"口径表述已按文末补报修正。 + +--- + +## ① 定位判断 + +**一句话定义**:openElement 是一个以标准 Custom Elements 为唯一组件契约、默认输出 DSD(声明式 Shadow DOM)、static-first 的 Deno 系全栈框架——但今天的真实形态是**三样东西的联合体**:一个已 stable 的 WC 静态站生成器(0.41 线)、一个尚在 alpha 的请求时应用闭环(0.42 线:loader/action/morph)、一个只被两个桌面示例使用的 SPA 运行时(`defineApp`),三者共用 `defineElement` 组件层。 + +**它不是什么**:不是通用全栈框架(README.md:61-64 自认 "not broad fullstack parity");不是组件库(`@openelement/ui` 到 0.46 才决定是否 stable,ROADMAP.md:97);不是有外部生产验证的产品(ADR-0119 记录 #390 试点三个发布周期**零招募**后被撤销)。 + +**目标用户**:按 ROADMAP.md:49-53,是"想让 Web Components 定义整个应用架构的团队"。 + +**不可替代性(正方)**——这个组合位确实空着,六份竞品调研互相印证(主审抽查 astro/enhance 两份,事实准确): + +- WC + DSD 默认 + static-first + 应用层闭环:Enhance 是 WC 全栈但 light-DOM 主义、无构建期 SSG、绑 Architect/AWS;Fresh 是 request-time-first 且 Preact 绑定;Astro 的组件契约不是 CE;Lit SSR 不管应用层。 +- 内置 422/303 PRG 状态代数 + 无 JS 表单闭环:Astro 把 PRG 留作手写 recipe(与 Astro 官方文档一致),openElement 做成内置是正当差异化——虽然语义本身搬运自 SvelteKit/React Router,无新颖性。 +- Vite+Nitro 可移植输出:相比 Enhance 的 AWS 绑定是真优势(`ssg-helpers.ts:164-178` nitro-mount seam)。 + +**反方观点(更有分量)**: + +- 这个"空位"的用户群**数量未证实存在**。#390 试点零招募是最直接的反证;GitHub 实测 2 stars(代理 curl GitHub API),npm 五包月下载 2851-3681 且均匀得反常,与 CI consumer smoke 每次装包的形态吻合——organic 采用信号≈0。 +- 目标用户今天**有更好选择**:要 WC+SSR+表单闭环的生产应用,Enhance 有多年生产历史(session flash 错误闭环成熟);openElement 的对应能力在 alpha.6,且其 morph 增强在 alpha.3/alpha.4 两个已发布 tag 上"从未真正工作过"(ADR-0121 自述)。要 WC+静态站,Lit/Stencil+Astro/11ty 生态大几个数量级。要 Deno 全栈,Fresh 2 是官方选项。 +- "standard CE 跨层复用"(README.md:42-46)是理念差异而非能力差异:Enhance/Lit 用户今天就拥有同等的标准 CE 复用,openElement 只是把 DSD 设为默认。 + +**定位内在矛盾(证据)**: + +- **三个枚举值、两种行为**:`PageRenderingMode = 'auto'|'static'|'dynamic'`(packages/app/src/authoring.ts:25),但全仓对 `'auto'/'static'` 的唯一消费是 authoring.ts:285-291 的合法性校验——`'static'` 是纯占位符,只有 `'dynamic'` 有行为(ssg-render.ts:84-98)。冗余 API 面,白送认知负担。 +- **defineApp 是 SPA 孤岛**:`SpaAppOptions.mode:'spa'` 字面量(spa.ts:26),仓内仅两个桌面例子使用;然而 ADR-0119:30-33 声称冻结 "the static and SPA semantics of defineApp"——**defineApp 不存在 static 语义**,冻结 ADR 的措辞与其冻结的 API 对不上(文档失信,轻度)。 +- **"continuity" 承诺在数据层不成立**:SPA loader 只收 `{params}`(spa.ts:62),请求时 loader 收 `{request,params,env,platform}`(protocol/data.ts:30-35),代码注释自认 "intentionally parallel; not interchangeable (#570)"。同一页面无法在 SPA 与服务端模式间复用 loader——continuity 只覆盖渲染层。 +- **"零 JS 默认"被产物证伪〔主审实测〕**:`www/dist` 全站 **144/144** 个 HTML(含 404.html 和纯静态指南页)都被无条件注入 `` 注入被中和,代理 deno eval 实测);action 分发 own-key 门控;生产 500 三通道全脱敏(裸文案,stack 仅 dev);bodyLimit 10MB 语义正确(有 Content-Length 直接 413 不读 body,无内存放大);CORS 默认仅反射 localhost 且拒绝 `*`+credentials;DANGEROUS_KEYS 12 键过滤;deno.lock v5 全量 386 条 integrity;发布物白名单+provenance。 +- 攻击者视角 TOP-3: + 1. **Action 端点零内置 CSRF 防线**(框架设计问题,ADR-0121 §12 已记录的接受风险):生成 POST 处理器无 Origin/Sec-Fetch-Site/token 检查(entry-render-helpers.ts:156-302),纯 cookie 会话靠浏览器 SameSite=Lax 兜底;SameSite=None、Basic/mTLS、非浏览器客户端场景裸奔。只有文档配方(security.tsx:141-168,fail-closed 写法正确)。修复成本低-中(生成码默认挂 same-origin 检查,50-100 行,0.44 已排)。 + 2. **SSR 属性名注入(实测 XSS 原语,实现缺陷)**:`serializeAttrs` 不校验属性名(render-ir.ts:119-144),恶意 key `"x\" onclick=\"alert(1)\" data-x"` 经 spread props 输出完整事件处理器(代理 deno eval 复现)。React SSR 有 `isAttributeNameSafe`,openElement 没有。修复 <20 行(白名单正则+跳过告警)。 + 3. **`renderSsrError` 生产模式仍输出 error.message**(html-escape.ts:190-207,框架内部未调用但属导出 API 的脚雷)+ `/_data` 路由表无 own-key 检查(entry-render-helpers.ts:484,与 ADR-0121 纪律不一致)。修复各 <10 行。 + +**生态采用 ★☆** + +- 硬门槛(按阻塞度):session/auth 无基元 > **npm `latest` 全部指向 alpha.6〔主审实测:五包 dist-tags 均为 `latest: 0.42.0-alpha.6`,stable 0.41.x 无 tag 保护——`npm install @openelement/element` 默认踩 alpha,与"0.41 已 stable"叙事直接冲突;旅程代理更早还观察到 create 解析到 0.41.2,说明 dist-tag 在漂移,发布纪律本身不稳定〕** > JSX 编辑器零支持 > 迁移指南整体缺席(grep from next/remix/astro/sveltekit 零命中)> 调试=裸 Vite(无错误覆盖页/devtools/sourcemap 承诺)。 +- packages/ui 仅 10 个组件(2366 行),无 select/checkbox/radio/switch/toast/tooltip/table/表单布局,a11y 薄(无焦点陷阱/键盘导航体系);ui 不依赖 shoelace/material(纯自研),**第三方 WC 无桥接层**——shoelace 只在手工 smoke fixture 里客户端渲染(SSR 校验只查裸标签存在,升级前是无样式空壳)。examples 可跑(reader `deno task smoke` 44 passed,代理实测)但走 workspace 相对路径,不验证真实消费路径;mastodon 关键项 "verified by hand"。JSR 是僵尸(latest=0.40.6,落后 npm 两个月,无指向标记)。包目录残留旧 tarball(openelement-ui-0.42.0-alpha.5.tgz)。 + +--- + +## ③ 竞品逐项对比 + +(对比基于仓库六份调研——主审抽查 astro/enhance 两份联网复核准确——及公开事实;SvelteKit 为自选主流参照) + +| 维度 | openElement (0.42a) | Fresh 2 (Deno) | Enhance | Astro | SvelteKit | +| ---------------- | ----------------------------------------------------------------- | --------------------------- | ------------------------------------------- | ----------------------------- | ---------------------------- | +| 组件契约 | 标准 CE + DSD(唯一贯穿应用层) | Preact | 标准 CE(light DOM 主义) | .astro + 任意框架岛 | Svelte | +| 默认渲染 | static-first,逐页 auto/static/dynamic | request-time-first + 静态岛 | SSR-first MPA | static-first(逐页 prerender) | 逐页 SSR/SSG/CSR | +| "零 JS"声称 | **不成立**:每页强制 5.3KB〔主审实测 144/144〕 | 成立(无岛即 0) | 基本成立(无 JS 基线) | 成立(无岛即 0) | 不作此声称 | +| 表单/action 闭环 | 内置 422/303 PRG,双通道对称,alpha | actions 有,成熟度中 | session flash 闭环,多年生产 | actions 稳定,PRG 手写 recipe | form actions,业界标杆 | +| session/auth | **无基元**(0.44) | 中间件生态 | 内置 session | 有 sessions 能力 | hooks+成熟生态(auth.js 等) | +| 客户端连续性 | 自研 morph(实证 bug 群) | 部分岛+整页 | 无(MPA 哲学) | View Transitions | 客户端路由,生产验证 | +| 部署 | 静态一次点亮;Node/Workers 配方级(Nitro 非官方路径,零部署文档) | Deno Deploy 一等 | Architect/AWS 绑定 | 全宿主 adapter 生态 | adapter 生态最完整 | +| 组件生态 | 10 件自研,无第三方桥接 | Preact 生态可用 | 任意 WC 直接复用 | 全框架组件生态 | Svelte 生态+任意 lib | +| 生产验证 | 零外部采用(#390 零招募) | 官方+真实用户 | 多年生产历史(团队已并入 Sanity,活力下降) | 大规模生产 | 大规模生产 | +| 文档/上手 | 指南 13/14 页零代码,旅程 9 卡点 | 官方文档完整 | 文档成熟 | 文档生态最佳之一 | 文档完整 | + +一句话:openElement 真正赢 Fresh 的点只有"DSD 默认+static-first";赢 Enhance 的点是"不绑 AWS+构建期 SSG+现代工具链";赢 Astro 的点是"标准 CE 契约+内置 PRG 闭环"。而这三个"赢点"今天分别被 alpha 成熟度、零生态、零部署文档抵消。 + +--- + +## ④ 阻塞性缺口清单 + +**高(选型否决级)** + +1. **session/auth/CSRF 基元缺失**——packages/ 全 grep 零命中;`docs/integrations/better-auth.md` 自标 "doc-level, not CI-verified"。任何登录态应用须全自建(100-200 行+存储依赖)。〔路线图:0.44;成本:框架级 2-4 周〕类型:生态缺口/框架设计问题 +2. **npm latest 指向 alpha 且 dist-tag 漂移**——〔主审实测〕五包 latest=alpha.6,stable 无线保护;旅程中 create 一度解析到 0.41.2。每个新用户第一脚踩错版本。〔路线图:无;成本:小时级 dist-tag 操作+发布纪律〕类型:实现缺陷(发布) +3. **morph 增强层实证 bug 群**(0.42 核心故事,VERSION_PLAN 自评 "least proven"): + - `__scanSubmitRoots` ReferenceError〔主审 deno eval 复现〕:有岛屿无 enhance 表单的站点每次水合后 timer 抛未捕获异常,#584 rescan 静默失效(entry-generators.ts:180 vs 537 条件分支); + - `form.action` IDL 陷阱〔主审逐行确认 :588-589〕:表单含 ``(极常见)时 fetch 目标变成 `"[object HTMLInputElement]"`,同步抛 TypeError 逃出 .catch,表单**永久卡死**; + - 跨表单 last-wins 静默丢响应〔主审确认 :610〕:A 表单 action 已在服务端成功,响应被整体丢弃,UI 永久过期零反馈; + - 焦点/滚动/表单控件 property 连续性完全无处理(全文无 activeElement;`__syncAttrs` 不碰 checked/value property,用户摸过的 checkbox 从此拒收服务端状态);a11y 上每次 morph 丢焦点无 aria-live; + - 嵌套 DSD 非递归实例化(:371-388),岛中岛 morph 后留下惰性 template; + - `open:ready` 对 load/only 策略从不派发(:194-208)。 + 〔路线图:无明确条目;成本:单点修复均 <100 行,系统性成熟(焦点/滚动/并发语义+三引擎多表单 fixture)2-4 周〕类型:实现缺陷+测试缺口 +4. **server 产物开箱不可运行 + preview 误导**〔主审旅程实测〕:无 start task/serve CLI、import map 缺 nitro-mount 子路径、preview 下动态路由静默 fallback。动态路线用户必踩且零文档。〔路线图:无;成本:1-3 天〕类型:实现缺陷+文档失信 +5. **静态页渲染失败 → 构建退出码仍 0**(ssg-render.ts:146-149 机制确认;唯一兜底门禁未入 CI)。线上静默缺页,是"生产级"的直接反例。〔路线图:无;成本:1-2 天,把 static-output-freeze 接 CI+构建失败语义统一〕类型:实现缺陷+测试缺口 + +**中(绕行成本高)** + +6. 数据驱动 SEO meta 不可声明(head 静态对象,authoring.ts:153-158)——内容站的 og:title/canonical 无法从 loader 来。〔无计划;~1 周〕框架设计问题 +7. loader 无响应通道:动态路由强制 no-store,ETag/max-age 只能 Hono 中间件绕行(data.ts:30-35;entry-render-helpers.ts:168)。〔0.44 cache 排期〕框架设计问题 +8. 幂等/防重:no-JS 双击=两次真实 POST,无 idempotency key 机制。〔无计划;依赖 session〕框架设计问题 +9. i18n 半成品(URL 前缀+locale 静态路径,词典/t() 全自建),**0.43-0.46 均无条目**。定位风险 +10. 类型断链 ×3:loader Data 零推导、JSX 全 unknown、ctx.route 运行时注入但类型无声明(指南还写错)。〔无计划;1-2 周〕实现缺陷 +11. sourcemap 全面缺失+configFile:false 封堵用户配置。〔无计划;1-2 天〕实现缺陷 +12. 部署文档/matchRequestTimeRoute 宿主契约零文档;Nitro 集成停留 fixture 级。〔无计划;数天〕文档失信/生态缺口 +13. 文件上传:bodyLimit 10MB 硬编码不可配,超限 413 在 enhance 路径**静默重载丢表单**无错误提示(entry-generators.ts:620,646)。〔无计划;1-2 天〕实现缺陷 +14. 表单回填/字段级错误约定全手写(ADR-0120 rule 3 的 "echoing values" 只兑现 fail data)。〔无计划;~1 周〕框架设计问题 + +**低(有损信任但不阻塞)** + +15. `'auto'/'static'` 冗余枚举(三值两行为)。〔无计划;小时级,0.43 deprecate〕框架设计问题 +16. 文档失信集合:README.md:61-64 重复残句〔主审实测〕;README 把 alpha.6 与 stable 写进同一句;`legacyDsdPolyfill`/`injectDsdPolyfill` 幽灵 API〔主审实测:仅文档引用,src 零命中——基线外浏览器实际无支持路径〕;comparison 页 Fresh "zero build step" 过时(与自家调研 fresh.md 矛盾);CHANGELOG 三处用错 API 名(`rendering:` vs 实际 `renderIntent:`);create README 称生成 www/ 目录(实际无);packages/ui 残留旧 tarball。〔成本:小时级〕文档失信 +17. "147×3"口径歧义(fixture 三引擎合计 147 真跑;www 站 firefox/webkit 仅 26 用例子集,已有文档披露——详见补报修正);interface snapshot 强度不足(#592/#593 OPEN)。〔TP-6〕测试缺口 +18. 模板细节:blog 预置配置 404、check task 硬编码 4 文件、新增路由不热发现。〔成本:1-2 天〕实现缺陷+文档缺口 + +--- + +## ⑤ 三类用户采用建议 + +**内容站团队(博客/文档/营销)——今天可以用,但要钉版本、闭眼蹚坑。** +静态子集走 0.41 stable 线,是全线最成熟的部分:DSD 首屏即内容对 SEO/爬虫/打印天然友好(shadowrootmode 内容就在初始 HTML);旅程实测 build 1.7s、产物 368KB、纯静态宿主零配置一次点亮。**前提**:`npm install` 显式钉 `@openelement/*@0.41.x`(绝不能装 latest);接受指南靠不住、要靠读模板源码;接受每页强制 5.3KB 客户端 JS("零 JS"是话术);接受无增量构建(大站构建线性增长)、dev 加路由要重启。**诚实的反问**:除非"标准 WC 组件契约"是你的硬约束,Astro 今天在生态、内容集合、文档、增量构建上几乎全面更优——选 openElement 的理由是赌 WC 架构的长期复利,不是当下的生产力。 + +**轻表单应用团队(订阅/联系单/简单 CRUD)——谨慎可用,仅限能自建底座的团队。** +协议骨架实测正确且双通道诚实(旅程实测:422 回显、303 PRG、跟随重定向渲染,no-JS 全闭环)——这是相对 Astro 的真差异化。但:morph 增强层有实证活 bug(含可让表单永久卡死的 form.action 陷阱);session/CSRF/幂等要自建;server 产物要自己接线才能跑。**建议**:今天用就 no-JS 优先、把 enhance 当渐进增强而非依赖,CSRF 从 security 指南抄配方挂 `_middleware`;否则等 0.42 stable + 0.44 再评估。 + +**业务后台团队——今天不能用。** +无 auth/session/权限、无表格/表单组件体系(ui 仅 10 件,无 select/checkbox/table)、JSX 无类型提示、调试裸 Vite、无 i18n 计划——每一项都是后台刚需,且大多不在 0.43-0.46 路线图。选 SvelteKit/Remix/Next;openElement 对这个人群的最早合理评估窗口是 0.44 之后。 + +--- + +## ⑥ TOP-5 结构性风险(设计赌注,非 bug) + +1. **"WC 全应用架构"的用户群真实存在且足够大。** 全部定位押在"有团队愿意让标准 CE 定义整个应用架构"。反证已出现:#390 试点零招募、2 stars、npm 下载疑似 CI 贡献。**赌注成立的证据**:重启试点能招募到 ≥3 个外部团队做出真实项目并存活 3 个月;organic 下载与 CI 下载可区分且持续增长。 +2. **DSD-first 的代价能被框架机制封住。** shadow 事件边界(submit 刚炸过)、样式工程(每 shadow root 注 adoptedStyleSheets)、a11y(morph 丢焦点)、第三方 WC 的 SSR(Shoelace 首屏是无样式空壳)——赌注是这些墙能被封到用户不用操心;0.43 通用 WC SSR 是关键一战。**证据**:第三方 WC 在 DSD SSR 下首屏即有完整样式内容、以 ElementInternals 参与 enhance 表单的三引擎 e2e;一份完整的"DSD 代价清单"文档(现在 BROWSER_BASELINE 只承认了一半,样式穿透代价无文档)。 +3. **自研 morph 增强层能收敛到 Turbo/idiomorph 级成熟度。** 这是个有十年积累的成熟问题域,openElement 选择自研 WC 版。现状:alpha.3/4 从未生效(ADR-0121 自述)、两轮审查 53 issue 后我仍实证 ≥5 个活 bug/脆弱点。**证据**:多表单并发+嵌套岛屿+键盘导航场景的三引擎 e2e 套件进 critical-path 门禁;增强层公开 bug 连续两个版本为零;与 idiomorph 的 DOM 语义对齐矩阵公开。 +4. **static-first 与 fullstack loop 同一心智模型不撕裂。** 今天已有裂缝:loader 上下文双形态不可互换(#570 自认)、defineApp 是 SPA 孤岛、'static' 占位符枚举、ADR-0119 冻结了 defineApp 不存在的 "static 语义"。赌注:连续谱成立而非两个产品缝在一起。**证据**:同一页面文件不改一行代码在 static/dynamic 间切换的 e2e;SPA 与 server 链共享数据层类型与缓存语义;删掉冗余枚举而不引发用户混淆投诉。 +5. **治理工程能在零社区时充当可信度代理。** 239 个内部 md/102 个 ADR/35 门禁 vs 15 页用户指南——体量倒挂,且部分机制是自证(证据 JSON 自写自校、"147×3"口径、"零 JS"话术与产物矛盾)。赌注:这套证据链能让选型者信服。风险:一旦外部发现声称与产物不符(我已经发现了),治理反而成为负资产——"连自己的门禁都管不住自己的 README"。**证据**:每条对外声称都可点击溯源到一个 CI 断言;外部用户/贡献者引用证据链作为采用理由;#592/#593 关闭。 + +--- + +## ⑦ 只做三件事(ROI 排序,与路线图措辞正交) + +**1. 修"第一公里"到零卡点(约 1-2 周,决定每个新用户第一小时的留存)。** +四件事打包:发布纪律(latest 指回 stable 线、alpha 只在 alpha tag;create 模板与发布线机械对齐);模板修 blog 死链/check 硬编码/新增路由热发现;给 server 产物一个 `deno task start`(生成 runner+补全 import map),让 preview 对动态路由要么支持要么显式报错;把 Deployment 和 Getting Started 两页写成有命令、可照做的文档。ROI 最高的原因是:这是唯一一件"每个新用户 100% 经过"的事,而今天它 4 个高严重度必踩卡点(旅程实测)——其余所有能力建设都排在"用户能跑起来"之后才有意义。 + +**2. 把 morph 增强层当产品级子系统补质量(2-4 周,0.42 故事的心脏)。** +修 5 个实证 bug(ReferenceError、form.action 陷阱、last-wins 丢弃、open:ready 不一致、嵌套 DSD 递归)+ 补焦点/滚动/表单控件 property 连续性 + 定义多表单并发语义,然后建一个真实多表单 fixture 进三引擎 critical-path 门禁。morph 是"WC Application Loop"相对 Enhance 的唯一体验差异,也是当前最薄弱件——它不稳,0.42 整条发布线的故事就立不住;它稳了,轻表单用户那一档(⑤)立即从"谨慎"变"可以"。 + +**3. 把每条对外声称绑到机器证据,或改成精确措辞(约 1 周,防信任崩盘)。** +"先验证据引用"替代"事后禁词表":README/营销页的每条能力声称(零 JS、147×3、workers verified、frozen)要么挂一个 CI 断言,要么改写成与产物一致的精确表述(如 "no framework runtime on the critical path; a 5.3KB island client ships on every page");同时把 interface snapshot 升级成 type-level(关掉 #592)。对零社区项目,可信度是唯一资产——而当前证据(README 残句、幽灵 polyfill、Fresh 过时描述、144/144 强制 JS)说明治理体系管得住代码、没管住门面。这件事不做,前两件的成果会被第一条"声称与产物不符"的外部推文抵消。 + +--- + +## ⑧ 按"WC 全栈框架"目标的定向重读(归档时追加) + +> 维护者已明确:项目目标是 **WC 全栈框架**,而非 SSG 静态站生成器——SSG/static-first 是路径和入口,全栈应用闭环才是产品本体。据此,本报告的若干结论需要换一个读法:① 中"更像 SSG 生成器"是当前成熟度快照,不是方向判定;下列重排不推翻任何事实发现,只改变优先级权重。 + +**目标对齐后的资产盘点**:全栈框架最难补、最容易烂的部分是协议层,而它恰好是本项目最硬的一环(见 ② 应用闭环)——资产与目标是对齐的。竞争窗口同样支持这个目标:WC 全栈空位上唯一在位者 Enhance 团队已并入 Sanity、活力下降(见 ③ 对比表),Fresh 绑 Preact,Astro 契约非 CE。错位的是路线图排序和营销话术,不是方向。 + +**优先级重排(相对 ⑦ 的调整)**: + +1. **morph 从"第二件事"升为不可谈判的第一件**。SSG 可以没有 morph,全栈框架不行——它是 Application Loop 相对 Enhance MPA 哲学的唯一体验差异。④ 高档 #3 的 5 个实证 bug(尤其 `form.action` 永久卡死)修不掉,0.42 故事立不住。 +2. **数据基元(session/CSRF/flash/幂等)应评估从 0.44 提前**。⑤ 把"轻表单应用团队"评为"谨慎可用",卡的就是这四样;这一档用户是全栈叙事的滩头阵地。与 0.43 排期的通用 WC SSR 相比,数据基元更快让一个真实应用跑在框架上。 +3. **server 产物开箱不可运行从"瑕疵"升级为"资格问题"**(④ 高档 #4)。自称全栈的框架,request-time 路径必须是一等公民体验:`deno task start`、部署文档、preview 动态路由要么支持要么显式报错。 +4. **"零 JS"话术直接放弃而非修补**。那是 SSG 身份的卖点,与全栈目标不匹配;营销面改讲"标准 WC 从静态页到动态应用的连续谱",同时自然消解最大的文档失信项(① 144/144 强制注入)。 +5. **dogfood 必须换到想成为的那一半**。www 是内容站,验证的是 SSG 身份;数据基元落地后,需要一个 loader/action/session 全链路的带登录态真实应用作旗舰 dogfood——⑥ 风险 #1(用户群未证实)的第一块证据只能自己先给。 + +**不变的部分**:⑦ 第一件事(第一公里)和第三件事(声称绑证据)的优先级不受目标调整影响——发布纪律、可信度对两种身份都是前提。⑥ 的 TOP-5 结构性风险全部保持有效,且 #1(WC 全应用用户群存在性)对全栈目标的权重更高而非更低。 + +--- + +## 补报:门禁与测试数字复现结果(门禁复现代理完成后追加) + +全部实跑完成,未修改任何 git 跟踪文件(结束 `git status --porcelain` 为空)。日志在 `/tmp/autoflow-ci.log` 等。 + +**1. `deno task autoflow:ci` 端到端复现:成立。** CI tier 恰好选中 35 个门禁(policy.ts 对 ci/release 恒全量,无视路径触发器),非 fail-fast、逐条汇总、失败 exit 1。实测 9 分 46 秒,**34/35 PASS**。唯一 FAIL 是 `release:evidence:check`——环境性原因:它要求本地存在 git tag `v0.42.0-alpha.6` 做 `git rev-parse`,本地 clone 只到 alpha.5(已用 `git tag -l` 证实);审计记录的真实 CI run 中该门禁 PASS。无跳过、无子集替代(`OPEN_ELEMENT_E2E_OFFLINE` 在 ci tier 明确不生效)。 + +**2. 单测计数:声称 971,实测 973 passed / 0 failed(16s)。** 差异已定位:971 是 alpha.5 审计口径,alpha.6 的 parity 修复净增 2 个用例(`git diff --stat v0.42.0-alpha.5..HEAD` 证实)。口径为 test case。判定:**属实**(数字随版本漂移,当前应说 973)。 + +**3. "147×3 引擎"的真实口径——需要修正我报告正文的说法。** 实测结果: + +- **147 的出处是 fixture 套件**:`live.spec.ts` 每引擎 49 用例,三引擎**合计 147**(不是每引擎 147)。`fixture:request-time:gate` 实测 28 秒、147/147 全过(chromium+firefox+webkit 各 49)。审计原文 "126×3" 同理是合计口径。判定:**数字真实、全部三引擎真跑**,但 "×3" 表述易被读成每引擎 147——按 "147×3=441" 理解则夸大 3 倍。 +- **www 主站套件**:每引擎 `--list` 为 159 用例;CI 上 **chromium 全量 159 全过(6.7m),firefox/webkit 各跑 26 个 grep 子集全过**——且这一点 autoflow-ci.yml 注释与 BROWSER_BASELINE.md:12-14 均**如实披露**("full E2E suite on Chromium and the smoke subset on Firefox and WebKit"),无隐瞒。 + +**修正**:我正文 ②工程质量栏写的 "'147×3' 口径有水分" 应降级为"**口径歧义**"——fixture 三引擎是实打实全量跑的(49×3),www 侧的子集事实也有文档级如实披露。真正站不住的声称仍然是另外两条:每页强制 5.3KB 客户端 JS 的"零 JS"话术(主审实测 144/144 页面),和幽灵 API `legacyDsdPolyfill`。 + +**4. 对记分卡的影响**:★★★☆ 维持不变,但依据更新——可复现性这一项从"待验证"变为"**已验证**"(35 门禁真实执行、973 单测全绿、fixture 三引擎 28 秒全过,这套门禁体系不是表演);不升星的原因也维持原判:interface snapshot 弱(#592 OPEN)、证据 JSON 自证、critical-path 以测试标题为证据、第三方 WC smoke 不在 CI、www 站 Firefox/WebKit 只有 26 用例覆盖("三引擎验证"对 www 站只能算有限成立)。 + +至此全部 10 路审查 + 主审复核 + 门禁复现均已闭环,报告中不再有待补项。 diff --git a/docs/current/PACKAGE_SURFACE.md b/docs/current/PACKAGE_SURFACE.md index 8f114d3d8..46195aa97 100644 --- a/docs/current/PACKAGE_SURFACE.md +++ b/docs/current/PACKAGE_SURFACE.md @@ -12,13 +12,13 @@ authoring modes = Basic Element standalone + full application ## Current five-package surface -| Package | Responsibility | Supported public interface | -| --------------------------- | ---------------------------------------------------- | ----------------------------------------------------- | -| `@openelement/element` | Custom Elements, JSX, DSD, hydration and signals | root, `jsx-runtime`, `jsx-dev-runtime`, `build-utils` | -| `@openelement/app` | Pages, routing, islands and request/render semantics | root, `hono`, `model`, `spa`, `preact` | -| `@openelement/adapter-vite` | Vite, content, SSG and Nitro build implementation | root, `nitro-mount`, `cli/build`, `sitemap` | -| `@openelement/create` | Installed starter and coherent version entry | CLI binary (root) | -| `@openelement/ui` | Optional, proven general-purpose primitives | root and retained primitive subpaths | +| Package | Responsibility | Supported public interface | +| --------------------------- | ---------------------------------------------------- | ----------------------------------------------------------------------- | +| `@openelement/element` | Custom Elements, JSX, DSD, hydration and signals | root, `jsx-runtime`, `jsx-dev-runtime`, `build-utils` | +| `@openelement/app` | Pages, routing, islands and request/render semantics | root, `hono`, `model`, `spa`, `preact` | +| `@openelement/adapter-vite` | Vite, content, SSG and Nitro build implementation | root, `nitro-mount`, `cli/build`, `cli/start`, `cli/preview`, `sitemap` | +| `@openelement/create` | Installed starter and coherent version entry | CLI binary (root) | +| `@openelement/ui` | Optional, proven general-purpose primitives | root and retained primitive subpaths | Application authors should normally learn `element`, `app`, `adapter-vite`, and `create`; `ui` is optional. @@ -55,7 +55,7 @@ promise and are not application-authoring surface. "internal": ["i18n"] }, "@openelement/adapter-vite": { - "supported": [".", "nitro-mount", "cli/build", "sitemap"], + "supported": [".", "nitro-mount", "cli/build", "cli/start", "cli/preview", "sitemap"], "internal": [] }, "@openelement/create": { @@ -97,7 +97,8 @@ promise and are not application-authoring surface. `plugin-mdx`, `route-manifest`, `cli/build-client`, `cli/build-ssg`) were pruned at the 0.41.0 freeze (ADR-0119): they had zero consumer specifiers — the build pipeline and generated code import only relatively or through the - supported root, `nitro-mount`, `cli/build` and `sitemap` subpaths. The module + supported root, `nitro-mount`, `cli/build`, `cli/start`, `cli/preview` and + `sitemap` subpaths. The module files remain inside the package for internal relative imports only. - `@openelement/ui` supported subpaths: `open-badge`, `open-button`, `open-callout`, `open-card`, `open-code-block`, `open-dialog`, diff --git a/docs/current/VERSION_PLAN.md b/docs/current/VERSION_PLAN.md index 083261da0..2c58c37ce 100644 --- a/docs/current/VERSION_PLAN.md +++ b/docs/current/VERSION_PLAN.md @@ -1,28 +1,45 @@ -# v0.42.0 — WC Application Loop release plan +# v0.42.0 — WC Application Loop (light fullstack) release plan > Current source package line: `v0.42.0-alpha.6`\ > Current npm registry line: `v0.42.0-alpha.6`\ -> Active release target: `v0.41.1`\ -> Planning release target: `v0.42.0` (WC Application Loop)\ +> Next alpha train: `v0.42.0-alpha.7` (TP-5.7)\ +> Active release target: `v0.42.0-alpha.7`\ +> Planning release target: `v0.42.0` (WC light fullstack / Application Loop)\ > Next release line: `v0.43.0` (Universal WC SSR)\ > Current maturity stage: stable (0.41.x line); 0.42.0 planned under ADR-0120 +> Plan amendment: 2026-07-28 — light-fullstack product promise, CSRF floor +> in alpha.7 ship gate, login-via-recipe clarified (no 0.44 wait) ## Objective -`0.42.0` ships the WC Application Loop governed by -[`ADR-0120`](../adr/ADR-0120-0-42-0-wc-application-loop-scope.md): one -route-to-interaction loop — load, DSD render, progressive form, action, -error/redirect and revalidation — that works without JavaScript. It extends -the frozen static-first model to request time without touching the ADR-0119 -freeze surface. +`0.42.0` ships **WC light fullstack**: the Application Loop governed by +[`ADR-0120`](../adr/ADR-0120-0-42-0-wc-application-loop-scope.md) — one +route-to-interaction loop (load, DSD render, progressive form, action, +error/redirect, revalidation) that works without JavaScript — plus the +minimum operational floor so a stranger can `build → start` a dynamic +route, trust static prerender failures, and post forms under a default +same-origin CSRF check. It extends the frozen static-first model to +request time without touching the ADR-0119 freeze surface. ```text OpenElement = Web Components-native fullstack application framework -0.42 scope = the route-to-interaction loop at request time +0.42 product promise = WC light fullstack +0.42 scope = request-time application loop + first-mile + CSRF floor +0.42 does NOT ship = framework session/flash, cache/ISR, auth packages +login apps = supported via recipes (better-auth) on Web-standard Request component contract = standard Custom Elements (unchanged) official build path = Vite + Nitro (unchanged) ``` +**Light fullstack means (0.42 exit):** dynamic `loader`/`action` routes; +no-JS and enhanced form loops; honest npm tags; runnable server artifact; +fail-closed static prerender; default CSRF same-origin on generated POST; +documented login via third-party session on `Request` headers. + +**Light fullstack does not mean:** framework-owned session store, flash +across redirects, OAuth package, or production cache/ISR — those remain +`0.44.0` (or stay recipes forever if a library already owns them). + The protocol layer is evidence-backed by the six-framework study archived at [`docs/audit/2026-07-27-application-loop-framework-research/`](../audit/2026-07-27-application-loop-framework-research/README.md): standard form POST wire format, one POST/two responses, the 303/422 status @@ -39,16 +56,27 @@ invariant. - The external adopter pilot #390 stays retired by ADR-0119; 0.42 builds adoption evidence through reproducible recipes and dogfooding instead of a pilot program. -- Request-time data, forms, sessions and cache are explicitly unfrozen; - this plan covers data and forms only. Sessions and cache stay with - `0.44.0`. +- Request-time **data and forms** are the 0.42 unfrozen surface this plan + freezes at TP-6. **Framework session and cache semantics** stay with + `0.44.0`. **Login is not blocked on 0.44**: apps attach better-auth (or + any cookie session library) via `loader`/`action` + `Request` headers per + `docs/integrations/better-auth.md`; the framework does not own the + session blob. - ADR-0120 records the scope boundary (self-built loop semantics and continuity mechanism; third-party server layer and Web-standard context) and the action protocol. Changes to either require an ADR-0120 amendment. -- The line shipped as four themed alphas; a fifth remediation alpha - (TP-5.5) was inserted by the first implementation audit round before the - stable decision; the stable freeze scope for request-time semantics is - decided by a separate ADR at the end of the line. +- CSRF: ADR-0121 accepted a documentation recipe. This plan **promotes a + default same-origin check on generated action POST** into the 0.42 light + fullstack floor (#611); the #611 PR lands the ADR-0121 amendment text + with the code (fail-closed default, documented opt-out for non-browser + clients). Full session-aware CSRF tokens remain 0.44-optional. +- Surgical `@openelement/element` security fixes that do not change the + ADR-0119 authoring surface are allowed in remediation alphas (e.g. #602 + attr-name allowlist). Broader element API work is still a stop-and-recheck + signal. +- The line shipped themed alphas plus remediation alphas (TP-5.5–TP-5.7); + the stable freeze scope for request-time semantics is decided by a + separate ADR at TP-6. ## Task packages @@ -288,22 +316,68 @@ expanding the 0.42 scope. included; release-tier gates green; alpha.6 published with two-stage evidence; #592/#593 stay open as the TP-6 agenda they record. -### TP-6 — `0.42.0` stable decision +### TP-5.7 — `0.42.0-alpha.7` light-fullstack floor + audit round 3 -Goal: decide and ship the request-time freeze scope. +Goal: close the third review round (issues #597–#616, milestone +`v0.42.0-alpha.7`, source `docs/audit/2026-07-28-alpha6-production-review.md` +plus the 2026-07-28 orchestrator review) **and** land the light-fullstack +operational floor (runnable server, fail-closed SSG, CSRF default, honest +tags/claims) without expanding into framework session/cache (0.44). -- 准入: TP-5.6 closed (alpha.6 published); the seven-day P0 watch on the +- 准入: TP-5.6 closed (alpha.6 published); round-3 findings filed as + #597–#616 (done 2026-07-28); this plan amendment (light fullstack + CSRF + ship-gate promotion) accepted in-repo (2026-07-28). +- 执行步骤: + 1. **Ship gate (must)** — Meta checklist #616: + - Morph residual: #597 H1 `__scanSubmitRoots`, #598 H2 `form.action` + IDL / `name=action`, #599 H3 multi-form last-wins + - Build/ops floor: #600 H4 SSG non-200 fail-closed + freeze hard-fail; + #601 H5 request-time `start`/import-map/preview first mile + - Security floor: #602 M1 `serializeAttrs` attr-name allowlist; + **#611 CSRF same-origin default on generated action POST** (promoted + from capacity → must; lands ADR-0121 amendment with the PR) + - Honesty floor: #607 M6 npm `latest`→stable; #608 M7 zero-JS / + client.js / STATUS claim precision + 2. **In-train if capacity** (not required to publish alpha.7): #603 M2 + morph focus/scroll/controls, #604 M3 nested DSD, #605 M4 `open:ready`, + #606 M5 single island scheduler, #609 L1 PageRenderingMode collapse, + #610 L2 extract morph module. + 3. **Login path (docs, not framework session):** keep better-auth recipe + accurate against the alpha.7 loop; README/STATUS one-liner: “login via + recipe on 0.42; framework session is 0.44”. Optional: one CI or + recorded dogfood that boots the recipe shape (not a new package). + 4. **Explicit non-blockers:** framework session/auth primitives #612 → + 0.44; loader head/SEO #613; sourcemaps #614; SPA/server loader typing + #615; freeze-gate shape #592 and freeze baseline policy #593 → TP-6. +- 准出: every ship-gate issue closed with code + test/gate proof; fixture + suite three engines green including multi-form / island-only paths + touched by H1–H3; CSRF default covered by unit or e2e deny/allow; + `deno task start` (or documented equivalent) hits a dynamic route in CI + or release smoke; release-tier gates green; alpha.7 published with + two-stage evidence; `npm view` dist-tags honest (`latest` stable, `alpha` + current); deferred issues stay open with `deferred` label. + +### TP-6 — `0.42.0` stable decision (freeze light fullstack) + +Goal: freeze and ship the **WC light fullstack** request-time surface. + +- 准入: TP-5.7 closed (alpha.7 published); the seven-day P0 watch on the last alpha shows no P0. - 执行步骤: - 1. Stable-scope ADR: which request-time semantics freeze (expected: the - loop contract and action protocol; expected unfrozen: streaming, - performance behavior) and which defer to 0.43/0.44. - 2. Migration note: zero-cost upgrade proof for static-only users; prose - for SPA users. - 3. Release through the full workflow; restart the seven-day P0 watch. + 1. Stable-scope ADR: freeze the loop contract, action protocol, CSRF + default, and first-mile start semantics. Explicitly **unfrozen / + out of 0.42 claim**: framework session/flash, cache/ISR, streaming, + performance SLOs, third-party WC SSR corpus (0.43), production + runtime recovery (0.44). Record that login apps use recipes. + 2. Product wording: README/www/STATUS say `0.42 = WC light fullstack` + with the non-goals above; no “full production runtime” claim. + 3. Migration note: zero-cost upgrade proof for static-only `0.41.x` + users; prose for SPA users; note for apps adding better-auth. + 4. Release through the full workflow; restart the seven-day P0 watch. - 准出: npm, dist-tags, tag, GitHub release, docs and evidence agree on `0.42.0`; the freeze ADR is accepted; #37-style stable gate for 0.42 - opened fresh (not reused). + opened fresh (not reused); light-fullstack acceptance bullets below + are all green. ## Acceptance @@ -312,19 +386,36 @@ Goal: decide and ship the request-time freeze scope. - A pure-static `0.41.x` project upgrades to `0.42.0` with zero changes and byte-identical output. - The no-JavaScript form loop is proven in three engines, not inferred. +- Enhanced multi-form and island-only client paths do not throw or silently + drop successful actions (TP-5.7 ship gate). +- A project with request-time routes can `build` then `start` (documented + one-command path) and complete a GET loader + POST action without tribal + knowledge. +- Unexpected static non-200 prerenders fail the build; freeze/missing-page + assertion is a CI hard fail. +- Generated action POST rejects cross-site browser requests by default; + opt-out is documented. +- npm `latest` points at the last stable line; `alpha` at the active alpha. +- Login-via-recipe is documented as supported on 0.42; framework session is + explicitly a 0.44 topic — not a prerequisite for signed-in apps. - The WC + DSD + static-first loop is documented as the product - differentiator in README/www after alpha.4. + differentiator in README/www. ## Non-goals -- No session or cache semantics (0.44); request-time routes ship a - conservative no-cache default only. -- No auth, OAuth, ORM, database or storage features — recipes only. -- No new package; no change to `@openelement/element`; no change to the - ADR-0119 frozen surface or the SPA client-side chain. +- **No framework session or cache semantics** (0.44). Request-time routes + keep a conservative no-cache default only. Cookie sessions owned by + better-auth (or equivalent) are in-bounds via recipes. +- **No auth, OAuth, ORM, database or storage packages** — recipes only. + Signing users in on 0.42 is a recipe problem, not a “wait for 0.44” gate. +- No new package; no change to the ADR-0119 frozen authoring surface or the + SPA client-side chain. Surgical element security fixes only (see Entry + truth). - No `shouldRevalidate`-style route opt-outs until a proven need exists. - No streaming SSR; per-request render is buffered in 0.42 (streaming is a 0.43/0.44 candidate). +- No pulling 0.43 third-party WC SSR corpus or 0.44 production-runtime + platform into this line. ## Test matrix @@ -352,8 +443,13 @@ Goal: decide and ship the request-time freeze scope. ## Risks 1. Island state preservation across morph is the least proven piece; TP-4 - carries it deliberately, and a failing matrix holds the alpha. + carried it, TP-5.6–5.7 harden residuals; a failing matrix still holds + the alpha. 2. Dev/build parity drift between hono and Nitro is contained by the TP-2 contract test — the 0.41-era dev/SSR/SSG drift lesson. -3. Scope pull toward sessions/cache is blocked by ADR-0120; any temptation - is an amendment, not an edit. +3. Scope pull toward **framework** sessions/cache is blocked by ADR-0120; + any temptation is an amendment, not an edit. Recipe-based login is + encouraged and must not be misread as “framework session shipped.” +4. Over-claiming “fullstack” without first-mile/CSRF/honest tags burns + trust faster than missing features — TP-5.7 ship gate exists to prevent + that. diff --git a/docs/governance/PROJECT_WORKFLOW.md b/docs/governance/PROJECT_WORKFLOW.md index 5f9cea960..e4fecce58 100644 --- a/docs/governance/PROJECT_WORKFLOW.md +++ b/docs/governance/PROJECT_WORKFLOW.md @@ -11,8 +11,8 @@ complete because an issue, chat message, or SOP says it is complete. It is complete only when the repository contains the decision, the execution package, the implementation, and the gates that prove the claim. -Current execution anchor: published package line `v0.42.0-alpha.6`, completed -implementation anchor `v0.42.0-alpha.6`, and `0.42.0` WC Application Loop planning +Current execution anchor: published package line `v0.42.0-alpha.6`, active +train `v0.42.0-alpha.7` (TP-5.7), and `0.42.0` WC light fullstack planning under ADR-0120 and `docs/current/VERSION_PLAN.md`. OpenElement is one Web Components-native, static-first application framework: Basic Element is an authoring mode, not a @@ -116,11 +116,14 @@ smoke are release evidence, not telemetry. A version line is not closed until the status, roadmap, release checklist, release note, and public README files record the npm outcome truthfully. -Dist-tag policy for the alpha line: every prerelease publish tags both its -prerelease line (`alpha`/`beta`/`rc`) and `latest`, so `latest` never lags the -active release line. `tools/verify-npm-release.ts` enforces this by asserting -`dist-tags.latest` equals the just-published version; stable publishes keep the -npm default of tagging `latest`. +Dist-tag policy (#607): prerelease publishes tag only their line +(`alpha`/`beta`/`rc`). **`latest` always tracks the last stable release** so +`npm install @openelement/*` never lands on alpha by default. Consumers who +want the alpha train use `@alpha` or an exact version. Stable publishes keep +npm's default `latest` tag. `tools/verify-npm-release.ts` asserts +`dist-tags.` for alphas and `dist-tags.latest` for stables. +After alpha.6, run a one-time `npm dist-tag add @openelement/@ latest` +if registry `latest` still points at an alpha. ## Automation Gates diff --git a/docs/release/v0.41.0-interface-snapshot.json b/docs/release/v0.41.0-interface-snapshot.json index 79e11194e..5e616726e 100644 --- a/docs/release/v0.41.0-interface-snapshot.json +++ b/docs/release/v0.41.0-interface-snapshot.json @@ -115,6 +115,8 @@ "exports": { ".": "./src/index.ts", "./cli/build": "./src/cli/build.ts", + "./cli/preview": "./src/cli/preview.ts", + "./cli/start": "./src/cli/start.ts", "./nitro-mount": "./src/nitro-mount.ts", "./sitemap": "./src/sitemap.ts" }, @@ -135,6 +137,14 @@ "sha256": "19b817d8d114f0b107b7708675754de3fdc12c76c85c4dd3333814b6643894bd", "publicDeclarations": [] }, + "./cli/preview": { + "sha256": "207adce03deb976db737773bd957e65af6bf5675b460c1121e77203181c06f65", + "publicDeclarations": [] + }, + "./cli/start": { + "sha256": "968339216c37c775eb83672ca3b61eebd8e8ff974c6ed019ce50fccbf1f78957", + "publicDeclarations": [] + }, "./nitro-mount": { "sha256": "33056a4b4e9c291ea90532ad68268aaa9ad7be1a7f4897986847bd4e54926c47", "publicDeclarations": [ diff --git a/docs/roadmap/ROADMAP.md b/docs/roadmap/ROADMAP.md index 1bdec0d7c..6a4c59e6d 100644 --- a/docs/roadmap/ROADMAP.md +++ b/docs/roadmap/ROADMAP.md @@ -7,9 +7,10 @@ Execution and release state follow the > Published package line: `v0.42.0-alpha.6`.\ > Active execution target: `v0.42.0-alpha.6`.\ > Current implementation state: five-package convergence is published; -> alpha.17 closed the first audit remediation, alpha.18 completed the -> second audit sweep (ADR-0117), and alpha.19 completed the third (ADR-0118).\ -> Planned line: `0.42.0` (WC Application Loop) under ADR-0120 and the active +> 0.42 alphas through alpha.6 shipped the loop; alpha.7 closes the +> light-fullstack floor (morph residuals, start path, SSG fail-closed, +> CSRF default, honest tags).\ +> Planned line: `0.42.0` (WC light fullstack) under ADR-0120 and the active > version plan in `docs/current/VERSION_PLAN.md`.\ > Maturity stage: stable (0.41.x interface freeze under ADR-0119); the > abandoned beta naming is retired. @@ -81,23 +82,23 @@ making the standard Custom Element contract span both layers. See the official ## Forward versions -| Version | Theme | Required evidence | -| ----------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `0.41.0-alpha.10` | Five-package convergence | Exact published CLI lifecycle; browser gates; npm, tag, GitHub Release, docs and evidence agree | -| `0.41.0-alpha.11` | Audit remediation | Runtime regressions, Workers, artifacts, protocol seam, coverage, and two-phase release truth | -| `0.41.0-alpha.14` | Release recovery | Exact-version starter, published consumers and honest two-stage evidence | -| `0.41.0-alpha.15` | Adoption and interface proof | #390, current CI runtime, cross-platform consumers and stable-interface rehearsal | -| `0.41.0-alpha.16` | Correctness reset | ADR-0116 audit findings: hydration/props correctness, island chunk matching, npm `latest` policy, #460, drift clearance | -| `0.41.0-alpha.17` | Remediation completion | Real-browser test credibility, convergence hygiene, release-tooling evidence fixes, #390 pilot launch | -| `0.41.0-alpha.18` | Second audit sweep | ADR-0117: sibling-path closures, evidence honesty, reflect-prop correctness, redundancy cleanup | -| `0.41.0` | Core interface freeze | Five-package graph plus `defineElement`, `definePage`, `defineApp` and `buildApp` require no further architecture-level breaking change | -| `0.42.0` | WC Application Loop | Scoped by ADR-0120 and the active version plan: one route-to-interaction loop — load, DSD render, progressive form, action, error/redirect and revalidation; works without JavaScript | -| `0.43.0` | Universal WC SSR | CEM/admission information, DSD/light/client-only classification, native/Lit/FAST/Stencil corpus and hydration-mismatch diagnostics | -| `0.44.0` | Production Runtime | Node and Workers behavior, stream/abort/timeout, cache/ISR/SWR, deploy manifests, version-skew handling and recovery proof | -| `0.45.0` | WC Ecosystem Platform | Component and application starters, CEM/open-wc workflows, migration guides, compatibility registry and external adopters | -| `0.46.0` | v1 Product Freeze | Remove unused exports, decide the UI commitment, freeze errors, browser/runtime support and upgrade policy | -| `1.0.0-rc.x` | Stability only | No new capability; compatibility, reliability and documentation fixes only | -| `1.0.0` | Stable five-package product | External production users prove that the core interfaces are stable | +| Version | Theme | Required evidence | +| ----------------- | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0.41.0-alpha.10` | Five-package convergence | Exact published CLI lifecycle; browser gates; npm, tag, GitHub Release, docs and evidence agree | +| `0.41.0-alpha.11` | Audit remediation | Runtime regressions, Workers, artifacts, protocol seam, coverage, and two-phase release truth | +| `0.41.0-alpha.14` | Release recovery | Exact-version starter, published consumers and honest two-stage evidence | +| `0.41.0-alpha.15` | Adoption and interface proof | #390, current CI runtime, cross-platform consumers and stable-interface rehearsal | +| `0.41.0-alpha.16` | Correctness reset | ADR-0116 audit findings: hydration/props correctness, island chunk matching, npm `latest` policy, #460, drift clearance | +| `0.41.0-alpha.17` | Remediation completion | Real-browser test credibility, convergence hygiene, release-tooling evidence fixes, #390 pilot launch | +| `0.41.0-alpha.18` | Second audit sweep | ADR-0117: sibling-path closures, evidence honesty, reflect-prop correctness, redundancy cleanup | +| `0.41.0` | Core interface freeze | Five-package graph plus `defineElement`, `definePage`, `defineApp` and `buildApp` require no further architecture-level breaking change | +| `0.42.0` | WC light fullstack | ADR-0120 loop (load → DSD → form → action → revalidate, no-JS) + first-mile `start`, fail-closed SSG, default CSRF same-origin; login via recipes (better-auth), not framework session | +| `0.43.0` | Universal WC SSR | CEM/admission information, DSD/light/client-only classification, native/Lit/FAST/Stencil corpus and hydration-mismatch diagnostics | +| `0.44.0` | Production Runtime | Framework session/flash floor, stream/abort/timeout, cache/ISR/SWR, deploy manifests, version-skew handling and recovery proof — not a prerequisite for recipe-based login on 0.42 | +| `0.45.0` | WC Ecosystem Platform | Component and application starters, CEM/open-wc workflows, migration guides, compatibility registry and external adopters | +| `0.46.0` | v1 Product Freeze | Remove unused exports, decide the UI commitment, freeze errors, browser/runtime support and upgrade policy | +| `1.0.0-rc.x` | Stability only | No new capability; compatibility, reliability and documentation fixes only | +| `1.0.0` | Stable five-package product | External production users prove that the core interfaces are stable | ## Roadmap rules @@ -105,10 +106,12 @@ making the standard Custom Element contract span both layers. See the official - A public adapter seam requires two real adapters or an ADR backed by runtime isolation, dependency-cycle, artifact-size or independent-consumer evidence. - Auth, OAuth, ORM, databases and storage remain recipes. OpenElement owns the - application contract, not those service products. -- Session and cache may become App behavior only when the Application Loop or - Production Runtime has a proven semantic need; they do not get speculative - packages. + application contract, not those service products. Signed-in apps on 0.42 use + recipes (e.g. better-auth on Web-standard `Request`); they do not wait for + framework session APIs. +- Framework session and cache become App behavior in 0.44 when Production + Runtime has a proven semantic need; they do not get speculative packages and + must not be confused with “login is impossible before 0.44.” - `@openelement/ui` must have two non-site consumers by v0.46 or shrink to proven primitives outside the v1 compatibility promise. - A feature is complete only when starter, docs, packed artifacts, dogfood and diff --git a/docs/status/STATUS.md b/docs/status/STATUS.md index 6f3807774..9e93f4ed1 100644 --- a/docs/status/STATUS.md +++ b/docs/status/STATUS.md @@ -1,12 +1,12 @@ # OpenElement Status -> Updated: 2026-07-27\ +> Updated: 2026-07-28\ > Repository package line: `v0.42.0-alpha.6`\ > npm registry line: `v0.42.0-alpha.6`\ > Active release target: `v0.42.0-alpha.6`\ -> Next release line: `v0.42.0`\ +> Next release line: `v0.42.0` (WC light fullstack)\ > Product graph: five packages\ -> Current maturity stage: stable (0.41.x) +> Current maturity stage: stable (0.41.x); 0.42 alpha in flight ## Current position @@ -52,31 +52,39 @@ and output path. 2. Current-document version drift keeps reappearing at gate edges; alpha.18 package B makes the anchor gates reject stale claims. 3. WC SSR compatibility still needs broader adopter evidence. -4. Request-time data, forms, sessions and cache are not stable interfaces. -5. UI remains optional until v0.46 records its stable scope. +4. Request-time loop is implemented through alpha.6 but not yet a light-fullstack + floor (morph residuals, start path, SSG fail-closed, CSRF default, honest + dist-tags) — active train `0.42.0-alpha.7` / TP-5.7. +5. Framework session and cache remain unfrozen until 0.44; login apps use + recipes (better-auth) and must not wait on framework session APIs. +6. UI remains optional until v0.46 records its stable scope. ## 0.42 direction -The `0.42.0` WC Application Loop line is scoped by -[`ADR-0120`](../adr/ADR-0120-0-42-0-wc-application-loop-scope.md) and planned -in [`VERSION_PLAN.md`](../current/VERSION_PLAN.md) with task packages TP-0 -through TP-6 (entry/exit criteria each). The protocol layer is -evidence-backed by the archived six-framework study -([`docs/audit/2026-07-27-application-loop-framework-research/`](../audit/2026-07-27-application-loop-framework-research/README.md)). -TP-0 (release tooling self-repair, `0.41.2` patch) may start immediately; -alpha.1 starts after it. +The `0.42.0` line is **WC light fullstack**: Application Loop (ADR-0120) plus +first-mile ops and CSRF floor. Planned in +[`VERSION_PLAN.md`](../current/VERSION_PLAN.md) (TP-0…TP-6; active **TP-5.7** +alpha.7). Protocol evidence: +[`docs/audit/2026-07-27-application-loop-framework-research/`](../audit/2026-07-27-application-loop-framework-research/README.md). + +Product promise at `0.42.0`: dynamic loader/action, no-JS + enhanced forms, +`build → start`, fail-closed static prerender, default same-origin CSRF, +login via recipe on `Request`. **Not** in 0.42: framework session/flash, +cache/ISR, auth packages (0.44 or recipes). ## Release direction -| Version | Focus | -| ----------------- | ------------------------------------------------ | -| `0.41.0-alpha.17` | First audit remediation baseline (ADR-0116) | -| `0.41.0-alpha.18` | Second audit sweep (ADR-0117) | -| `0.41.0-alpha.19` | Third audit cleanup sweep (ADR-0118) | -| `0.41.0` | Separate stable decision after alpha.19 evidence | -| `0.42.0` | WC Application Loop | -| `0.43.0` | Universal WC SSR compatibility and diagnostics | -| `1.0.0` | Stable five-package product | +| Version | Focus | +| ----------------- | -------------------------------------------------- | +| `0.41.0-alpha.17` | First audit remediation baseline (ADR-0116) | +| `0.41.0-alpha.18` | Second audit sweep (ADR-0117) | +| `0.41.0-alpha.19` | Third audit cleanup sweep (ADR-0118) | +| `0.41.0` | Separate stable decision after alpha.19 evidence | +| `0.42.0-alpha.7` | Light-fullstack floor + audit round 3 (TP-5.7) | +| `0.42.0` | WC light fullstack (Application Loop freeze) | +| `0.43.0` | Universal WC SSR compatibility and diagnostics | +| `0.44.0` | Production runtime (session/cache/deploy recovery) | +| `1.0.0` | Stable five-package product | ## Evidence and workflow diff --git a/packages/adapter-vite/__fixtures__/request-time/app/routes/form.tsx b/packages/adapter-vite/__fixtures__/request-time/app/routes/form.tsx index 6274b2551..d2c69d5d5 100644 --- a/packages/adapter-vite/__fixtures__/request-time/app/routes/form.tsx +++ b/packages/adapter-vite/__fixtures__/request-time/app/routes/form.tsx @@ -25,13 +25,13 @@ export function action(ctx: { formData: FormData }): OpenElementActionFailure({ diff --git a/packages/adapter-vite/__fixtures__/request-time/app/routes/items.tsx b/packages/adapter-vite/__fixtures__/request-time/app/routes/items.tsx index 1976501f3..51ae9516f 100644 --- a/packages/adapter-vite/__fixtures__/request-time/app/routes/items.tsx +++ b/packages/adapter-vite/__fixtures__/request-time/app/routes/items.tsx @@ -15,13 +15,13 @@ export function loader(ctx: { request: Request }): { items: string[] } { export function action(ctx: { formData: FormData }): never { const items = String(ctx.formData.get('items') ?? 'a,b'); - redirect(`/items?items=${encodeURIComponent('new,' + items)}`); + throw redirect(`/items?items=${encodeURIComponent('new,' + items)}`); } export const actions = { reverse(ctx: { formData: FormData }): never { const items = String(ctx.formData.get('items') ?? 'a,b').split(','); - redirect(`/items?items=${encodeURIComponent(items.reverse().join(','))}`); + throw redirect(`/items?items=${encodeURIComponent(items.reverse().join(','))}`); }, }; diff --git a/packages/adapter-vite/__fixtures__/request-time/app/routes/ping.tsx b/packages/adapter-vite/__fixtures__/request-time/app/routes/ping.tsx index 60dec5dc0..aebecda23 100644 --- a/packages/adapter-vite/__fixtures__/request-time/app/routes/ping.tsx +++ b/packages/adapter-vite/__fixtures__/request-time/app/routes/ping.tsx @@ -31,7 +31,7 @@ export const actions = { // Success returns nothing: the default PRG target applies (ADR-0121 §4). }, mv307(): never { - redirect('/ping?moved=1', 307); + throw redirect('/ping?moved=1', 307); }, raw(): Response { return new Response('

raw

', { diff --git a/packages/adapter-vite/__fixtures__/request-time/app/routes/regions.tsx b/packages/adapter-vite/__fixtures__/request-time/app/routes/regions.tsx index 38e940dda..44413e5ff 100644 --- a/packages/adapter-vite/__fixtures__/request-time/app/routes/regions.tsx +++ b/packages/adapter-vite/__fixtures__/request-time/app/routes/regions.tsx @@ -27,7 +27,7 @@ export function action( if (!message) { return fail(422, { error: 'message is required', message } satisfies RegionActionData); } - redirect(`/regions?echoed=${encodeURIComponent(message)}`); + throw redirect(`/regions?echoed=${encodeURIComponent(message)}`); } const RegionsPage = definePage({ diff --git a/packages/adapter-vite/__fixtures__/request-time/app/routes/register.tsx b/packages/adapter-vite/__fixtures__/request-time/app/routes/register.tsx index 1c24abaf2..dd1f330bc 100644 --- a/packages/adapter-vite/__fixtures__/request-time/app/routes/register.tsx +++ b/packages/adapter-vite/__fixtures__/request-time/app/routes/register.tsx @@ -30,7 +30,7 @@ export function action(ctx: { formData: FormData }): OpenElementActionFailure = { }; const serverEntry = await import(pathToFileURL(join(ROOT, 'server/index.js')).href); +type RequestTimeEvent = { request: Request; env?: Record }; const handleRequestTime = serverEntry.default as ( - event: { request: Request }, + event: RequestTimeEvent, ) => Promise; // Generated dispatch (#556): matches concrete pathnames ('/item/42') against // the request-time route patterns ('/item/:id') baked into the server entry. @@ -78,7 +79,7 @@ async function serveStatic(pathname: string): Promise { Deno.serve({ port: PORT, hostname: '127.0.0.1' }, async (request) => { const url = new URL(request.url); if (matchRequestTimeRoute(url.pathname) !== null) { - return await handleRequestTime({ request }); + return await handleRequestTime({ request, env: Deno.env.toObject() }); } const staticResponse = await serveStatic(url.pathname); if (staticResponse) return staticResponse; diff --git a/packages/adapter-vite/__tests__/entry-generators.test.ts b/packages/adapter-vite/__tests__/entry-generators.test.ts index 765441cbb..2b7b83c6b 100644 --- a/packages/adapter-vite/__tests__/entry-generators.test.ts +++ b/packages/adapter-vite/__tests__/entry-generators.test.ts @@ -276,6 +276,12 @@ Deno.test('client entry includes the ADR-0120 form enhancement layer', () => { assert(code.includes('new FormData(form, submitter)')); assert(code.includes("window.addEventListener('popstate'")); assert(code.includes('result.status === 200 || result.status === 422')); + // #598: never use form.action IDL (name="action" trap) + assert(code.includes("form.getAttribute('action')")); + assert(!/form\.action\s*\|\|/.test(code)); + // #599: per-form sequence, not global last-wins + assert(code.includes('form.__openElementSeq')); + assert(!code.includes('var __submitSeq')); }); Deno.test('islands without enhancedForms omit the enhancement layer (#569 complement)', () => { @@ -289,4 +295,20 @@ Deno.test('islands without enhancedForms omit the enhancement layer (#569 comple assert(!code.includes('__attachSubmit')); assert(!code.includes("window.addEventListener('popstate'")); assert(code.includes('the form enhancement layer is omitted')); + // #597: __load must not reference __scanSubmitRoots when the enhance + // layer is omitted — that symbol is only defined inside the enhance block. + assert(!code.includes('__scanSubmitRoots')); +}); + +Deno.test('#597/#584 late-hydrate rescan only when enhancedForms', () => { + const withEnhance = generateClientEntry( + [{ tagName: 'x-counter', modulePath: './counter.ts', strategy: 'load' }], + { enhancedForms: true }, + ); + assert(withEnhance.includes('__scanSubmitRoots(document)')); + const without = generateClientEntry( + [{ tagName: 'x-counter', modulePath: './counter.ts', strategy: 'load' }], + { enhancedForms: false }, + ); + assert(!without.includes('__scanSubmitRoots')); }); diff --git a/packages/adapter-vite/__tests__/entry-renderer.test.ts b/packages/adapter-vite/__tests__/entry-renderer.test.ts index 89abfa2cf..8e9c0018e 100644 --- a/packages/adapter-vite/__tests__/entry-renderer.test.ts +++ b/packages/adapter-vite/__tests__/entry-renderer.test.ts @@ -822,6 +822,13 @@ Deno.test('renderEntry: ADR-0121 hardening is present in the action codegen', () const desc = buildEntryDescriptor(basicRoutes, {}); const code = renderEntry(desc); + // #611: default same-origin CSRF floor on generated action POST + assertStringIncludes(code, 'sec-fetch-site'); + assertStringIncludes(code, 'cross-site'); + assertStringIncludes(code, 'OPEN_ELEMENT_DISABLE_CSRF'); + assertStringIncludes(code, 'Cross-site form submission rejected'); + assertStringIncludes(code, '__loadContext.env'); + // #542: named-action dispatch is own-key gated (prototype keys are 404). assertStringIncludes(code, 'Object.prototype.hasOwnProperty.call(__namedActions, __actionName)'); // #541: a returned Response is a contract violation, never a response. diff --git a/packages/adapter-vite/__tests__/ssg-render.test.ts b/packages/adapter-vite/__tests__/ssg-render.test.ts index 8dac21030..35fecc44a 100644 --- a/packages/adapter-vite/__tests__/ssg-render.test.ts +++ b/packages/adapter-vite/__tests__/ssg-render.test.ts @@ -168,7 +168,7 @@ Deno.test('ssgRender - handles options with speculation enabled', async () => { // ─── alpha.18 R2-H3: static-route non-200 outcomes ───────────── -Deno.test('ssgRender - static non-200 routes surface in the build summary and are not written', async () => { +Deno.test('ssgRender - static non-200 routes fail the build (#600)', async () => { const outDir = './dist-test-ssg-render-non200'; await Deno.remove(outDir, { recursive: true }).catch(() => {}); const app = new Hono(); @@ -186,45 +186,23 @@ Deno.test('ssgRender - static non-200 routes surface in the build summary and ar ], }); - const warnings: string[] = []; - const originalWarn = console.warn; - console.warn = (...args: unknown[]) => { - warnings.push(args.map(String).join(' ')); - }; - let summary; + let err: unknown; try { - summary = await ssgRender(bundle, { ...defaultOptions, outDir }); - } finally { - console.warn = originalWarn; - } - - // The summary lists every non-200 static route with its status. - const non200 = new Map(summary.staticNon200.map((r) => [r.path, r.status])); - assertEquals(non200.get('/missing'), 404); - assertEquals(non200.get('/boom'), 500); - assertEquals(non200.get('/moved'), 302); - assertEquals(non200.has('/'), false); - assertEquals(summary.staticNon200.length, 3); - - // The build log surfaces the same count + paths. - const summaryLine = warnings.find((w) => w.includes('non-200')); - assert(summaryLine !== undefined, 'expected a non-200 summary warning in the build log'); - assert(summaryLine.includes('3'), 'summary must include the non-200 count'); - for (const path of ['/missing', '/boom', '/moved']) { - assert( - warnings.some((w) => w.includes(path)), - `summary must list ${path}`, - ); + await ssgRender(bundle, { ...defaultOptions, outDir }); + } catch (e) { + err = e; } + assert(err instanceof Error, 'expected SSG to throw on static non-200'); + assert(String(err).includes('non-200'), 'error must mention non-200'); + assert(String(err).includes('/missing'), 'error must list failing paths'); + assert(String(err).includes('/boom')); + assert(String(err).includes('/moved')); - // Non-200 pages are not persisted; the 200 page is. - assertEquals(await pathExists(`${outDir}/index.html`), true); + // Non-200 pages are not persisted; the 200 page may already be written. assertEquals(await pathExists(`${outDir}/missing.html`), false); assertEquals(await pathExists(`${outDir}/missing/index.html`), false); assertEquals(await pathExists(`${outDir}/boom.html`), false); assertEquals(await pathExists(`${outDir}/boom/index.html`), false); - assertEquals(await pathExists(`${outDir}/moved.html`), false); - assertEquals(await pathExists(`${outDir}/moved/index.html`), false); await Deno.remove(outDir, { recursive: true }).catch(() => {}); }); diff --git a/packages/adapter-vite/deno.json b/packages/adapter-vite/deno.json index b527a9bf7..c03071071 100644 --- a/packages/adapter-vite/deno.json +++ b/packages/adapter-vite/deno.json @@ -5,7 +5,9 @@ ".": "./src/index.ts", "./nitro-mount": "./src/nitro-mount.ts", "./sitemap": "./src/sitemap.ts", - "./cli/build": "./src/cli/build.ts" + "./cli/build": "./src/cli/build.ts", + "./cli/start": "./src/cli/start.ts", + "./cli/preview": "./src/cli/preview.ts" }, "imports": { "@mdx-js/rollup": "npm:@mdx-js/rollup@^3.1.1", diff --git a/packages/adapter-vite/src/cli/preview.ts b/packages/adapter-vite/src/cli/preview.ts new file mode 100644 index 000000000..056d246d7 --- /dev/null +++ b/packages/adapter-vite/src/cli/preview.ts @@ -0,0 +1,36 @@ +/** + * @openelement/adapter-vite - CLI: preview built output + * + * #601: `vite preview` is static-only and silently wrong for dynamic routes. + * If dist/server exists, refuse and point at `start`. Otherwise serve static. + */ + +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import process from 'node:process'; + +const distDir = join(process.cwd(), 'dist'); +const serverEntry = join(distDir, 'server', 'index.js'); + +if (import.meta.main) { + if (!existsSync(distDir)) { + console.error('[openElement preview] dist/ not found. Run `deno task build` first.'); + process.exit(1); + } + if (existsSync(serverEntry)) { + console.error( + '[openElement preview] This project has request-time routes (dist/server).\n' + + ' `vite preview` cannot serve dynamic loader/action routes.\n' + + ' Use: deno task start\n' + + ' (or: deno run -A npm:@openelement/adapter-vite/cli/start)', + ); + process.exit(1); + } + // Static-only: delegate to vite preview + const { spawn } = await import('node:child_process'); + const child = spawn('deno', ['run', '-A', 'npm:vite', 'preview', ...process.argv.slice(2)], { + stdio: 'inherit', + shell: false, + }); + child.on('exit', (code) => process.exit(code ?? 1)); +} diff --git a/packages/adapter-vite/src/cli/start.ts b/packages/adapter-vite/src/cli/start.ts new file mode 100644 index 000000000..e8c52e41e --- /dev/null +++ b/packages/adapter-vite/src/cli/start.ts @@ -0,0 +1,140 @@ +/** + * @openelement/adapter-vite - CLI: start built app (static + request-time) + * + * #601: one-command path after `build` so dynamic routes are reachable + * without tribal Nitro wiring. + * + * Usage: + * deno run -A npm:@openelement/adapter-vite/cli/start + * OPEN_ELEMENT_PORT=4173 deno task start + */ + +import { existsSync, readFileSync, statSync } from 'node:fs'; +import { extname, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import process from 'node:process'; + +const root = process.cwd(); +const distDir = join(root, 'dist'); +const serverEntry = join(distDir, 'server', 'index.js'); +const port = Number(process.env.OPEN_ELEMENT_PORT || process.env.PORT || 4173); +const hostname = process.env.OPEN_ELEMENT_HOST || '0.0.0.0'; + +const MIME: Record = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.webp': 'image/webp', + '.woff2': 'font/woff2', + '.txt': 'text/plain; charset=utf-8', +}; + +function contentType(filePath: string): string { + return MIME[extname(filePath).toLowerCase()] || 'application/octet-stream'; +} + +function tryStatic(pathname: string): Response | null { + const decoded = decodeURIComponent(pathname.split('?')[0] || '/'); + const rel = decoded === '/' ? 'index.html' : decoded.replace(/^\//, ''); + const candidates = [ + join(distDir, rel), + join(distDir, rel, 'index.html'), + rel.endsWith('.html') ? null : join(distDir, `${rel}.html`), + ].filter((p): p is string => p != null); + + for (const filePath of candidates) { + if (!filePath.startsWith(distDir)) continue; + if (!existsSync(filePath) || !statSync(filePath).isFile()) continue; + const body = readFileSync(filePath); + return new Response(body, { + status: 200, + headers: { 'content-type': contentType(filePath) }, + }); + } + return null; +} + +type ServerModule = { + default?: (event: { request: Request }) => Promise<{ response: Response }>; + matchRequestTimeRoute?: (pathname: string) => unknown; +}; + +async function main(): Promise { + if (!existsSync(distDir)) { + console.error( + '[openElement start] dist/ not found. Run `deno task build` first.', + ); + process.exit(1); + } + + let serverMod: ServerModule | null = null; + if (existsSync(serverEntry)) { + serverMod = await import(pathToFileURL(serverEntry).href) as ServerModule; + if (typeof serverMod.default !== 'function') { + console.error( + '[openElement start] dist/server/index.js has no default export.', + ); + process.exit(1); + } + console.log( + '[openElement start] request-time server entry loaded (dynamic routes enabled)', + ); + } else { + console.log( + '[openElement start] no dist/server — static-only preview', + ); + } + + Deno.serve({ port, hostname }, async (request) => { + const url = new URL(request.url); + + if (serverMod?.default) { + const match = serverMod.matchRequestTimeRoute?.(url.pathname); + const isMutating = request.method !== 'GET' && request.method !== 'HEAD'; + if (match || isMutating) { + try { + const result = await serverMod.default({ request }); + return result.response; + } catch (err) { + console.error('[openElement start] request-time handler error:', err); + return new Response('Internal Server Error', { status: 500 }); + } + } + } + + const staticResponse = tryStatic(url.pathname); + if (staticResponse) return staticResponse; + + if (serverMod?.default) { + try { + const result = await serverMod.default({ request }); + return result.response; + } catch (err) { + console.error('[openElement start] request-time handler error:', err); + return new Response('Internal Server Error', { status: 500 }); + } + } + + return new Response('Not Found', { status: 404 }); + }); + + console.log( + `[openElement start] http://${hostname === '0.0.0.0' ? 'localhost' : hostname}:${port}`, + ); +} + +if (import.meta.main) { + try { + await main(); + } catch (error) { + console.error( + `Start failed: ${error instanceof Error ? error.stack ?? error.message : String(error)}`, + ); + process.exit(1); + } +} diff --git a/packages/adapter-vite/src/generated-export-files.ts b/packages/adapter-vite/src/generated-export-files.ts index 0932065ca..597a000ba 100644 --- a/packages/adapter-vite/src/generated-export-files.ts +++ b/packages/adapter-vite/src/generated-export-files.ts @@ -5,6 +5,8 @@ export const OPENELEMENT_EXPORT_FILES: Record> = 'adapter-vite': { '.': 'src/index.ts', 'cli/build': 'src/cli/build.ts', + 'cli/preview': 'src/cli/preview.ts', + 'cli/start': 'src/cli/start.ts', 'nitro-mount': 'src/nitro-mount.ts', 'sitemap': 'src/sitemap.ts', }, diff --git a/packages/adapter-vite/src/internal/ssg/entry-generators.ts b/packages/adapter-vite/src/internal/ssg/entry-generators.ts index 438dd81fc..f652dc127 100644 --- a/packages/adapter-vite/src/internal/ssg/entry-generators.ts +++ b/packages/adapter-vite/src/internal/ssg/entry-generators.ts @@ -175,9 +175,14 @@ var __tags = [${tags}]; function __load(tag) { if (__map[tag]) { __map[tag]().then(function () { - // #584: late-hydrating islands create their shadow roots after the +${ + options.enhancedForms === true + ? ` // #584: late-hydrating islands create their shadow roots after the // ready-time scan; rescan so enhanced forms inside them are heard. - setTimeout(function () { __scanSubmitRoots(document); }, 0); + // #597: only emit when the enhance layer defines __scanSubmitRoots. + setTimeout(function () { __scanSubmitRoots(document); }, 0);` + : ' // #597: no enhance layer — skip submit-root rescan' + } }).catch(function(e) { log.warn(tag, e); }); __map[tag] = null; } @@ -552,7 +557,6 @@ function __scanSubmitRoots(root) { } } -var __submitSeq = 0; // #578: the marker lives in sessionStorage so it survives a page reload // (a memory variable resets, and Back after a reload would show stale // content for the restored URL — the exact thing §10 forbids). @@ -576,17 +580,21 @@ function __onSubmit(event) { var method = (form.getAttribute('method') || 'get').toUpperCase(); if (method === 'GET') return; event.preventDefault(); - // #564: a second submit while one is in flight is ignored; cross-form - // responses are ordered by the sequence check below. + // #564: a second submit on the SAME form while one is in flight is ignored. + // #599: sequence is per-form so a concurrent submit on another form cannot + // silently drop this form's successful response (no global last-wins). if (form.__openElementBusy) return; form.__openElementBusy = true; - var seq = ++__submitSeq; + form.__openElementSeq = (form.__openElementSeq || 0) + 1; + var seq = form.__openElementSeq; var submitter = event.submitter; - // #576: the formAction IDL attribute returns the document URL when no - // formaction attribute is present, so it must only win when the attribute - // exists — otherwise form.action (the declared address) is ignored. + // #576: formAction IDL is the document URL when formaction is absent. + // #598: form.action IDL returns an element when + // present — always resolve the action attribute (or current URL). var actionUrl = (submitter && submitter.hasAttribute('formaction') && submitter.formAction) || - form.action || window.location.href; + (form.getAttribute('action') + ? new URL(form.getAttribute('action'), window.location.href).href + : window.location.href); // #544: the submitter's name/value is part of the body — the body never // differs between the two paths (ADR-0120 rule 2). var body = submitter ? new FormData(form, submitter) : new FormData(form); @@ -607,7 +615,7 @@ function __onSubmit(event) { }); }).then(function (result) { form.__openElementBusy = false; - if (seq !== __submitSeq) return; + if (seq !== form.__openElementSeq) return; var target = new URL(result.url, window.location.href); // #555: cross-origin targets are real navigations, never pushState. if (target.origin !== window.location.origin) { diff --git a/packages/adapter-vite/src/internal/ssg/entry-render-helpers.ts b/packages/adapter-vite/src/internal/ssg/entry-render-helpers.ts index 398a78734..e91dc4ec5 100644 --- a/packages/adapter-vite/src/internal/ssg/entry-render-helpers.ts +++ b/packages/adapter-vite/src/internal/ssg/entry-render-helpers.ts @@ -220,6 +220,29 @@ export function renderRouteHandler( // (HTML responses identical to the no-JS path). lines.push(` const __actionHeader = c.req.header('x-openelement-action');`); lines.push(` __isFetch = __actionHeader === 'true';`); + // #611 / ADR-0121 §12 (amended): default same-origin CSRF floor for + // browser POSTs. Non-browser clients that omit Origin and Sec-Fetch-Site + // are allowed. Opt out via runtime env on the request context: + // c.env.OPEN_ELEMENT_DISABLE_CSRF === '1' (Workers/Node bindings). + lines.push(` {`); + lines.push( + ` const __csrfOff = __loadContext.env && __loadContext.env.OPEN_ELEMENT_DISABLE_CSRF === '1';`, + ); + lines.push(` if (!__csrfOff) {`); + lines.push(` const __origin = c.req.header('origin');`); + lines.push(` const __sfs = (c.req.header('sec-fetch-site') || '').toLowerCase();`); + lines.push(` let __cross = __sfs === 'cross-site';`); + lines.push( + ` if (!__cross && __origin) { try { __cross = new URL(__origin).origin !== new URL(c.req.url).origin; } catch { __cross = true; } }`, + ); + lines.push(` if (__cross) {`); + lines.push( + ` if (__isFetch) return c.json({ type: 'error', status: 403, error: { message: 'Cross-site form submission rejected' } }, 403);`, + ); + lines.push(` return c.text('Forbidden', 403);`); + lines.push(` }`); + lines.push(` }`); + lines.push(` }`); lines.push(` if (typeof __actionFn !== 'function') {`); lines.push( ` const __noActionMessage = __actionName !== undefined ? 'No action named "' + __actionName + '" on this route.' : 'This route does not accept submissions.';`, diff --git a/packages/adapter-vite/src/internal/ssg/ssg-render.ts b/packages/adapter-vite/src/internal/ssg/ssg-render.ts index a0ccb4e3a..7e9ad69a6 100644 --- a/packages/adapter-vite/src/internal/ssg/ssg-render.ts +++ b/packages/adapter-vite/src/internal/ssg/ssg-render.ts @@ -215,13 +215,21 @@ export async function ssgRender( ); } - if (staticNon200.length > 0) { - log.warn( - `Static route non-200 results: ${staticNon200.length} page(s) dropped (not written):`, + // #600: only non-200 for known page routes (in routeInfo) fail the build. + // API routes registered on the Hono app are not page routes — their non-200 + // status does not mean missing content. The same routeInfo-filter avoids + // hard-coding path conventions like /api/ or /_data. + const pagePaths = new Set(routeInfo.map((r) => r.path)); + const pageNon200 = staticNon200.filter((r) => pagePaths.has(r.path)); + if (pageNon200.length > 0) { + const detail = pageNon200.map((e) => `${e.path} -> ${e.status}`).join(', '); + log.error( + `Static route non-200 results: ${pageNon200.length} page(s) dropped (not written): ${detail}`, + ); + throw new Error( + `SSG failed: ${pageNon200.length} static route(s) returned non-200 ` + + `(pages not written): ${detail}`, ); - for (const entry of staticNon200) { - log.warn(` ${entry.path} -> ${entry.status}`); - } } const isrRoutes = buildIsrManifestEntries(routeInfo, staticPathParamsByRoute); diff --git a/packages/create/__tests__/cli.test.ts b/packages/create/__tests__/cli.test.ts index 91d6035fe..7820cb22a 100644 --- a/packages/create/__tests__/cli.test.ts +++ b/packages/create/__tests__/cli.test.ts @@ -30,6 +30,7 @@ Deno.test('starter exposes only product imports and the standard lifecycle', () assertEquals(Object.keys(denoJson.imports).sort(), [ '@deno/vite-plugin', '@openelement/adapter-vite', + '@openelement/adapter-vite/nitro-mount', '@openelement/app', '@openelement/element', '@openelement/element/jsx-dev-runtime', @@ -45,7 +46,20 @@ Deno.test('starter exposes only product imports and the standard lifecycle', () denoJson.imports['@openelement/element/jsx-dev-runtime'], 'npm:@openelement/element@${v.element}/jsx-dev-runtime', ); - assertEquals(Object.keys(denoJson.tasks).sort(), ['build', 'check', 'dev', 'preview', 'test']); + assertEquals( + Object.keys(denoJson.tasks).sort(), + ['build', 'check', 'dev', 'preview', 'start', 'test'], + ); + assert( + String(denoJson.imports['@openelement/adapter-vite/nitro-mount'] || '').includes( + 'nitro-mount', + ), + 'starter import map must include adapter-vite/nitro-mount (#601)', + ); + assert( + String(denoJson.tasks.start || '').includes('cli/start'), + 'starter must expose deno task start (#601)', + ); assertEquals(denoJson.tasks.test, 'deno test --config deno.json --permit-no-files'); assertEquals(denoJson.imports.hono, 'npm:hono@^4.12'); assertEquals(denoJson.compilerOptions.jsxImportSource, '@openelement/element'); diff --git a/packages/create/templates/app/routes/contact.tsx b/packages/create/templates/app/routes/contact.tsx index cdf741995..d9c9a1322 100644 --- a/packages/create/templates/app/routes/contact.tsx +++ b/packages/create/templates/app/routes/contact.tsx @@ -24,7 +24,7 @@ export function action(ctx: { formData: FormData }): OpenElementActionFailure { assertEquals(bindings.size, 1); }); +Deno.test('#602 serializeAttrs rejects unsafe attribute names', () => { + const safe = serializeAttrs('div', { className: 'ok', 'data-x': '1', id: 'a' }); + assertStringIncludes(safe, 'class="ok"'); + assertStringIncludes(safe, 'data-x="1"'); + assertStringIncludes(safe, 'id="a"'); + + const evil = serializeAttrs('div', { + 'x" onclick="alert(1)" data-x': 'pwn', + 'onmouseover': 'alert(1)', + 'ok-name': 'yes', + }); + assertEquals(evil.includes('onclick'), false); + assertEquals(evil.includes('onmouseover'), false); + assertStringIncludes(evil, 'ok-name="yes"'); +}); + Deno.test('SSR rejects rendering beyond the nesting-depth limit', async () => { class RecursiveElement { render() { diff --git a/packages/element/src/internal/core/render-ir.ts b/packages/element/src/internal/core/render-ir.ts index 82084a1b5..b66b34033 100644 --- a/packages/element/src/internal/core/render-ir.ts +++ b/packages/element/src/internal/core/render-ir.ts @@ -106,6 +106,10 @@ const SKIP_ATTR_KEYS = new Set([ 'textContent', ]); +// #602: attribute *names* are not escaped — reject anything that cannot be a +// safe HTML attribute name (blocks quote/space/event-handler injection). +const SAFE_ATTR_NAME = /^[a-zA-Z_:][\w:.-]*$/; + export function serializeAttrs(tag: string, props: Record): string { const isCustomElement = tag.includes('-'); let result = ''; @@ -120,6 +124,9 @@ export function serializeAttrs(tag: string, props: Record): str if (isCustomElement && attrName === key) { attrName = camelToKebab(attrName); } + if (!SAFE_ATTR_NAME.test(attrName)) continue; + // Never emit HTML event-handler attributes from SSR props. + if (/^on/i.test(attrName)) continue; const resolved = unwrapSignalLike(value); diff --git a/tools/autoflow/policy.ts b/tools/autoflow/policy.ts index 30bf59080..3a0d5a28f 100644 --- a/tools/autoflow/policy.ts +++ b/tools/autoflow/policy.ts @@ -252,7 +252,9 @@ export const GATES: readonly GateDefinition[] = [ // fixed-ref byte comparison meaningless as a gate. name: 'check:static-output-freeze', command: ['deno', 'task', 'check:static-output-freeze', '--self-check'], - tiers: ['release'], + // #600: missing-page / determinism assertion is a CI hard fail, not + // release-only theater. + tiers: ['ci', 'release'], triggers: [/^packages\//, /^www\//, /^deno\.json$/], }, { diff --git a/tools/consumer-local.ts b/tools/consumer-local.ts index e537bded3..54d70d452 100644 --- a/tools/consumer-local.ts +++ b/tools/consumer-local.ts @@ -133,6 +133,7 @@ const generatedImportMap = { ...denoJson.imports } as Record; const productImports = [ '@deno/vite-plugin', '@openelement/adapter-vite', + '@openelement/adapter-vite/nitro-mount', '@openelement/app', '@openelement/element', '@openelement/element/jsx-dev-runtime', diff --git a/tools/lib/npm-release-verifier.test.ts b/tools/lib/npm-release-verifier.test.ts index 31b527d22..bf6a8e3a2 100644 --- a/tools/lib/npm-release-verifier.test.ts +++ b/tools/lib/npm-release-verifier.test.ts @@ -28,28 +28,22 @@ Deno.test('verifyNpmRelease retries transient registry misses and verifies the m '@openelement/element@0.41.0-alpha.13:version', '@openelement/element@0.41.0-alpha.13:version', '@openelement/element:dist-tags.alpha', - '@openelement/element:dist-tags.latest', ]); assertEquals(sleeps, [1, 2]); }); -Deno.test('verifyNpmRelease rejects a latest dist-tag that lags the release line', async () => { - // Policy: dist-tags.latest must equal the just-published version. A stale - // latest (e.g. the historical latest = alpha.6 drift) fails verification. - await assertRejects( - () => - verifyNpmRelease({ - version: '0.41.0-alpha.13', - packages: ['element'], - delaysMs: [0, 1], - sleep: () => Promise.resolve(), - query: (_specifier, field) => - Promise.resolve(field === 'dist-tags.latest' ? '0.41.0-alpha.6' : '0.41.0-alpha.13'), - }), - Error, - '@openelement/element dist-tags.latest verification failed after 2 attempts: ' + - 'expected=0.41.0-alpha.13, observed=0.41.0-alpha.6', - ); +Deno.test('verifyNpmRelease does not require latest === prerelease (#607)', async () => { + // Prerelease only checks the alpha/beta/rc tag; latest may stay on stable. + await verifyNpmRelease({ + version: '0.41.0-alpha.13', + packages: ['element'], + delaysMs: [0], + sleep: () => Promise.resolve(), + query: (_specifier, field) => + Promise.resolve( + field === 'dist-tags.latest' ? '0.41.2' : '0.41.0-alpha.13', + ), + }); }); Deno.test('verifyNpmRelease reports the final observed state after exhausting retries', async () => { diff --git a/tools/lib/npm-release-verifier.ts b/tools/lib/npm-release-verifier.ts index c99b1feef..3081571a5 100644 --- a/tools/lib/npm-release-verifier.ts +++ b/tools/lib/npm-release-verifier.ts @@ -79,6 +79,8 @@ export async function verifyNpmRelease(options: VerifyNpmReleaseOptions): Promis runtime, ); if (tag) { + // #607: prerelease only requires its line tag (alpha/beta/rc). Do not + // require latest === prerelease — latest must remain on stable. await verifyField( `${packageName} dist-tags.${tag}`, packageName, @@ -86,24 +88,20 @@ export async function verifyNpmRelease(options: VerifyNpmReleaseOptions): Promis options.version, runtime, ); + options.log?.( + `${packageName}@${options.version}: ${tag} dist-tag verified (latest left on stable)`, + ); + } else { + await verifyField( + `${packageName} dist-tags.latest`, + packageName, + 'dist-tags.latest', + options.version, + runtime, + ); + options.log?.( + `${packageName}@${options.version}: latest dist-tag verified (stable)`, + ); } - // latest dist-tag policy: prerelease publishes also move `latest` (see - // tools/publish-npm.ts); stable publishes keep the npm default of tagging - // `latest`. Either way `latest` must equal the just-published version — - // it must never lag the active release line. - // Chosen invariant: dist-tags.latest === (exact match, - // not semver >=), so a stale `latest` fails verification immediately. - await verifyField( - `${packageName} dist-tags.latest`, - packageName, - 'dist-tags.latest', - options.version, - runtime, - ); - options.log?.( - tag - ? `${packageName}@${options.version}: ${tag} and latest dist-tags verified` - : `${packageName}@${options.version}: latest dist-tag verified (stable)`, - ); } } diff --git a/tools/publish-npm.test.ts b/tools/publish-npm.test.ts index f1d6e577b..8eb847d93 100644 --- a/tools/publish-npm.test.ts +++ b/tools/publish-npm.test.ts @@ -127,7 +127,7 @@ Deno.test('publishPackage skips an immutable version that already exists', async ]); }); -Deno.test('publishPackage moves the latest dist-tag after a prerelease publish', async () => { +Deno.test('publishPackage does not move latest after a prerelease publish (#607)', async () => { const published: string[][] = []; const publishIo: PublishPackageIo = { versionExists: () => Promise.resolve(false), @@ -140,18 +140,16 @@ Deno.test('publishPackage moves the latest dist-tag after a prerelease publish', await publishPackage(pkg('@openelement/element', '0.41.0-alpha.13'), false, publishIo); - assertEquals(published.length, 2); + assertEquals(published.length, 1); assertEquals(published[0].slice(0, 2), [ 'publish', 'packages/element/openelement-element-0.41.0-alpha.13.tgz', ]); assertEquals(published[0].slice(-2), ['--tag', 'alpha']); - assertEquals(published[1], [ - 'dist-tag', - 'add', - '@openelement/element@0.41.0-alpha.13', - 'latest', - ]); + assertEquals( + published.some((args) => args.includes('latest')), + false, + ); }); Deno.test('publishPackage leaves latest to the npm default for stable versions', async () => { diff --git a/tools/publish-npm.ts b/tools/publish-npm.ts index 09a8920bf..c4b67944d 100644 --- a/tools/publish-npm.ts +++ b/tools/publish-npm.ts @@ -260,15 +260,10 @@ export async function publishPackage( } throw error; } - // latest dist-tag policy (alpha line): every prerelease publish also points - // `latest` at the just-published version, so `latest` never lags the active - // prerelease line. tools/verify-npm-release.ts enforces the invariant by - // asserting dist-tags.latest === . Stable publishes need - // no explicit tag: npm defaults them to `latest`. - if (!dryRun && isPrerelease(pkg.version)) { - await io.publish(['dist-tag', 'add', `${pkg.name}@${pkg.version}`, 'latest']); - io.log(`[npm] ${pkg.name}@${pkg.version}: latest dist-tag updated.`); - } + // #607: prerelease publishes use --tag alpha|beta|rc only. Never move + // `latest` onto an alpha — `latest` stays on the last stable line so + // `npm install @openelement/*` does not land on prerelease by default. + // Stable publishes keep npm's default `latest` tag. } export function npmPublishTag(version: string): string { diff --git a/www/app/routes/architecture/benchmark.tsx b/www/app/routes/architecture/benchmark.tsx index fa6ffc154..57cf60a32 100644 --- a/www/app/routes/architecture/benchmark.tsx +++ b/www/app/routes/architecture/benchmark.tsx @@ -81,8 +81,9 @@ export default class Benchmark extends OpenElement {

Bundle Size

- openElement ships zero runtime JS for DSD components. Islands load on-demand by - strategy. No framework runtime in the critical path. + DSD components need no framework virtual DOM runtime. Client JS is emitted only when + islands or enhanced forms exist; pure-static pages stay script-free. Islands load + on-demand by strategy.