├── .gitattributes ├── .github ├── dependabot.yml └── workflows │ ├── publish.yml │ └── updater.yml ├── cacheUrl.ts ├── changelog.md ├── deno.json ├── filePathToContentType.ts ├── handlers └── npm.ts ├── license ├── mod.ts ├── readme.md ├── respondWithError.ts └── rewriteContent.ts /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'github-actions' 4 | directory: '/' 5 | schedule: 6 | interval: 'daily' 7 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | kind: 7 | description: Kind of release 8 | default: minor 9 | type: choice 10 | options: 11 | - prepatch 12 | - patch 13 | - preminor 14 | - minor 15 | - premajor 16 | - major 17 | required: true 18 | 19 | jobs: 20 | publish: 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - uses: actions/checkout@v4 25 | 26 | - name: Publish update 27 | uses: boywithkeyboard/publisher@v2 28 | with: 29 | kind: ${{github.event.inputs.kind}} 30 | -------------------------------------------------------------------------------- /.github/workflows/updater.yml: -------------------------------------------------------------------------------- 1 | name: updater 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * *' 6 | workflow_dispatch: 7 | 8 | permissions: 9 | contents: write 10 | pull-requests: write 11 | 12 | jobs: 13 | updater: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Setup Deno 20 | uses: denoland/setup-deno@v1 21 | with: 22 | deno-version: v1.x 23 | 24 | - name: Run update 25 | run: | 26 | deno run -A https://den.ooo/updater@v0.14.0/mod.ts -c 27 | CHANGELOG=$(cat updates_changelog.md) 28 | echo "CHANGELOG<> $GITHUB_ENV 29 | echo "$CHANGELOG" >> $GITHUB_ENV 30 | echo "EOF" >> $GITHUB_ENV 31 | rm updates_changelog.md 32 | 33 | - name: Create pull request 34 | uses: peter-evans/create-pull-request@v6 35 | with: 36 | title: 'refactor: update dependencies' 37 | author: 'github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>' 38 | committer: 'github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>' 39 | commit-message: 'refactor: update dependencies' 40 | body: '${{ env.CHANGELOG }}' 41 | labels: 'dependencies' 42 | delete-branch: true 43 | branch: 'refactor/dependencies' 44 | -------------------------------------------------------------------------------- /cacheUrl.ts: -------------------------------------------------------------------------------- 1 | import { Registry } from './registry.ts' 2 | 3 | const REGEX = 4 | /(?:(?<=(?:import|export)[^`'"]*from\s+[`'"])(?[^`'"]+)(?=(?:'|"|`)))|(?:\b(?:import|export)(?:\s+|\s*\(\s*)[`'"](?[^`'"]+)[`'"])/g 5 | 6 | /** 7 | * Cache a file recursively. 8 | */ 9 | export async function cacheUrl(registry: Registry, url: string) { 10 | // #1 cache file 11 | const res = await fetch(url) 12 | 13 | if (!res.ok) { 14 | return 15 | } 16 | 17 | const str = await res.text() 18 | 19 | await registry.fileCache.set(url, str) 20 | 21 | // #2 walk through imports 22 | 23 | for (const url of str.matchAll(REGEX)) { 24 | await cacheUrl(registry, url[0]) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | ## Upcoming 2 | 3 | - **Version Ranges** 4 | 5 | ... 6 | 7 | - **Permanent Caching** 8 | 9 | ... 10 | 11 | - **Strict & Unified Semantic Versioning** 12 | 13 | ... 14 | -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "fmt": { 3 | "semiColons": false, 4 | "singleQuote": true 5 | }, 6 | "tasks": { 7 | "dev": "export STAGE=development && deno run -A --watch local.js" 8 | }, 9 | "imports": { 10 | "eszip": "https://deno.land/x/eszip@v0.64.2/mod.ts", 11 | "octokit": "https://esm.sh/octokit@3.1.2?target=es2022", 12 | "semver": "https://esm.sh/semver@7.6.0?target=es2022", 13 | "typebox": "https://esm.sh/@sinclair/typebox@0.32.15?target=es2022", 14 | "typebox/value": "https://esm.sh/@sinclair/typebox@0.32.15/value?target=es2022" 15 | }, 16 | "lock": false 17 | } 18 | -------------------------------------------------------------------------------- /filePathToContentType.ts: -------------------------------------------------------------------------------- 1 | export function filePathToContentType(filePath: string) { 2 | return filePath.endsWith('.jsx') 3 | ? 'text/jsx' 4 | : filePath.endsWith('.tsx') 5 | ? 'text/tsx' 6 | : filePath.endsWith('.js') || filePath.endsWith('.mjs') 7 | ? 'text/javascript' 8 | : filePath.endsWith('.ts') 9 | ? 'text/typescript' 10 | : filePath.endsWith('.json') 11 | ? 'application/json' 12 | : filePath.endsWith('.wasm') 13 | ? 'application/wasm' 14 | : 'text/plain' 15 | } 16 | -------------------------------------------------------------------------------- /handlers/npm.ts: -------------------------------------------------------------------------------- 1 | import semver from 'semver' 2 | import { respondWithError } from '../respondWithError.ts' 3 | import { rewriteContent } from '../rewriteContent.ts' 4 | import { Handler, HandlerContext } from '../types.ts' 5 | 6 | function parseUrl(url: URL) { 7 | const pieces = url.pathname.split('/').slice(2) 8 | const isScoped = pieces[0].startsWith('@') 9 | 10 | let name: string 11 | let version: string | null = null 12 | let submodulePath: string 13 | 14 | if (isScoped) { 15 | const includesVersion = pieces[1].includes('@') 16 | 17 | if (includesVersion) { 18 | version = pieces[1].split('@')[1] 19 | pieces[1] = pieces[1].split('@')[0] 20 | name = pieces.slice(0, 2).join('/') 21 | } else { 22 | name = pieces.slice(0, 2).join('/') 23 | } 24 | 25 | submodulePath = '/' + pieces.slice(2).join('/') 26 | } else { 27 | const includesVersion = pieces[0].includes('@') 28 | 29 | if (includesVersion) { 30 | name = pieces[0].split('@')[0] 31 | version = pieces[0].split('@')[1] 32 | } else { 33 | name = pieces[0] 34 | } 35 | 36 | submodulePath = '/' + pieces.slice(1).join('/') 37 | } 38 | 39 | return { 40 | name, 41 | version, 42 | submodulePath, 43 | } 44 | } 45 | 46 | async function fetchVersions( 47 | ctx: HandlerContext, 48 | data: ReturnType, 49 | ): Promise { 50 | const cachedVersions = await ctx.versionCache.get(`npm:${data.name}`) 51 | 52 | if (cachedVersions) { 53 | return cachedVersions 54 | } 55 | 56 | const res = await fetch(`https://registry.npmjs.org/${data.name}`) 57 | 58 | if (!res.ok) { 59 | return [] 60 | } 61 | 62 | let { versions } = await res.json() as { versions: string[] } 63 | 64 | versions = Object.keys(versions) 65 | 66 | await ctx.versionCache.set(`npm:${data.name}`, versions) 67 | 68 | return versions.filter((version) => semver.valid(version)) 69 | } 70 | 71 | export const handle: Handler = async (ctx) => { 72 | const data = parseUrl(ctx.url) 73 | 74 | // if there's no version tag 75 | if (data.version === null) { 76 | const versions = await fetchVersions(ctx, data) 77 | 78 | if (versions.length === 0) { 79 | return respondWithError('BAD_MODULE') 80 | } 81 | 82 | data.version = semver.rsort(versions)[0] 83 | // if there's an invalid version tag or range 84 | } else if (semver.valid(data.version) === null) { 85 | if (semver.validRange(data.version) === null) { 86 | return respondWithError('BAD_VERSION') 87 | } 88 | 89 | const versions = await fetchVersions(ctx, data) 90 | 91 | if (versions.length === 0) { 92 | return respondWithError('BAD_MODULE') 93 | } 94 | 95 | const suggestedVersion = semver.maxSatisfying(versions, data.version) 96 | 97 | if (suggestedVersion === null) { 98 | return respondWithError('BAD_VERSION') 99 | } 100 | 101 | data.version = suggestedVersion 102 | } 103 | 104 | if (ctx.url.searchParams.has('tgz') || ctx.url.searchParams.has('tar.gz')) { 105 | return Response.redirect( 106 | `https://registry.npmjs.org/${data.name}/-/${data.name}-${data.version}.tgz`, 107 | 307, 108 | ) 109 | } 110 | 111 | const res = await fetch( 112 | `https://cdn.jsdelivr.net/npm/${data.name}@${data.version}${ 113 | data.submodulePath === '/' ? '' : data.submodulePath 114 | }/+esm`, 115 | ) 116 | 117 | if (!res.ok) { 118 | return respondWithError('UNINDEXED_MODULE') 119 | } 120 | 121 | let content = await res.text() 122 | 123 | content = rewriteContent(content, ({ url }) => { 124 | // e.g. /npm/lru-cache@6.0.0/+esm 125 | if (/^\/npm(\/[^\/]+)+\/\+esm$/.test(url)) { 126 | url = url.replace( 127 | '/npm', 128 | ctx.url.protocol + '//' + ctx.url.hostname + '/npm', 129 | ) 130 | url = url.slice(0, -5) // remove /+esm 131 | } 132 | 133 | return url 134 | }) 135 | 136 | return new Response(content, { 137 | headers: { 138 | 'cache-control': `public, max-age=${ctx.cacheDurationInHours * 3600}`, 139 | 'content-type': 'text/javascript; charset=utf-8', 140 | 'x-typescript-types': `https://esm.sh/${data.name}@${data.version}${ 141 | data.submodulePath === '/' ? '' : data.submodulePath 142 | }?target=es2022`, 143 | }, 144 | }) 145 | } 146 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023-2024 Samuel Kopp 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /mod.ts: -------------------------------------------------------------------------------- 1 | export { cacheUrl } from './cacheUrl.ts' 2 | export { rewriteContent } from './rewriteContent.ts' 3 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | ***This project is dead, please use [deno.re](https://deno.re) or an alternative service instead!*** 2 | -------------------------------------------------------------------------------- /respondWithError.ts: -------------------------------------------------------------------------------- 1 | const ERRORS = { 2 | BAD_MODULE: 400, 3 | BAD_VERSION: 400, 4 | UNINDEXED_MODULE: 404, 5 | NOT_FOUND: 404, 6 | } 7 | 8 | export function respondWithError(message: keyof typeof ERRORS) { 9 | return new Response(message, { 10 | status: ERRORS[message], 11 | headers: { 12 | 'content-type': 'text/plain; charset=utf-8', 13 | }, 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /rewriteContent.ts: -------------------------------------------------------------------------------- 1 | const REGEX = 2 | /(?:(?<=(?:import|export)[^`'"]*from(\s+)?[`'"])(?[^`'"]+)(?=(?:'|"|`)))|(?:\b(?:import|export)(?:\s+|\s*\(\s*)[`'"](?[^`'"]+)[`'"])/g 3 | 4 | /** 5 | * Rewrite imports and exports in a string. 6 | * 7 | * @example 8 | * 9 | * ```ts 10 | * let fileContent = await Deno.readTextFile('./file.js') 11 | * 12 | * fileContent = rewriteContent(fileContent, ({ url }) => { 13 | * return url.replace('https://esm.sh/', 'npm:') 14 | * }) 15 | * ``` 16 | */ 17 | export function rewriteContent( 18 | fileContent: string, 19 | replacer: (data: { url: string }) => string, 20 | ) { 21 | return fileContent.replace(REGEX, (url) => { 22 | return replacer({ url }) 23 | }) 24 | } 25 | --------------------------------------------------------------------------------