import 'urlpattern-polyfill'; // only for local dev
import { html, HTMLResponse } from '@worker-tools/html';
import { ok, badRequest, temporaryRedirect, forbidden } from '@worker-tools/response-creators';
import { WorkerRouter } from '@worker-tools/router';
import { provides } from '@worker-tools/middleware';
import { StorageArea } from '@worker-tools/cloudflare-kv-storage';
import { dedent } from 'ts-dedent'
import { stylesHandler } from './styles';
const navigator = self.navigator || { userAgent: 'Cloudflare Workers' } // only for local dev
const defaultBranchStorage = new StorageArea('default-branches');
const defaultPathStorage = new StorageArea('default-path');
const layout = (title, content) => html`
${title}
GHUC.CC
ghuc.cc = GitHub User Content Carbon Copy
Your friendly neighborhood redirection service for Deno 🦕 to import code directly from GitHub.
${content}
`
const mkGHUC_href = ({ user, repo, branchOrTag, path }) =>
`https://raw.githubusercontent.com/${user}/${repo}/${branchOrTag}/${path}`
const mkPage = ({ user, repo, branchOrTag, path }, url) => {
return new HTMLResponse(layout('ghuc.cc', html`
📦 ${new URL(new URL(url).pathname, 'https://ghuc.cc')}
➡️ ${mkGHUC_href({ user, repo, branchOrTag, path })}
JS required to load remote content.
`))
}
const mkInfo = (response) => {
const ghucV = (self.GITHUB_SHA || '2c877da').substring(0, 7);
return new HTMLResponse(layout('ghuc.cc',
html`
Needs to match pattern
/:user/:repo{\@:version}?{/:path(.*)}?
. Examples:
`), response)
}
const mkError = (response) => {
return new HTMLResponse(layout('ghuc.cc',
html`
Something went wrong: ${response.text().then(x => `${response.status}: ${x}`)}
.
`), response)
}
const ghAPI = (href, request) => {
return fetch(new URL(href, 'https://api.github.com').href, {
headers: {
'Accept': 'application/vnd.github.v3+json',
'User-Agent': navigator.userAgent,
...request.headers.has('authorization')
? { 'Authorization': request.headers.get('authorization') } : {},
},
})
}
const fetchHEAD = (href, request) => {
return fetch(href, {
method: 'HEAD',
headers: {
'User-Agent': navigator.userAgent,
...request.headers.has('authorization')
? { 'Authorization': request.headers.get('authorization') } : {},
}
});
}
async function inferDefaultBranch({ user, repo, maybePath }, { request }) {
// In case there's also no path we use .gitignore as a last resort to test for a file that is likely to be present.
const path = maybePath && !maybePath.endsWith('/') ? maybePath : '.gitignore'
for (const maybeBranch of ['master', 'main']) {
const res = await fetchHEAD(mkGHUC_href({ user, repo, branchOrTag: maybeBranch, path }), request);
if (res.ok) return maybeBranch
}
throw forbidden(dedent`ghuc.cc reached GitHub API's rate limit and cannot infer the default branch via heuristics.
You can provide the default branch via @ specifier after the repository name, or
you can draw from your own rate limit by providing a 'Authorization' headers with a GitHub personal access token.
In Deno this is achieved via the DENO_AUTH_TOKENS environment variable.
For more, see: https://deno.land/manual/linking_to_external_code/private#deno_auth_tokens`)
}
async function getBranchOrTag({ user, repo, version, maybePath }, { request, waitUntil }) {
if (version) {
return version.match(/^\d+\.\d+\.\d+/)
? version.endsWith('!') ? version.substring(0, version.length - 1) : `v${version}`
: version;
} else {
let defaultBranch = await defaultBranchStorage.get([user, repo])
if (!defaultBranch) {
const gh = await ghAPI(`/repos/${user}/${repo}`, request)
if (gh.ok) {
defaultBranch = (await gh.json()).default_branch;
} else {
if (gh.status === 403 && gh.headers.get('x-ratelimit-remaining') === '0') {
defaultBranch = await inferDefaultBranch({ user, repo, maybePath }, { request })
} else {
throw new Response(`Response from GitHub not ok: ${gh.status}`, gh)
}
}
waitUntil(defaultBranchStorage.set([user, repo], defaultBranch, { expirationTtl: 60 * 60 * 24 * 30 * 3 }))
}
return defaultBranch;
}
}
const stripLast = s => s.substring(0, s.length - 1)
async function getPath({ user, repo, branchOrTag, maybePath }, { request, waitUntil }) {
if (maybePath && !maybePath.endsWith('/')) return maybePath;
const storageKey = [user, repo, ...maybePath ? [stripLast(maybePath)] : []];
let path = await defaultPathStorage.get(storageKey)
if (!path) {
const dir = maybePath || '';
for (path of ['index.ts', 'mod.ts', 'index.js', 'mod.js'].map(p => dir + p)) {
const res = await fetchHEAD(mkGHUC_href({ user, repo, branchOrTag, path }), request);
if (res.ok) {
waitUntil(defaultPathStorage.set(storageKey, path, { expirationTtl: 60 * 60 * 24 * 30 * 3 }))
return path
}
}
throw badRequest('Couldn\'t determine file path. Provide a full path or ensure index.ts/mod.ts exists in the root')
}
return path;
}
const mw = provides(['text/html', '*/*']);
const assetsRouter = new WorkerRouter()
.get('/index.css', stylesHandler)
.recover('*', mw, (_, { type, response }) => {
if (type === 'text/html') return mkError(response)
return response;
})
const router = new WorkerRouter(mw)
.get('/favicon.ico', () => ok()) // TODO
.use('/_public/*', assetsRouter)
.get('/:handle/:repo(@?[^@/]+){@:version([^/]+)}?{/:path(.*)}?', async (request, { match, type, waitUntil }) => {
const { pathname: { groups: { handle, repo, version, path: maybePath } } } = match;
const user = handle.startsWith('@') ? handle.substring(1) : handle
const branchOrTag = await getBranchOrTag({ user, repo, version, maybePath }, { request, waitUntil })
const path = await getPath({ user, repo, branchOrTag, maybePath }, { request, waitUntil })
if (type === 'text/html') return mkPage({ user, repo, branchOrTag, path }, request.url)
return temporaryRedirect(mkGHUC_href({ user, repo, branchOrTag, path }));
})
.get('/', (_, { type }) => {
if (type === 'text/html') return mkInfo()
return ok("Needs to match pattern '/:user/:repo{\@:version}?{/:path(.*)}?'")
})
.any('*', (_, { type }) => {
if (type === 'text/html') return mkInfo(badRequest())
return badRequest("Needs to match pattern '/:user/:repo{\@:version}?{/:path(.*)}?'")
})
.recover('*', mw, (_, { type, response }) => {
if (type === 'text/html') return mkError(response)
return response;
})
self.addEventListener('fetch', router);