Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions apps/shell/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,16 @@
},
"dependencies": {
"@unself/contracts": "workspace:*",
"vue": "^3.5.0",
"vue-router": "^4.5.0",
"@unself/ui": "workspace:*",
"lucide-vue-next": "^0.544.0"
"lucide-vue-next": "^0.544.0",
"vue": "^3.5.0",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@vitejs/plugin-vue": "^6.0.0",
"@vue/test-utils": "^2.5.0",
"jsdom": "^27.4.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.9.0",
"vite": "^7.0.0",
Expand Down
166 changes: 166 additions & 0 deletions apps/shell/src/App.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
// SPDX-License-Identifier: AGPL-3.0-only
// @vitest-environment jsdom
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import App from './App.vue'
import { fetchMe } from './lib/session-api'
import { fetchEnabledModules } from './lib/registry-api'
import { fetchModuleToken } from './lib/token-api'

const { TOKEN, MODULE, REJECTED } = vi.hoisted(() => {
const TOKEN = {
token: 'tok-1',
expiresIn: 600,
claims: {
iss: 'unself',
sub: 'u1',
aud: 'hello',
iat: 1_700_000_000,
exp: 1_700_000_600,
},
}
/** enabled 模块:manifest.entry 同域路径(§5.3 单域名路径制)。 */
const MODULE = {
id: 'hello',
enabled: true,
version: '0.1.0',
manifest: {
id: 'hello',
route: '/m/hello/',
entry: '/m/hello/',
runtime: 'vue',
version: '0.1.0',
},
}
const REJECTED = Object.assign(new Error('登录已过期,请重新登录'), { status: 401 })
return { TOKEN, MODULE, REJECTED }
})

vi.mock('vue-router', () => ({
useRoute: () => ({ query: {} }),
useRouter: () => ({ push: vi.fn() }),
}))

vi.mock('./lib/session-api', () => ({
fetchMe: vi.fn().mockResolvedValue({
authenticated: true,
user: { id: 'u1', name: '黄一', issuer: 'unself', sub: 'u1' },
}),
loginUrl: (next?: string) => `/api/auth/login${next ? `?next=${encodeURIComponent(next)}` : ''}`,
logout: vi.fn().mockResolvedValue(undefined),
}))

vi.mock('./lib/registry-api', async (importOriginal) => {
const actual = await importOriginal<typeof import('./lib/registry-api')>()
return {
...actual,
fetchEnabledModules: vi.fn().mockResolvedValue([MODULE]),
}
})

vi.mock('./lib/token-api', () => ({
fetchModuleToken: vi.fn(),
}))

beforeEach(() => {
vi.clearAllMocks()
})

/** 冲刷挂载 → 拉注册表 → 选模块 → post-flush 挂桥整条异步链。 */
async function settle() {
await flushPromises()
await nextTick()
await flushPromises()
await nextTick()
await flushPromises()
}

/** 模拟 iframe 内 SDK 发 ready(真实 MessageEvent,走真实桥的消息监听)。 */
function dispatchReady(iframe: HTMLIFrameElement) {
const ev = new MessageEvent('message', {
data: { type: 'ready' },
origin: window.location.origin,
source: iframe.contentWindow,
})
window.dispatchEvent(ev)
}

// 注意:jsdom 中未挂入 document 的 iframe 共享同一 contentWindow,
// 因此每个用例必须 wrapper.unmount()(触发 onBeforeUnmount 拆桥),
// 否则上一个用例的桥监听器会截获下一个用例的 ready 事件。
describe('App.vue 模块桥挂载时机(#71 根因 2)', () => {
it('桥在 iframe 挂载后 attach:ready → token → frameState ready(不误判配置无效)', async () => {
vi.mocked(fetchModuleToken).mockResolvedValue(TOKEN)
vi.mocked(fetchMe).mockResolvedValue({
authenticated: true,
user: { id: 'u1', name: '黄一', issuer: 'unself', sub: 'u1' },
})
vi.mocked(fetchEnabledModules).mockResolvedValue([MODULE])

const wrapper = mount(App)
await settle()

const iframeEl = wrapper.find('iframe')
expect(iframeEl.exists()).toBe(true)
// 落地规则:直访 / 落第一个启用模块 → hello 进入握手期(骨架可见、帧隐藏)
expect(iframeEl.attributes('src')).toBe('/m/hello/')
expect(iframeEl.classes()).toContain('shell-frame-hidden')
expect(wrapper.text()).not.toContain('模块入口配置无效')

dispatchReady(iframeEl.element as HTMLIFrameElement)
await settle()

// 行为断言:真实桥收到了 ready → 以 'hello' 请求 token → 下发 → 帧就位
expect(fetchModuleToken).toHaveBeenCalledTimes(1)
expect(fetchModuleToken).toHaveBeenCalledWith('hello')
expect(wrapper.find('iframe').classes()).not.toContain('shell-frame-hidden')
expect(wrapper.text()).not.toContain('模块加载失败')
expect(wrapper.text()).not.toContain('模块入口配置无效')

wrapper.unmount()
})

it('retry 强制 iframe 重挂(key 变化 → DOM 替换)后对新帧重新挂桥', async () => {
vi.mocked(fetchModuleToken)
.mockRejectedValueOnce(REJECTED)
.mockResolvedValue(TOKEN)
vi.mocked(fetchMe).mockResolvedValue({
authenticated: true,
user: { id: 'u1', name: '黄一', issuer: 'unself', sub: 'u1' },
})
vi.mocked(fetchEnabledModules).mockResolvedValue([MODULE])

const wrapper = mount(App)
await settle()

const before = wrapper.find('iframe').element as HTMLIFrameElement
expect(before).toBeTruthy()

// 首次 ready → 领 token 失败(401)→ 失败卡
dispatchReady(before)
await settle()
expect(fetchModuleToken).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('模块加载失败')
const retry = wrapper.find('button.u-error-retry')
expect(retry.text()).toBe('重新加载')

// retry:frameReload 自增 → iframe 按 key 重挂(DOM 元素被替换)
await retry.trigger('click')
await settle()

const after = wrapper.find('iframe').element as HTMLIFrameElement
expect(after).not.toBe(before)
expect(after.getAttribute('src')).toBe('/m/hello/')

// 新 iframe 重新发 ready → 真实桥对新帧再次取 token → 就位
dispatchReady(after)
await settle()
expect(fetchModuleToken).toHaveBeenCalledTimes(2)
expect(fetchModuleToken).toHaveBeenNthCalledWith(2, 'hello')
expect(wrapper.find('iframe').classes()).not.toContain('shell-frame-hidden')
expect(wrapper.text()).not.toContain('模块加载失败')

wrapper.unmount()
})
})
102 changes: 69 additions & 33 deletions apps/shell/src/App.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { LogOut, LayoutDashboard } from 'lucide-vue-next'
import { UButton, UErrorCard } from '@unself/ui'
Expand Down Expand Up @@ -43,15 +43,18 @@ const activeModule = computed(() => {
})

const frameSrc = computed(() => (activeModule.value ? moduleFrameSrc(activeModule.value) : null))
const frameOrigin = computed(() => frameOriginFor(activeModule.value?.manifest?.entry ?? null))
const frameKey = computed(() => `${activeModule.value?.id ?? 'none'}`)
/** 重挂计数(#71 根因 2):retry 自增 → frameKey 变化 → iframe 按 key 重挂 → SDK 重新发 ready。 */
const frameReload = ref(0)
const frameKey = computed(() => `${activeModule.value?.id ?? 'none'}#${frameReload.value}`)

// iframe 生命周期状态(六种异常卡,§6.5)
type FrameState = 'idle' | 'handshaking' | 'ready' | 'failed' | 'disabled'
const frameState = ref<FrameState>('idle')
const frameError = ref<ApiError | Error | null>(null)

let bridge: BridgeHandle | null = null
/** iframe 模板 ref:挂载/重挂期间会短暂为空,挂桥前必须确认就位。 */
const frameEl = ref<HTMLIFrameElement | null>(null)

onMounted(async () => {
// ① 会话真值检查(服务端):未登录去登录页
Expand Down Expand Up @@ -92,21 +95,62 @@ watch(selectedId, () => {
frameState.value = isHostView(selectedId.value) ? 'idle' : 'handshaking'
})

watch(activeModule, (mod) => {
bridge?.detach()
bridge = null
if (!mod) {
frameState.value = 'idle'
return
}
frameState.value = 'handshaking'
const iframe = frameEl.value
/**
* 模块激活即挂桥(#71 根因 2):
* flush 'post' 保证回调在 DOM 更新(iframe 挂载)之后执行;
* 回调内再等一拍取 frameEl;若仍未挂载则等模板 ref 就位,不再静默判 failed。
*/
watch(
activeModule,
async (mod) => {
bridge?.detach()
bridge = null
if (!mod) {
frameState.value = 'idle'
return
}
frameState.value = 'handshaking'
await nextTick()
await attachBridgeFor(mod)
},
{ flush: 'post' },
)

/** 等 iframe 模板 ref 就位(首次挂载/重挂);模块切换或组件卸载后返回 null。 */
function waitForFrameEl(): Promise<HTMLIFrameElement | null> {
if (frameEl.value) return Promise.resolve(frameEl.value)
return new Promise((resolve) => {
let stopFrame = () => {}
const stopActive = watch(activeModule, () => {
stopFrame()
resolve(null)
})
stopFrame = watch(frameEl, (el) => {
if (el) {
stopActive()
stopFrame()
resolve(el)
}
})
})
}

/**
* 给模块挂桥(watch 与 retry 共用,避免两处漂移):
* 仅入口配置无效(frameOriginFor 为 null)才判 failed;iframe 未挂载则等挂载后再挂。
*/
async function attachBridgeFor(mod: RegistryModule): Promise<void> {
const origin = frameOriginFor(mod.manifest?.entry ?? null)
if (!iframe || !origin) {
frameState.value = 'failed'
if (origin === null) {
frameError.value = new Error('模块入口配置无效,请联系管理员')
frameState.value = 'failed'
return
}
const iframe = await waitForFrameEl()
if (!iframe) return
// 等待期间用户可能已切换模块:交给新模块的 watch 处理
if (activeModule.value !== mod) return
bridge?.detach()
bridge = attachModuleBridge({
iframe,
moduleId: mod.id,
Expand All @@ -119,9 +163,8 @@ watch(activeModule, (mod) => {
frameState.value = 'failed'
},
})
})
}

const frameEl = ref<HTMLIFrameElement | null>(null)
onBeforeUnmount(() => bridge?.detach())

// 15s 握手超时(§6.5 异常卡:加载中骨架 → 失败卡)
Expand All @@ -144,27 +187,20 @@ async function onLogout() {
window.location.assign('/login')
}

/** 手动重试:重新拉 token 并触发 SDK 重新握手(发 ready 由 SDK 循环处理,这里重挂桥)。 */
function retryFrame() {
/**
* 手动重试:强制 iframe 重挂(frameReload 自增 → key 变化 → 新 iframe 重新发 ready),
* 重挂后对新 iframe 重新挂桥——旧消息不再丢失(#71 根因 2)。
*/
async function retryFrame() {
frameError.value = null
frameState.value = 'handshaking'
const mod = activeModule.value
const iframe = frameEl.value
const origin = frameOriginFor(mod?.manifest?.entry ?? null)
if (!mod || !iframe || !origin) return
if (!mod) return
bridge?.detach()
bridge = attachModuleBridge({
iframe,
moduleId: mod.id,
frameOrigin: origin,
onToken: () => {
frameState.value = 'ready'
},
onError: (err) => {
frameError.value = err
frameState.value = 'failed'
},
})
bridge = null
frameReload.value += 1
await nextTick()
await attachBridgeFor(mod)
}

/** 选中模块的完整 URL(新窗口打开,轻操作兜底)。 */
Expand Down
30 changes: 30 additions & 0 deletions deploy/cloudflare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,36 @@ core 与模块全部 zone 路径路由:core = `<domain>/*`,模块 = `<domain
(无需 config zone 字段)。多级子域不在 Universal SSL 覆盖内,脚本自动开启 Total TLS
逐个签发证书(签发秒级延迟,冒烟前已触发)。

## 模块验签公钥(CORE_JWKS_JSON,部署期注入)

模块与 core 同 zone 时,模块 Worker 运行时跨 Worker 拉 core 的 `/.well-known/jwks.json`
会被 CF 同 zone 禁令拦截 → 恒 401(#71 根因①)。B 方案:部署期把 core 公钥以
`vars.CORE_JWKS_JSON` 注入各模块——内容是 `{ keys: [ { kty:'EC', crv:'P-256', x, y, kid,
use:'sig', alg:'ES256' } ] }`(与 core `GET /.well-known/jwks.json` 响应体同形状),
模块本地验签,零运行时网络。

取钥两级(均在步骤④模块循环前):

| 情形 | 取钥方式 |
|------|----------|
| 首部署(本运行刚生成 JWT_PRIVATE_KEY) | 直接用内存里的新公钥(不抓公网,部署器对刚 deploy 的域名抓取会因 DNS/路由未就绪失败) |
| 已有 secret(重跑) | 部署器在公网 `GET <baseUrl>/.well-known/jwks.json`(无 CF 同 zone 禁令);失败即硬报错,提示 DNS/路由可能尚未就绪,可重跑部署(幂等) |

## 换钥流程(轮换 JWT 签名密钥)

1. `wrangler secret put JWT_PRIVATE_KEY --name unself-core-api`(新 PKCS8 PEM)
——core 的签名密钥由 secret 派生,替换后立即生效(secret put 会触发重新部署);
2. 重跑部署脚本:核心迁移/模块部署幂等收敛,步骤④会重新抓取 `/.well-known/jwks.json`
(现在已是新公钥)并把新 JWKS 注入各模块 `vars.CORE_JWKS_JSON`;
3. 旧 token 由新 kid 拒绝,系统自然失效——存量会话需重新登录。

## Docker 等价注记

`docker/` 目录目前为空壳,本次改动只在 CF 装配器(`deploy/cloudflare`)落地。
容器化部署的等价做法:同一环境变量 `CORE_JWKS_JSON` 写入 compose 的模块服务
environment(值由生成脚本在启动时从 core 侧导出),模块行为与 CF 一致——本地验签、
零运行时网络取钥。待 docker/ 落地时按此注记实现。

## 产物

所有生成的部署配置与 shell 构建副本落在 `.deploy/cloudflare/`(已 gitignore):
Expand Down
Loading
Loading