diff --git a/packages/room-source/src/githubSource.test.ts b/packages/room-source/src/githubSource.test.ts index 9bcc000..a96dc69 100644 --- a/packages/room-source/src/githubSource.test.ts +++ b/packages/room-source/src/githubSource.test.ts @@ -37,10 +37,10 @@ describe('GitHub room URLs', () => { const client = new GitHubSourceClient({ fetch: vi.fn(async (input) => { const url = String(input); const found = url.endsWith(`/heads/${encodeURIComponent('feature/room')}`); - return new Response(found ? '{}' : '', { status: found ? 200 : 404 }); + return new Response(found ? JSON.stringify({ object: { sha: 'a'.repeat(40) } }) : '', { status: found ? 200 : 404 }); }) as typeof fetch }); await expect(client.resolveUrl('https://github.com/a/room/tree/feature/room/dist')).resolves.toMatchObject({ - ref: 'feature/room', scope: 'dist', explicitRef: true, + ref: 'feature/room', contentRef: 'a'.repeat(40), scope: 'dist', explicitRef: true, }); }); }); @@ -90,6 +90,41 @@ describe('GitHub repository trees', () => { .mockResolvedValueOnce(new Response(JSON.stringify({ truncated: true, tree: [] }), { status: 200 })) }); await expect(truncated.listPaths(repo, 'main')).rejects.toMatchObject({ code: 'GITHUB_TREE_TRUNCATED' }); }); + + it('pins mutable branch downloads to the resolved commit instead of jsDelivr @main', async () => { + const sha = 'b'.repeat(40); + const treeUrl = `https://data.jsdelivr.com/v1/packages/gh/a/room@${sha}`; + const cdnBase = `https://cdn.jsdelivr.net/gh/a/room@${sha}/`; + const files: Record = { + 'parti.room.json': JSON.stringify(manifest), + 'index.html': '
room
', + 'room.worker.js': 'export default null;', + }; + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url === 'https://api.github.com/repos/a/room') { + return new Response(JSON.stringify({ default_branch: 'main' }), { status: 200 }); + } + if (url === 'https://api.github.com/repos/a/room/git/ref/heads/main') { + return new Response(JSON.stringify({ object: { sha } }), { status: 200 }); + } + if (url === treeUrl) { + return new Response(JSON.stringify({ files: Object.keys(files).map((name) => ({ type: 'file', name })) }), { status: 200 }); + } + if (url.startsWith(cdnBase)) { + const path = url.slice(cdnBase.length); + return new Response(files[path] ?? '', { status: files[path] === undefined ? 404 : 200 }); + } + return new Response('', { status: 404 }); + }); + + const resolved = await resolveGitHubImport('https://github.com/a/room', new GitHubSourceClient({ fetch: fetchMock as typeof fetch })); + expect(resolved.request).toMatchObject({ ref: 'main', contentRef: sha, refKind: 'branch' }); + const urls = fetchMock.mock.calls.map(([input]) => String(input)); + expect(urls).toContain(treeUrl); + expect(urls).toContain(`${cdnBase}parti.room.json`); + expect(urls.filter((url) => /(?:data|cdn)\.jsdelivr\.net\/.*@main/.test(url))).toEqual([]); + }); }); describe('GitHub release fallback', () => { diff --git a/packages/room-source/src/githubSource.ts b/packages/room-source/src/githubSource.ts index 2c1c82c..e528060 100644 --- a/packages/room-source/src/githubSource.ts +++ b/packages/room-source/src/githubSource.ts @@ -38,6 +38,11 @@ export interface ResolvedGitHubRoomRequest { owner: string; repo: string; ref: string; + /** + * 可变分支在下载前解析出的不可变 commit。保留 `ref` 供 UI 与市场元数据显示, + * 但 CDN 读取必须使用此值,避免 `@main` 命中陈旧边缘缓存。 + */ + contentRef?: string; scope: string; explicitRef: boolean; refKind?: GitHubRefKind; @@ -104,10 +109,20 @@ interface GitHubTreeResponse { truncated?: boolean; } +interface GitHubRefResolution { + kind: GitHubRefKind; + /** 仅分支 ref 有稳定的 commit 目标;annotated tag 仍使用 tag 名称。 */ + contentRef?: string; +} + function rateLimited(response: Response): boolean { return response.status === 403 || response.status === 429; } +function commitSha(value: unknown): string | undefined { + return typeof value === 'string' && /^[0-9a-f]{40}$/i.test(value) ? value : undefined; +} + function apiHeaders(token?: string): HeadersInit { return { Accept: 'application/vnd.github+json', @@ -177,17 +192,25 @@ export class GitHubSourceClient { return data.default_branch; } - async resolveRefKind(repo: GitHubRepoRef, ref: string): Promise { + async resolveRef(repo: GitHubRepoRef, ref: string): Promise { for (const [namespace, kind] of [['heads', 'branch'], ['tags', 'tag']] as const) { const url = `https://api.github.com/repos/${repo.owner}/${repo.repo}/git/ref/${namespace}/${encodeURIComponent(ref)}`; const response = await this.request(url, { headers: apiHeaders(this.token) }); if (rateLimited(response)) throw new RoomSourceError('GITHUB_RATE_LIMITED', { status: response.status }); - if (response.ok) return kind; + if (response.ok) { + const data = await response.json() as { object?: { sha?: unknown } }; + const resolved = kind === 'branch' ? commitSha(data.object?.sha) : undefined; + return { kind, ...(resolved ? { contentRef: resolved } : {}) }; + } if (response.status !== 404) throw new RoomSourceError('GITHUB_TREE_FAILED', { status: response.status }); } return null; } + async resolveRefKind(repo: GitHubRepoRef, ref: string): Promise { + return (await this.resolveRef(repo, ref))?.kind ?? null; + } + async refExists(repo: GitHubRepoRef, ref: string): Promise { return (await this.resolveRefKind(repo, ref)) !== null; } @@ -195,19 +218,23 @@ export class GitHubSourceClient { async resolveUrl(value: string): Promise { const parsed = parseGitHubRoomUrl(value); if (parsed.kind === 'repository') { + const ref = await this.defaultBranch(parsed); + const resolved = await this.resolveRef(parsed, ref); + if (!resolved || resolved.kind !== 'branch') throw new RoomSourceError('GITHUB_REF_NOT_FOUND'); return { owner: parsed.owner, repo: parsed.repo, - ref: await this.defaultBranch(parsed), + ref, scope: '.', explicitRef: false, - refKind: 'branch', + refKind: resolved.kind, + ...(resolved.contentRef ? { contentRef: resolved.contentRef } : {}), }; } for (let length = parsed.refAndPath.length; length >= 1; length -= 1) { const ref = parsed.refAndPath.slice(0, length).join('/'); - const refKind = await this.resolveRefKind(parsed, ref); - if (!refKind) continue; + const resolved = await this.resolveRef(parsed, ref); + if (!resolved) continue; const remainder = parsed.refAndPath.slice(length); const scopeParts = parsed.kind === 'blob' ? remainder.slice(0, -1) : remainder; return { @@ -216,7 +243,8 @@ export class GitHubSourceClient { ref, scope: scopeParts.join('/') || '.', explicitRef: true, - refKind, + refKind: resolved.kind, + ...(resolved.contentRef ? { contentRef: resolved.contentRef } : {}), }; } throw new RoomSourceError('GITHUB_REF_NOT_FOUND'); @@ -275,10 +303,11 @@ export class GitHubSourceClient { } async resolveRepository(request: ResolvedGitHubRoomRequest): Promise { - const paths = await this.listPaths(request, request.ref); + const contentRef = request.contentRef ?? request.ref; + const paths = await this.listPaths(request, contentRef); const resolved = await resolveRoomPackageFiles( paths, - (path) => this.readRepositoryFile(request, request.ref, path), + (path) => this.readRepositoryFile(request, contentRef, path), request.scope, ); return { request, ...resolved };