Skip to content

Commit 6c0ca9f

Browse files
refactor: unify tarball resolution and optimize download latency (#468)
1 parent 0a43251 commit 6c0ca9f

9 files changed

Lines changed: 214 additions & 252 deletions

File tree

packages/app/e2e.test.ts

Lines changed: 70 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import type { Response } from "@cloudflare/workers-types";
21
import ezSpawn from "@jsdevtools/ez-spawn";
32
import { simulation } from "@simulacrum/github-api-simulator";
43
import fs from "node:fs/promises";
@@ -171,9 +170,15 @@ describe.sequential.each([
171170
expect(shaBlob.size).toBeGreaterThan(0);
172171

173172
// Test download with ref matches SHA content
174-
const refResponse = await fetchWithRedirect(
173+
const refResponse = await worker.fetch(
175174
`/${owner}/${repo}/playground-a@${ref}`,
176175
);
176+
expect(refResponse.status).toBe(200);
177+
expect(refResponse.headers.get("x-pkg-name-key")).toBe("playground-a");
178+
expect(refResponse.headers.get("x-commit-key")).toBe(
179+
`${owner}:${repo}:${fullSha}`,
180+
);
181+
177182
const refBlob = await refResponse.blob();
178183
const shaBlobSize = await shaBlob.arrayBuffer();
179184
const refBlobSize = await refBlob.arrayBuffer();
@@ -197,6 +202,29 @@ describe.sequential.each([
197202
);
198203
}, 20_000);
199204

205+
it(`returns metadata for HEAD requests (${mode})`, async () => {
206+
const [owner, repo] = payload.repository.full_name.split("/");
207+
const fullSha = pr ? payload.workflow_run.head_sha : gitRevParse;
208+
const sha = fullSha.substring(0, 7);
209+
210+
const headResponse = await worker.fetch(
211+
`/${owner}/${repo}/playground-a@${sha}`,
212+
{ method: "HEAD" },
213+
);
214+
215+
expect(headResponse.status).toBe(200);
216+
expect(headResponse.headers.get("x-pkg-name-key")).toBe("playground-a");
217+
expect(headResponse.headers.get("x-commit-key")).toBe(
218+
`${owner}:${repo}:${sha}`,
219+
);
220+
expect(headResponse.headers.get("content-type")).toBe(
221+
"application/tar+gzip",
222+
);
223+
expect(headResponse.headers.get("etag")).toBeDefined();
224+
const lastModified = headResponse.headers.get("last-modified");
225+
expect(new Date(lastModified!).toString()).not.toBe("Invalid Date");
226+
});
227+
200228
it(`serves and installs playground-b for ${mode}`, async () => {
201229
const [owner, repo] = payload.repository.full_name.split("/");
202230
const fullSha = pr ? payload.workflow_run.head_sha : gitRevParse;
@@ -231,48 +259,57 @@ describe.sequential.each([
231259
}, 20_000);
232260
});
233261

234-
describe("URL redirects", () => {
262+
describe("URL resolution", () => {
235263
describe("standard packages", () => {
236-
it("redirects full URLs correctly", async () => {
237-
const response = await fetchWithRedirect("/tinylibs/tinybench@a832a55");
238-
expect(response.url).toContain("/tinylibs/tinybench/tinybench@a832a55");
264+
it.each([
265+
["full", "/tinylibs/tinybench/tinybench@a832a55"],
266+
["compact", "/tinybench@a832a55"],
267+
["with .tgz extension", "/tinybench@a832a55.tgz"],
268+
])("resolves %s URLs", async (_, url) => {
269+
const response = await worker.fetch(url);
270+
271+
expect(response.headers.get("x-commit-key")).toBe(
272+
"tinylibs:tinybench:a832a55",
273+
);
274+
expect(response.headers.get("x-pkg-name-key")).toBe("tinybench");
239275
});
240276

241-
it("redirects compact URLs correctly", async () => {
242-
const response = await fetchWithRedirect("/tinybench@a832a55");
243-
expect(response.url).toContain("/tinylibs/tinybench/tinybench@a832a55");
277+
it("resolves URL with full Git SHA", async () => {
278+
const response = await worker.fetch(
279+
"/tinylibs/tinybench/tinybench@a832a55e8f50c419ed8414024899e37e69b1f999",
280+
);
281+
282+
expect(response.headers.get("x-pkg-name-key")).toBe("tinybench");
283+
expect(response.headers.get("x-commit-key")).toBe(
284+
"tinylibs:tinybench:a832a55e8f50c419ed8414024899e37e69b1f999",
285+
);
244286
});
245287
});
246288

247289
describe("scoped packages", () => {
248-
const expectedPath = `/stackblitz/sdk/${encodeURIComponent("@stackblitz/sdk")}@a832a55`;
290+
it.each([
291+
["full", "/stackblitz/sdk/@stackblitz/sdk@a832a55"],
292+
["encoded", "/stackblitz/sdk/%40stackblitz%2Fsdk@a832a55"],
293+
["compact", "/@stackblitz/sdk@a832a55"],
294+
["compact encoded", "/%40stackblitz%2Fsdk@a832a55"],
295+
])("resolves %s URLs", async (_, url) => {
296+
const response = await worker.fetch(url);
249297

250-
it("redirects full scoped package URLs correctly", async () => {
251-
const response = await fetchWithRedirect(
252-
"/stackblitz/sdk/@stackblitz/sdk@a832a55",
298+
expect(response.headers.get("x-pkg-name-key")).toBe("@stackblitz:sdk");
299+
expect(response.headers.get("x-commit-key")).toBe(
300+
"stackblitz:sdk:a832a55",
253301
);
254-
expect(response.url).toContain(expectedPath);
255302
});
256303

257-
it("redirects compact scoped package URLs correctly", async () => {
258-
const response = await fetchWithRedirect("/@stackblitz/sdk@a832a55");
259-
expect(response.url).toContain(expectedPath);
304+
it("resolves URL with full Git SHA", async () => {
305+
const response = await worker.fetch(
306+
"/stackblitz/sdk/@stackblitz/sdk@a832a55e8f50c419ed8414024899e37e69b1f999",
307+
);
308+
309+
expect(response.headers.get("x-pkg-name-key")).toBe("@stackblitz:sdk");
310+
expect(response.headers.get("x-commit-key")).toBe(
311+
"stackblitz:sdk:a832a55e8f50c419ed8414024899e37e69b1f999",
312+
);
260313
});
261314
});
262315
});
263-
264-
async function fetchWithRedirect(
265-
url: string,
266-
maxRedirects = 999,
267-
): Promise<Response> {
268-
const response = await worker.fetch(url, { redirect: "manual" });
269-
270-
if (response.status >= 300 && response.status < 400 && maxRedirects > 0) {
271-
const location = response.headers.get("location");
272-
if (location) {
273-
return fetchWithRedirect(location, maxRedirects - 1);
274-
}
275-
}
276-
277-
return response as unknown as Response;
278-
}

packages/app/fixtures/workflow_run.in_progress.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"id": 9394452824,
77
"name": "Preview & Release",
88
"node_id": "WFR_kwLOLiqblM8AAAACL_P5WA",
9-
"head_branch": "main",
9+
"head_branch": "@test/@chaotic.branch/name-@v1.@",
1010
"head_sha": "ded05e838c418096e5dd77a29101c8af9e73daea",
1111
"path": ".github/workflows/ci.yml",
1212
"display_title": "chore: 007 (#94)",

packages/app/nuxt.config.ts

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
// import ncb from "nitro-cloudflare-dev";
2-
import { resolve } from "pathe";
3-
41
// https://nuxt.com/docs/api/configuration/nuxt-config
52
export default defineNuxtConfig({
63
sourcemap: true,
@@ -66,23 +63,6 @@ export default defineNuxtConfig({
6663
test: "",
6764
},
6865

69-
hooks: {
70-
"nitro:build:before": (nitro) => {
71-
// Override the server routes with the client routes so they are higher priority
72-
const clientRenderer = resolve(
73-
"node_modules/nuxt/dist/core/runtime/nitro/renderer",
74-
);
75-
nitro.options.handlers.unshift({
76-
route: "/",
77-
handler: clientRenderer,
78-
});
79-
nitro.options.handlers.unshift({
80-
route: "/~/**",
81-
handler: clientRenderer,
82-
});
83-
},
84-
},
85-
8666
icon: {
8767
clientBundle: {
8868
icons: ["mdi-github"],
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import {
2+
extractOwnerAndRepo,
3+
extractRepository,
4+
isValidGitHash,
5+
} from "@pkg-pr-new/utils";
6+
import { getPackageManifest } from "query-registry";
7+
import { normalizeKey } from "unstorage";
8+
9+
const RESERVED_ROUTES = new Set(["api", "~"]);
10+
const ALLOWED_METHODS = new Set(["GET", "HEAD"]);
11+
12+
export default eventHandler(async (event) => {
13+
let decodedPath: string;
14+
try {
15+
const path = event.path
16+
.split("?")[0]
17+
// yarn support
18+
.replace(/\.tgz$/, "");
19+
decodedPath = decodeURIComponent(path);
20+
} catch {
21+
throw createError({
22+
statusCode: 400,
23+
message: "Malformed URI",
24+
});
25+
}
26+
27+
let separatorIndex = -1;
28+
29+
for (let i = 2; i < decodedPath.length - 1; i++) {
30+
if (decodedPath[i] === "@" && decodedPath[i - 1] !== "/") {
31+
separatorIndex = i;
32+
break;
33+
}
34+
}
35+
36+
if (separatorIndex === -1) return;
37+
38+
let refOrSha = decodedPath.slice(separatorIndex + 1);
39+
40+
const pathSegments = decodedPath
41+
.slice(0, separatorIndex)
42+
.split("/")
43+
.filter(Boolean);
44+
if (pathSegments.length === 0) return;
45+
46+
const rootSegment = pathSegments[0];
47+
if (RESERVED_ROUTES.has(rootSegment) || !/^[a-z0-9@]/i.test(rootSegment)) {
48+
return;
49+
}
50+
if (!ALLOWED_METHODS.has(event.method)) return;
51+
52+
let packageName = pathSegments.pop()!;
53+
54+
if (pathSegments.at(-1)?.startsWith("@")) {
55+
packageName = `${pathSegments.pop()}/${packageName}`;
56+
}
57+
58+
let owner = pathSegments.shift();
59+
let repo = pathSegments.shift() ?? (owner ? packageName : undefined);
60+
61+
if (pathSegments.length > 0) return;
62+
63+
if (!repo) {
64+
try {
65+
const manifest = await getPackageManifest(packageName);
66+
67+
const repository = extractRepository(manifest);
68+
if (!repository) throw new Error();
69+
70+
const match = extractOwnerAndRepo(repository);
71+
if (!match) throw new Error();
72+
73+
[owner, repo] = match;
74+
} catch {
75+
throw createError({
76+
statusCode: 404,
77+
message: "Registry or repository not found",
78+
});
79+
}
80+
}
81+
82+
const isFullGitHash = isValidGitHash(refOrSha);
83+
if (!isFullGitHash) {
84+
const cursorBucket = useCursorsBucket(event);
85+
const cursorKey = `${owner}:${repo}:${refOrSha}`;
86+
const currentCursor = await cursorBucket.getItem(cursorKey);
87+
88+
if (currentCursor) {
89+
refOrSha = currentCursor.sha;
90+
}
91+
}
92+
93+
const repositoryCommitKey = `${owner}:${repo}:${refOrSha}`;
94+
setResponseHeader(event, "x-commit-key", repositoryCommitKey);
95+
96+
const normalizedPkgName = normalizeKey(packageName);
97+
setResponseHeader(event, "x-pkg-name-key", normalizedPkgName);
98+
99+
const prefix = `${usePackagesBucket.base}:${repositoryCommitKey}`;
100+
101+
const binding = useBinding(event);
102+
const { objects } = await binding.list({ prefix });
103+
104+
const packageMetadata = objects.find(({ key }) => {
105+
// bucket:package:stackblitz-labs:pkg.pr.new:ded05e838c418096e5dd77a29101c8af9e73daea:playground-b
106+
if (!key.endsWith(normalizedPkgName)) return false;
107+
108+
// ...:playground-b
109+
const remainder = key.slice(prefix.length);
110+
const colonIdx = remainder.indexOf(":");
111+
112+
return remainder.slice(colonIdx + 1) === normalizedPkgName;
113+
});
114+
115+
if (!packageMetadata) {
116+
throw createError({
117+
statusCode: 404,
118+
message: "Pkg not found",
119+
});
120+
}
121+
122+
setResponseHeader(event, "content-type", "application/tar+gzip");
123+
setResponseHeader(event, "etag", packageMetadata.etag);
124+
setResponseHeader(
125+
event,
126+
"last-modified",
127+
packageMetadata.uploaded.toUTCString(),
128+
);
129+
130+
if (event.method === "HEAD") {
131+
setResponseStatus(event, 200);
132+
return send(event, null);
133+
}
134+
135+
const downloadedAtBucket = useDownloadedAtBucket(event);
136+
event.waitUntil(downloadedAtBucket.setItem(packageMetadata.key, Date.now()));
137+
138+
const object = await binding.get(packageMetadata.key);
139+
const stream = object?.body;
140+
141+
// TODO: add HTTP caching
142+
return stream;
143+
});

packages/app/server/plugins/config.ts

Lines changed: 0 additions & 16 deletions
This file was deleted.

packages/app/server/routes/[owner]/[repo]/[npmOrg]/[packageAndRefOrSha].get.ts

Lines changed: 0 additions & 17 deletions
This file was deleted.

0 commit comments

Comments
 (0)