Skip to content
Open
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
39 changes: 37 additions & 2 deletions packages/room-source/src/githubSource.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
});
});
Expand Down Expand Up @@ -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<string, string> = {
'parti.room.json': JSON.stringify(manifest),
'index.html': '<main>room</main>',
'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', () => {
Expand Down
47 changes: 38 additions & 9 deletions packages/room-source/src/githubSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -177,37 +192,49 @@ export class GitHubSourceClient {
return data.default_branch;
}

async resolveRefKind(repo: GitHubRepoRef, ref: string): Promise<GitHubRefKind | null> {
async resolveRef(repo: GitHubRepoRef, ref: string): Promise<GitHubRefResolution | null> {
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<GitHubRefKind | null> {
return (await this.resolveRef(repo, ref))?.kind ?? null;
}

async refExists(repo: GitHubRepoRef, ref: string): Promise<boolean> {
return (await this.resolveRefKind(repo, ref)) !== null;
}

async resolveUrl(value: string): Promise<ResolvedGitHubRoomRequest> {
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 {
Expand All @@ -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');
Expand Down Expand Up @@ -275,10 +303,11 @@ export class GitHubSourceClient {
}

async resolveRepository(request: ResolvedGitHubRoomRequest): Promise<ResolvedRepositoryPackage> {
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 };
Expand Down