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
14 changes: 14 additions & 0 deletions packages/playground/blueprints/src/lib/v1/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,11 @@ export interface CompileBlueprintV1Options {
* A filesystem to use for the blueprint.
*/
streamBundledFile?: StreamBundledFile;
/**
* Additional headers to pass to git operations.
* A function that returns headers based on the URL being accessed.
*/
gitAdditionalHeadersCallback?: (url: string) => Record<string, string>;
/**
* Additional steps to add to the blueprint.
*/
Expand Down Expand Up @@ -142,6 +147,7 @@ function compileBlueprintJson(
onBlueprintValidated = () => {},
corsProxy,
streamBundledFile,
gitAdditionalHeadersCallback,
additionalSteps,
}: CompileBlueprintV1Options = {}
): CompiledBlueprintV1 {
Expand Down Expand Up @@ -321,6 +327,7 @@ function compileBlueprintJson(
totalProgressWeight,
corsProxy,
streamBundledFile,
gitAdditionalHeadersCallback,
})
);

Expand Down Expand Up @@ -514,6 +521,11 @@ interface CompileStepArgsOptions {
* A filesystem to use for the "blueprint" resource type.
*/
streamBundledFile?: StreamBundledFile;
/**
* Additional headers to pass to git operations.
* A function that returns headers based on the URL being accessed.
*/
gitAdditionalHeadersCallback?: (url: string) => Record<string, string>;
}

/**
Expand All @@ -532,6 +544,7 @@ function compileStep<S extends StepDefinition>(
totalProgressWeight,
corsProxy,
streamBundledFile,
gitAdditionalHeadersCallback,
}: CompileStepArgsOptions
): { run: CompiledV1Step; step: S; resources: Array<Resource<any>> } {
const stepProgress = rootProgressTracker.stage(
Expand All @@ -546,6 +559,7 @@ function compileStep<S extends StepDefinition>(
semaphore,
corsProxy,
streamBundledFile,
gitAdditionalHeadersCallback,
});
}
args[key] = value;
Expand Down
91 changes: 90 additions & 1 deletion packages/playground/blueprints/src/lib/v1/resources.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
GitDirectoryResource,
BundledResource,
} from './resources';
import { expect, describe, it, vi, beforeEach } from 'vitest';
import { expect, describe, it, vi, beforeEach, afterEach } from 'vitest';
import { StreamedFile } from '@php-wasm/stream-compression';
import { mkdtemp, rm, writeFile, mkdir } from 'fs/promises';
import { tmpdir } from 'os';
Expand Down Expand Up @@ -232,6 +232,95 @@ describe('GitDirectoryResource', () => {
expect(name).toBe('https-github.com-WordPress-link-manager-trunk');
});
});

describe('CORS handling', () => {
let originalFetch: typeof global.fetch;

beforeEach(() => {
originalFetch = global.fetch;
});

afterEach(() => {
global.fetch = originalFetch;
});

it('should unwrap CORS URL in GitAuthenticationError', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 401,
statusText: 'Unauthorized',
});

const githubUrl = 'https://github.com/user/private-repo';
const resource = new GitDirectoryResource(
{
resource: 'git:directory',
url: githubUrl,
ref: 'main',
},
undefined,
{
corsProxy: 'https://cors-proxy.com/',
}
);

await expect(resource.resolve()).rejects.toMatchObject({
name: 'GitAuthenticationError',
repoUrl: githubUrl,
status: 401,
});
});

it('should preserve GitHub URL in GitAuthenticationError without CORS proxy', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 401,
statusText: 'Unauthorized',
});

const githubUrl = 'https://github.com/user/private-repo';
const resource = new GitDirectoryResource({
resource: 'git:directory',
url: githubUrl,
ref: 'main',
});

await expect(resource.resolve()).rejects.toMatchObject({
name: 'GitAuthenticationError',
repoUrl: githubUrl,
status: 401,
});
});

it('should call gitAdditionalHeadersCallback without CORS proxy', async () => {
const githubUrl = 'https://github.com/user/private-repo';
const headerCallback = vi.fn().mockReturnValue({
Authorization: 'Bearer test-token',
});

const resource = new GitDirectoryResource(
{
resource: 'git:directory',
url: githubUrl,
ref: 'main',
},
undefined,
{
additionalHeaders: headerCallback,
}
);

// Call resolve - it will fail but that's okay, we just want to verify the callback
try {
await resource.resolve();
} catch {
// Expected to fail - we're not mocking the entire git resolution
}

// Verify the callback was called with the GitHub URL (not CORS-wrapped)
expect(headerCallback).toHaveBeenCalledWith(githubUrl);
});
});
});

describe('BlueprintResource', () => {
Expand Down
116 changes: 77 additions & 39 deletions packages/playground/blueprints/src/lib/v1/resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { FileTree, UniversalPHP } from '@php-wasm/universal';
import type { Semaphore } from '@php-wasm/util';
import { randomFilename } from '@php-wasm/util';
import {
GitAuthenticationError,
listDescendantFiles,
listGitFiles,
resolveCommitHash,
Expand Down Expand Up @@ -157,12 +158,16 @@ export abstract class Resource<T extends File | Directory> {
progress,
corsProxy,
streamBundledFile,
gitAdditionalHeadersCallback,
}: {
/** Optional semaphore to limit concurrent downloads */
semaphore?: Semaphore;
progress?: ProgressTracker;
corsProxy?: string;
streamBundledFile?: StreamBundledFile;
gitAdditionalHeadersCallback?: (
url: string
) => Record<string, string>;
}
): Resource<File | Directory> {
let resource: Resource<File | Directory>;
Expand All @@ -185,6 +190,7 @@ export abstract class Resource<T extends File | Directory> {
case 'git:directory':
resource = new GitDirectoryResource(ref, progress, {
corsProxy,
additionalHeaders: gitAdditionalHeadersCallback,
});
break;
case 'literal:directory':
Expand Down Expand Up @@ -556,12 +562,18 @@ export class UrlResource extends FetchResource {
*/
export class GitDirectoryResource extends Resource<Directory> {
private reference: GitDirectoryReference;
private options?: { corsProxy?: string };
private options?: {
corsProxy?: string;
additionalHeaders?: (url: string) => Record<string, string>;
};

constructor(
reference: GitDirectoryReference,
_progress?: ProgressTracker,
options?: { corsProxy?: string }
options?: {
corsProxy?: string;
additionalHeaders?: (url: string) => Record<string, string>;
}
) {
super();
this.reference = reference;
Expand All @@ -570,51 +582,77 @@ export class GitDirectoryResource extends Resource<Directory> {
}

async resolve() {
const additionalHeaders =
this.options?.additionalHeaders?.(this.reference.url) ?? {};

const repoUrl = this.options?.corsProxy
? `${this.options.corsProxy}${this.reference.url}`
: this.reference.url;

const commitHash = await resolveCommitHash(repoUrl, {
value: this.reference.ref,
type: this.reference.refType ?? 'infer',
});
const allFiles = await listGitFiles(repoUrl, commitHash);

const requestedPath = (this.reference.path ?? '').replace(/^\/+/, '');
const filesToClone = listDescendantFiles(allFiles, requestedPath);
const checkout = await sparseCheckout(
repoUrl,
commitHash,
filesToClone,
{
withObjects: this.reference['.git'],
}
);
let files = checkout.files;
try {
const commitHash = await resolveCommitHash(
repoUrl,
{
value: this.reference.ref,
type: this.reference.refType ?? 'infer',
},
additionalHeaders
);
const allFiles = await listGitFiles(
repoUrl,
commitHash,
additionalHeaders
);

// Remove the path prefix from the cloned file names.
files = mapKeys(files, (name) =>
name.substring(requestedPath.length).replace(/^\/+/, '')
);
if (this.reference['.git']) {
const gitFiles = await createDotGitDirectory({
repoUrl: this.reference.url,
const requestedPath = (this.reference.path ?? '').replace(
/^\/+/,
''
);
const filesToClone = listDescendantFiles(allFiles, requestedPath);
const checkout = await sparseCheckout(
repoUrl,
commitHash,
ref: this.reference.ref,
refType: this.reference.refType,
objects: checkout.objects ?? [],
fileOids: checkout.fileOids ?? {},
pathPrefix: requestedPath,
});
files = {
...gitFiles,
...files,
filesToClone,
{
withObjects: this.reference['.git'],
additionalHeaders,
}
);
let files = checkout.files;

// Remove the path prefix from the cloned file names.
files = mapKeys(files, (name) =>
name.substring(requestedPath.length).replace(/^\/+/, '')
);
if (this.reference['.git']) {
const gitFiles = await createDotGitDirectory({
repoUrl: this.reference.url,
commitHash,
ref: this.reference.ref,
refType: this.reference.refType,
objects: checkout.objects ?? [],
fileOids: checkout.fileOids ?? {},
pathPrefix: requestedPath,
});
files = {
...gitFiles,
...files,
};
}
return {
name: this.filename,
files,
};
} catch (error) {
if (error instanceof GitAuthenticationError) {
// Unwrap and re-throw with the original URL (without CORS proxy)
throw new GitAuthenticationError(
this.reference.url,
error.status
);
}
throw error;
}
return {
name: this.filename,
files,
};
}

/**
Expand Down
2 changes: 2 additions & 0 deletions packages/playground/client/src/blueprints-v1-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export class BlueprintsV1Handler {
onBlueprintValidated,
onBlueprintStepCompleted,
corsProxy,
gitAdditionalHeadersCallback,
mounts,
sapiName,
scope,
Expand Down Expand Up @@ -72,6 +73,7 @@ export class BlueprintsV1Handler {
onStepCompleted: onBlueprintStepCompleted,
onBlueprintValidated,
corsProxy,
gitAdditionalHeadersCallback,
});
await runBlueprintV1Steps(compiled, playground);
}
Expand Down
5 changes: 5 additions & 0 deletions packages/playground/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@ export interface StartPlaygroundOptions {
* your Blueprint to replace all cross-origin URLs with the proxy URL.
*/
corsProxy?: string;
/**
* Additional headers to pass to git operations.
* A function that returns headers based on the URL being accessed.
*/
gitAdditionalHeadersCallback?: (url: string) => Record<string, string>;
/**
* The version of the SQLite driver to use.
* Defaults to the latest development version.
Expand Down
Loading