├── .env.sample ├── .eslintignore ├── .eslintrc ├── .github ├── FUNDING.yml └── workflows │ ├── docker-deploy.yml │ └── test.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc ├── Dockerfile ├── LICENSE ├── README.md ├── SPONSORS.md ├── fly.toml ├── jest.config.js ├── modules ├── .babelrc ├── __mocks__ │ ├── entryManifest.js │ ├── getStatsMock.js │ ├── imageMock.js │ ├── metadata │ │ ├── @babel │ │ │ └── core.json │ │ ├── preact.json │ │ ├── react.json │ │ └── sinuous.json │ ├── npmMock.js │ ├── packages │ │ ├── @babel │ │ │ └── core-7.5.4.tgz │ │ ├── preact-8.4.2.tgz │ │ ├── react-16.8.0.tgz │ │ └── sinuous-0.12.9.tgz │ └── styleMock.js ├── __tests__ │ ├── .eslintrc │ ├── browseDirectory-test.js │ ├── browseFile-test.js │ ├── browseRedirect-test.js │ ├── directoryRedirect-test.js │ ├── invalidPackageNames-test.js │ ├── legacyURLs-test.js │ ├── meta-test.js │ ├── missingFile-test.js │ ├── module-test.js │ ├── regularFile-test.js │ └── stats-test.js ├── actions │ ├── serveBrowsePage.js │ ├── serveDirectoryBrowser.js │ ├── serveDirectoryMetadata.js │ ├── serveFile.js │ ├── serveFileBrowser.js │ ├── serveFileMetadata.js │ ├── serveHTMLModule.js │ ├── serveJavaScriptModule.js │ ├── serveMainPage.js │ ├── serveModule.js │ └── serveStats.js ├── client │ ├── .babelrc │ ├── .eslintrc │ ├── browse.js │ ├── browse │ │ ├── App.js │ │ ├── ContentArea.js │ │ ├── FileViewer.js │ │ ├── FolderViewer.js │ │ ├── Icons.js │ │ └── images │ │ │ ├── DownArrow.png │ │ │ └── SelectDownArrow.png │ ├── main.js │ ├── main │ │ ├── App.js │ │ ├── Icons.js │ │ └── images │ │ │ ├── CloudflareLogo.png │ │ │ └── FlyLogo.png │ └── utils │ │ ├── format.js │ │ ├── markup.js │ │ └── style.js ├── createServer.js ├── middleware │ ├── allowQuery.js │ ├── findEntry.js │ ├── noQuery.js │ ├── redirectLegacyURLs.js │ ├── requestLog.js │ ├── validateFilename.js │ ├── validatePackageName.js │ ├── validatePackagePathname.js │ └── validatePackageVersion.js ├── plugins │ ├── __tests__ │ │ ├── .eslintrc │ │ └── unpkgRewrite-test.js │ └── unpkgRewrite.js ├── server.js ├── templates │ └── MainTemplate.js └── utils │ ├── __tests__ │ ├── .eslintrc │ ├── createSearch-test.js │ ├── getContentType-test.js │ ├── getLanguageName-test.js │ └── parsePackagePathname-test.js │ ├── asyncHandler.js │ ├── bufferStream.js │ ├── cloudflare.js │ ├── createDataURI.js │ ├── createPackageURL.js │ ├── createSearch.js │ ├── encodeJSONForScript.js │ ├── getContentType.js │ ├── getContentTypeHeader.js │ ├── getHighlights.js │ ├── getIntegrity.js │ ├── getLanguageName.js │ ├── getScripts.js │ ├── getStats.js │ ├── isValidPackageName.js │ ├── markup.js │ ├── npm.js │ ├── parsePackagePathname.js │ └── rewriteBareModuleIdentifiers.js ├── package.json ├── plugins └── entryManifest.mjs ├── pnpm-lock.yaml ├── public ├── favicon.ico └── robots.txt ├── rollup.config.mjs ├── scripts ├── purge-cache.js ├── show-log.js └── utils │ ├── cloudflare.js │ └── unpkg.js ├── sponsors └── angular.png ├── start.js └── unpkg.sketch /.env.sample: -------------------------------------------------------------------------------- 1 | # config for private registry url 2 | NPM_REGISTRY_URL=https://registry.npmjs.org 3 | 4 | # your unpkg website url 5 | ORIGIN=https://npmcdn.lzw.me 6 | # port to listen on. default 8080 7 | PORT=8080 8 | 9 | # enableDebugging 10 | # DEBUG=1 11 | 12 | # Google Analytics MEASUREMENT_ID. your can set empty to disable it. 13 | GTAG_MEASUREMENT_ID=UA-140352188-1 14 | 15 | # ENABLE_CLOUDFLARE=1 16 | # CLOUDFLARE_EMAIL=test@lzw.me 17 | # CLOUDFLARE_KEY=test 18 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /public 2 | /server.js 3 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "react-app", 3 | "rules": { 4 | "import/first": 0 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: mjackson 2 | -------------------------------------------------------------------------------- /.github/workflows/docker-deploy.yml: -------------------------------------------------------------------------------- 1 | name: deploy to docker hub 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*.*.*" 7 | # branches: 8 | # - 'master' 9 | 10 | env: 11 | # Use docker.io for Docker Hub if empty 12 | REGISTRY: ${{ secrets.REGISTRY }} 13 | # github.repository as / 14 | IMAGE_NAME: ${{ github.repository }} 15 | 16 | jobs: 17 | docker-deploy: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - 21 | name: Checkout 22 | uses: actions/checkout@v4 23 | - uses: pnpm/action-setup@v3 24 | - 25 | uses: actions/setup-node@v4 26 | with: 27 | cache: "pnpm" 28 | 29 | - 30 | name: Setup timezone 31 | uses: szenius/set-timezone@v1.2 32 | with: 33 | timezoneLinux: Asia/Shanghai 34 | 35 | - 36 | name: Install dependencies 37 | run: | 38 | pnpm install 39 | pnpm build 40 | rm -rf tmp 41 | mkdir tmp 42 | cp package.json tmp 43 | cd tmp 44 | npm install --omit dev 45 | pnpm dlx @lzwme/fed-lint-helper rm -f "node_modules/**/{license,LICENSE,README.md,readme.md,.*,*.d.ts,*.flow,*.map}" 46 | 47 | # - 48 | # name: Set up QEMU 49 | # uses: docker/setup-qemu-action@v3 50 | # - 51 | # name: Set up Docker Buildx 52 | # uses: docker/setup-buildx-action@v3 53 | 54 | - 55 | name: Extract Docker metadata 56 | id: meta 57 | uses: docker/metadata-action@v5 58 | with: 59 | images: ${{ env.IMAGE_NAME }} 60 | tags: | 61 | # set latest tag for default branch 62 | type=raw,value=latest,enable={{is_default_branch}} 63 | # tag event 64 | type=ref,enable=true,priority=600,prefix=,suffix=,event=tag 65 | - 66 | name: Login to Docker Hub 67 | uses: docker/login-action@v3 68 | with: 69 | # registry: ghcr.io # 声明镜像源 70 | username: ${{ secrets.DOCKER_USERNAME }} 71 | password: ${{ secrets.DOCKER_PASSWORD }} 72 | - 73 | name: Build and push 74 | id: docker_build 75 | uses: docker/build-push-action@v5 76 | with: 77 | context: . 78 | # platforms: linux/amd64,linux/arm64 79 | push: ${{ github.event_name != 'pull_request' }} 80 | tags: ${{ steps.meta.outputs.tags }} 81 | labels: ${{ steps.meta.outputs.labels }} 82 | 83 | # - 84 | # name: Login to Docker Hub 85 | # run: | 86 | # docker login --username=${{ secrets.DOCKER_USERNAME }} --password ${{ secrets.DOCKER_PASSWORD }} ${{ env.REGISTRY }} 87 | # - 88 | # name: Build and push Docker Image 89 | # run: | 90 | # docker build -t ${{ env.REGISTRY }}/${{ steps.meta.outputs.tags }} -f Dockerfile . 91 | # docker push ${{ env.REGISTRY }}/${{ steps.meta.outputs.tags }} 92 | 93 | # - 94 | # name: Deploy Docker App 95 | # uses: appleboy/ssh-action@master 96 | # env: 97 | # TZ: Asia/Shanghai 98 | # with: 99 | # host: ${{ secrets.HOST }} 100 | # username: ${{ secrets.HOST_USERNAME }} 101 | # key: ${{ secrets.HOST_SSHKEY }} 102 | # port: ${{ secrets.PORT }} 103 | # script: | 104 | # wget https://raw.githubusercontent.com/${{ env.IMAGE_NAME }}/master/docker/docker-compose.yml 105 | # ls 106 | # cat docker-compose.yml 107 | # docker-compose down -v 108 | # docker-compose up -d 109 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: 'Tests' 2 | on: 3 | push: 4 | branches: 5 | - '**' 6 | paths-ignore: 7 | - README.md 8 | - CONTRIBUTING.md 9 | pull_request: 10 | branches: 11 | - '**' 12 | jobs: 13 | test: 14 | runs-on: ${{ matrix.os }} 15 | continue-on-error: ${{ matrix.os == 'windows-latest' }} 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | os: 20 | - ubuntu-latest 21 | - macos-latest 22 | # - windows-latest 23 | node-version: 24 | - 18 25 | name: Node ${{ matrix.node-version }} on ${{ matrix.os }} 26 | steps: 27 | - name: Checkout 28 | uses: actions/checkout@v3 29 | with: 30 | submodules: 'recursive' 31 | 32 | - name: Install Node.js 33 | uses: actions/setup-node@v3 34 | with: 35 | node-version: ${{ matrix.node-version }} 36 | # cache: 'pnpm' 37 | 38 | - uses: pnpm/action-setup@v2 39 | with: 40 | version: 8 41 | 42 | - name: Install dependencies 43 | run: pnpm install --ignore-scripts 44 | 45 | - name: Test 46 | run: pnpm test 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | firebase-debug.log* 4 | npm-debug.log* 5 | 6 | /.env 7 | /.env.local 8 | /.env.prod 9 | /node_modules/ 10 | /public/_client/ 11 | /secrets.tar 12 | /server.js 13 | /service-account-staging.json 14 | /service-account.json 15 | *.tgz 16 | /tmp 17 | /cache 18 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | strict-peer-dependencies=false 2 | shamefully-hoist=true 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package.json 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "singleQuote": true, 4 | "trailingComma": "none" 5 | } 6 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # FROM node:18-alpine as builder 2 | # COPY package.json .npmrc /tmp/build/ 3 | # RUN set -x \ 4 | # && apk update \ 5 | # && npm i -g pnpm \ 6 | # && cd /tmp/build \ 7 | # && npm install --omit dev \ 8 | # && npx @lzwme/fed-lint-helper rm -f "node_modules/**/{license,LICENSE,README.md,readme.md,.*,*.d.ts,*.flow,*.map}" 9 | 10 | FROM node:18-alpine As producton 11 | LABEL maintainer="renxia " 12 | 13 | WORKDIR /app 14 | 15 | COPY package.json .npmrc server.js ./ 16 | COPY public public 17 | COPY tmp/node_modules node_modules 18 | 19 | ENV TZ=Asia/Shanghai 20 | # ENV DEBUG=0 21 | ENV NODE_ENV=production 22 | ENV PORT=8080 23 | ENV NPM_REGISTRY_URL=https://registry.npmmirror.com 24 | ENV ORIGIN=https://unpkg.com 25 | ENV ENABLE_CLOUDFLARE=0 26 | ENV CLOUDFLARE_EMAIL='' 27 | ENV CLOUDFLARE_KEY='' 28 | 29 | EXPOSE 8080/tcp 30 | 31 | # RUN npm i -g pnpm --registry $NPM_REGISTRY_URL 32 | # RUN pnpm install --prod --registry $NPM_REGISTRY_URL 33 | 34 | RUN rm -rf /root/.cache \ 35 | && rm -rf /root/.pnpm-store \ 36 | && rm -rf /root/.local/share/pnpm/store/v3/files 37 | 38 | ENTRYPOINT [ "node", "server.js"] 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2019 Michael Jackson 2 | 3 | This program is free software: you can redistribute it and/or modify 4 | it under the terms of the GNU Affero General Public License as 5 | published by the Free Software Foundation, either version 3 of the 6 | License, or (at your option) any later version. 7 | 8 | This program is distributed in the hope that it will be useful, 9 | but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | GNU Affero General Public License for more details. 12 | 13 | You should have received a copy of the GNU Affero General Public License 14 | along with this program. If not, see . 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # UNPKG (fork) 2 | 3 | **The goals of this fork are:** 4 | 5 | * Make it easier to self-host Unpkg. 6 | * Keep it upstream compatible. 7 | * Keep dependencies up to date. 8 | * Make no functional changes or add new features. 9 | ------ 10 | 11 | [UNPKG](https://unpkg.com) is a fast, global [content delivery network](https://en.wikipedia.org/wiki/Content_delivery_network) for everything on [npm](https://www.npmjs.com/). 12 | 13 | ## Development 14 | 15 | prepare: 16 | 17 | ```bash 18 | git clone https://github.com/lzwme/unpkg.git 19 | cd unpkg 20 | pnpm i 21 | cp .env.sample .env.local # and edit the env.local config for local development 22 | ``` 23 | 24 | dev: 25 | 26 | ```bash 27 | pnpm watch 28 | pnpm serve 29 | ``` 30 | 31 | ## Build and deploy 32 | 33 | ```bash 34 | cp .env.sample .env.prod # and edit the env.prod config for production 35 | set NODE_ENV=production # or staging 36 | pnpm build 37 | pnpm pack 38 | ``` 39 | 40 | A file will be generated like `unpkg-.tgz`. 41 | Deploy it in your server with pm2: 42 | 43 | ```bash 44 | tar zxvf unpkg-.tgz 45 | cd package 46 | npm i --omit dev 47 | # pnpm i -P 48 | pm2 -n unpkg start.js 49 | ``` 50 | 51 | ## With Docker 52 | 53 | ```bash 54 | docker pull lzwme/unpkg 55 | docker run -d -p 8080:8080 -e NPM_REGISTRY_URL=https://registry.npmjs.org -e ORGIN=* lzwme/unpkg 56 | ``` 57 | 58 | ## Configuration with `.env[.prod|.local]` 59 | 60 | Learn more from the file [.env.sample](./.env.sample). 61 | 62 | ```yaml 63 | # config for private registry url 64 | NPM_REGISTRY_URL=https://registry.npmjs.org 65 | 66 | # your unpkg website url 67 | ORIGIN=https://npmcdn.lzw.me 68 | # port to listen on. default 8080 69 | PORT=8080 70 | 71 | # enableDebugging 72 | # DEBUG=1 73 | 74 | # Google Analytics MEASUREMENT_ID. your can set empty to disable it. 75 | GTAG_MEASUREMENT_ID=UA-140352188-1 76 | 77 | # ENABLE_CLOUDFLARE=1 78 | # CLOUDFLARE_EMAIL=test@lzw.me 79 | # CLOUDFLARE_KEY=test 80 | ``` 81 | 82 | ## Documentation 83 | 84 | Please visit [the UNPKG website](https://unpkg.com) to learn more about how to use it. 85 | 86 | ## Sponsors 87 | 88 | Our sponsors and backers are listed [in SPONSORS.md](SPONSORS.md). 89 | -------------------------------------------------------------------------------- /SPONSORS.md: -------------------------------------------------------------------------------- 1 |

Sponsors & Backers

2 | 3 | UNPKG is an open source project licensed under the AGPL. Ongoing development of UNPKG is made possible thanks to the support of our sponsors and backers. 4 | 5 | If you'd like to support the project, please consider becoming a backer or sponsor on Patreon. 6 | 7 |

 

8 | 9 |

Key Sponsors

10 | 11 |

12 | 13 | Angular Logo 14 | 15 |

16 | 17 |

Become a Key Sponsor

18 | 19 |

 

20 | 21 |

Star Sponsors

22 | 23 |

Become a Star Sponsor

24 | 25 |

 

26 | 27 |

Sponsors

28 | 29 |

Become a Sponsor

30 | 31 |

 

32 | 33 |

Backers

34 | 35 |

Become a Backer

36 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml file generated for frosty-dust-3645 on 2021-03-09T12:21:21-06:00 2 | 3 | app = "unpkg" 4 | 5 | kill_signal = "SIGINT" 6 | kill_timeout = 5 7 | 8 | [build] 9 | builder = "paketobuildpacks/builder:base" 10 | buildpacks = ["gcr.io/paketo-buildpacks/nodejs"] 11 | [build.args] 12 | NODE_ENV="development" # for build 13 | 14 | [env] 15 | PORT = "8080" 16 | NODE_ENV = "production" 17 | 18 | [[services]] 19 | internal_port = 8080 20 | protocol = "tcp" 21 | 22 | [services.concurrency] 23 | type = "requests" 24 | hard_limit = 60 25 | soft_limit = 40 26 | 27 | [[services.ports]] 28 | handlers = ["http"] 29 | port = "80" 30 | 31 | [[services.ports]] 32 | handlers = ["tls", "http"] 33 | port = "443" 34 | 35 | [[services.tcp_checks]] 36 | grace_period = "1s" 37 | interval = "10s" 38 | port = "8080" 39 | restart_limit = 5 40 | timeout = "2s" 41 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | moduleNameMapper: { 3 | '\\.css$': '/modules/__mocks__/styleMock.js', 4 | '\\.png$': '/modules/__mocks__/imageMock.js', 5 | 'entry-manifest': '/modules/__mocks__/entryManifest.js', 6 | 'getStats\\.js': '/modules/__mocks__/getStatsMock.js', 7 | 'utils\\/npm\\.js': '/modules/__mocks__/npmMock.js' 8 | }, 9 | testMatch: ['**/__tests__/*-test.js'], 10 | }; 11 | -------------------------------------------------------------------------------- /modules/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [["@babel/preset-env", { "loose": true, "targets": "node 8" }]] 3 | } 4 | -------------------------------------------------------------------------------- /modules/__mocks__/entryManifest.js: -------------------------------------------------------------------------------- 1 | export default []; 2 | -------------------------------------------------------------------------------- /modules/__mocks__/getStatsMock.js: -------------------------------------------------------------------------------- 1 | export default function getStats(since, until) { 2 | const stats = { 3 | since: since, 4 | until: until, 5 | requests: { 6 | all: 0, 7 | cached: 0, 8 | country: 0, 9 | status: 0 10 | }, 11 | bandwidth: { 12 | all: 0, 13 | cached: 0, 14 | country: 0 15 | }, 16 | threats: { 17 | all: 0, 18 | country: 0 19 | }, 20 | uniques: { 21 | all: 0 22 | } 23 | }; 24 | 25 | return Promise.resolve({ 26 | timeseries: [stats], 27 | totals: stats 28 | }); 29 | } 30 | -------------------------------------------------------------------------------- /modules/__mocks__/imageMock.js: -------------------------------------------------------------------------------- 1 | export default ''; 2 | -------------------------------------------------------------------------------- /modules/__mocks__/npmMock.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import gunzip from 'gunzip-maybe'; 4 | 5 | function getPackageInfo(packageName) { 6 | const file = path.resolve(__dirname, `./metadata/${packageName}.json`); 7 | 8 | try { 9 | return JSON.parse(fs.readFileSync(file, 'utf-8')); 10 | } catch (error) { 11 | return null; 12 | } 13 | } 14 | 15 | export function getVersionsAndTags(packageName) { 16 | const info = getPackageInfo(packageName); 17 | return info 18 | ? { versions: Object.keys(info.versions), tags: info['dist-tags'] } 19 | : []; 20 | } 21 | 22 | export function getPackageConfig(packageName, version) { 23 | const info = getPackageInfo(packageName); 24 | return info ? info.versions[version] : null; 25 | } 26 | 27 | export function getPackage(packageName, version) { 28 | const file = path.resolve( 29 | __dirname, 30 | `./packages/${packageName}-${version}.tgz` 31 | ); 32 | 33 | return fs.existsSync(file) ? fs.createReadStream(file).pipe(gunzip()) : null; 34 | } 35 | -------------------------------------------------------------------------------- /modules/__mocks__/packages/@babel/core-7.5.4.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lzwme/unpkg/7d5ae1db29690106a810b553dc92fe3a11456224/modules/__mocks__/packages/@babel/core-7.5.4.tgz -------------------------------------------------------------------------------- /modules/__mocks__/packages/preact-8.4.2.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lzwme/unpkg/7d5ae1db29690106a810b553dc92fe3a11456224/modules/__mocks__/packages/preact-8.4.2.tgz -------------------------------------------------------------------------------- /modules/__mocks__/packages/react-16.8.0.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lzwme/unpkg/7d5ae1db29690106a810b553dc92fe3a11456224/modules/__mocks__/packages/react-16.8.0.tgz -------------------------------------------------------------------------------- /modules/__mocks__/packages/sinuous-0.12.9.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lzwme/unpkg/7d5ae1db29690106a810b553dc92fe3a11456224/modules/__mocks__/packages/sinuous-0.12.9.tgz -------------------------------------------------------------------------------- /modules/__mocks__/styleMock.js: -------------------------------------------------------------------------------- 1 | export default {}; 2 | -------------------------------------------------------------------------------- /modules/__tests__/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "jest": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /modules/__tests__/browseDirectory-test.js: -------------------------------------------------------------------------------- 1 | import request from 'supertest'; 2 | 3 | import createServer from '../createServer.js'; 4 | 5 | describe('A request to browse a directory', () => { 6 | let server; 7 | beforeEach(() => { 8 | server = createServer(); 9 | }); 10 | 11 | describe('with no version specified', () => { 12 | it('redirects to the latest version', done => { 13 | request(server) 14 | .get('/browse/react/') 15 | .end((err, res) => { 16 | expect(res.statusCode).toBe(302); 17 | expect(res.headers.location).toMatch( 18 | /\/browse\/react@\d+\.\d+\.\d+\// 19 | ); 20 | done(); 21 | }); 22 | }); 23 | }); 24 | 25 | describe('when the directory exists', () => { 26 | it('returns an HTML page', done => { 27 | request(server) 28 | .get('/browse/react@16.8.0/umd/') 29 | .end((err, res) => { 30 | expect(res.statusCode).toBe(200); 31 | expect(res.headers['content-type']).toMatch(/\btext\/html\b/); 32 | done(); 33 | }); 34 | }); 35 | }); 36 | 37 | describe('when the directory does not exist', () => { 38 | it('returns a 404 HTML page', done => { 39 | request(server) 40 | .get('/browse/react@16.8.0/not-here/') 41 | .end((err, res) => { 42 | expect(res.statusCode).toBe(404); 43 | expect(res.headers['content-type']).toMatch(/\btext\/html\b/); 44 | done(); 45 | }); 46 | }); 47 | }); 48 | 49 | describe('with invalid query params', () => { 50 | it('strips them from the query string', done => { 51 | request(server) 52 | .get('/browse/react@16.8.0/umd/?invalid') 53 | .end((err, res) => { 54 | expect(res.statusCode).toBe(302); 55 | expect(res.headers.location).toEqual('/browse/react@16.8.0/umd/'); 56 | done(); 57 | }); 58 | }); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /modules/__tests__/browseFile-test.js: -------------------------------------------------------------------------------- 1 | import request from 'supertest'; 2 | 3 | import createServer from '../createServer.js'; 4 | 5 | describe('A request to browse a file', () => { 6 | let server; 7 | beforeEach(() => { 8 | server = createServer(); 9 | }); 10 | 11 | describe('when the file exists', () => { 12 | it('returns an HTML page', done => { 13 | request(server) 14 | .get('/browse/react@16.8.0/umd/react.production.min.js') 15 | .end((err, res) => { 16 | expect(res.statusCode).toBe(200); 17 | expect(res.headers['content-type']).toMatch(/\btext\/html\b/); 18 | done(); 19 | }); 20 | }); 21 | }); 22 | 23 | describe('when the file does not exist', () => { 24 | it('returns a 404 HTML page', done => { 25 | request(server) 26 | .get('/browse/react@16.8.0/not-here.js') 27 | .end((err, res) => { 28 | expect(res.statusCode).toBe(404); 29 | expect(res.headers['content-type']).toMatch(/\btext\/html\b/); 30 | done(); 31 | }); 32 | }); 33 | }); 34 | 35 | describe('when the URL contains invalid query params', () => { 36 | it('strips them from the URL', done => { 37 | request(server) 38 | .get('/browse/react@16.8.0/react.production.min.js?invalid') 39 | .end((err, res) => { 40 | expect(res.statusCode).toBe(302); 41 | expect(res.headers.location).toBe( 42 | '/browse/react@16.8.0/react.production.min.js' 43 | ); 44 | done(); 45 | }); 46 | }); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /modules/__tests__/browseRedirect-test.js: -------------------------------------------------------------------------------- 1 | import request from 'supertest'; 2 | 3 | import createServer from '../createServer.js'; 4 | 5 | describe('A request with a trailing slash', () => { 6 | let server; 7 | beforeEach(() => { 8 | server = createServer(); 9 | }); 10 | 11 | describe('that does not include the version number', () => { 12 | it('redirects to /browse', done => { 13 | request(server) 14 | .get('/react/') 15 | .end((err, res) => { 16 | expect(res.statusCode).toBe(302); 17 | expect(res.headers.location).toEqual('/browse/react/'); 18 | done(); 19 | }); 20 | }); 21 | }); 22 | 23 | describe('that includes the version number', () => { 24 | it('redirects to /browse', done => { 25 | request(server) 26 | .get('/react@16.8.0/') 27 | .end((err, res) => { 28 | expect(res.statusCode).toBe(302); 29 | expect(res.headers.location).toEqual('/browse/react@16.8.0/'); 30 | done(); 31 | }); 32 | }); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /modules/__tests__/directoryRedirect-test.js: -------------------------------------------------------------------------------- 1 | import request from 'supertest'; 2 | 3 | import createServer from '../createServer.js'; 4 | 5 | describe('A request for a directory', () => { 6 | let server; 7 | beforeEach(() => { 8 | server = createServer(); 9 | }); 10 | 11 | describe('when a .js file exists with the same name', () => { 12 | it('is redirected to the .js file', done => { 13 | request(server) 14 | .get('/preact@8.4.2/devtools') 15 | .end((err, res) => { 16 | expect(res.statusCode).toBe(302); 17 | expect(res.headers.location).toEqual('/preact@8.4.2/devtools.js'); 18 | done(); 19 | }); 20 | }); 21 | }); 22 | 23 | describe('when a .json file exists with the same name', () => { 24 | it.todo('is redirected to the .json file'); 25 | }); 26 | 27 | describe('when it contains an index.js file', () => { 28 | it('is redirected to the index.js file', done => { 29 | request(server) 30 | .get('/preact@8.4.2/src/dom') 31 | .end((err, res) => { 32 | expect(res.statusCode).toBe(302); 33 | expect(res.headers.location).toEqual( 34 | '/preact@8.4.2/src/dom/index.js' 35 | ); 36 | done(); 37 | }); 38 | }); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /modules/__tests__/invalidPackageNames-test.js: -------------------------------------------------------------------------------- 1 | import request from 'supertest'; 2 | 3 | import createServer from '../createServer'; 4 | 5 | describe('Invalid package names', () => { 6 | let server; 7 | beforeEach(() => { 8 | server = createServer(); 9 | }); 10 | 11 | it('are rejected', done => { 12 | request(server) 13 | .get('/_invalid/index.js') 14 | .end((err, res) => { 15 | expect(res.statusCode).toBe(403); 16 | done(); 17 | }); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /modules/__tests__/legacyURLs-test.js: -------------------------------------------------------------------------------- 1 | import request from 'supertest'; 2 | 3 | import createServer from '../createServer'; 4 | 5 | describe('Legacy URLs', () => { 6 | let server; 7 | beforeEach(() => { 8 | server = createServer(); 9 | }); 10 | 11 | it('redirect /_meta to ?meta', done => { 12 | request(server) 13 | .get('/_meta/react') 14 | .end((err, res) => { 15 | expect(res.statusCode).toBe(301); 16 | expect(res.headers.location).toBe('/react?meta'); 17 | done(); 18 | }); 19 | }); 20 | 21 | it('redirect ?json to ?meta', done => { 22 | request(server) 23 | .get('/react?json') 24 | .end((err, res) => { 25 | expect(res.statusCode).toBe(301); 26 | expect(res.headers.location).toBe('/react?meta'); 27 | done(); 28 | }); 29 | }); 30 | 31 | it('redirect */ to /browse/*/', done => { 32 | request(server) 33 | .get('/react@16.8.0/umd/') 34 | .end((err, res) => { 35 | expect(res.statusCode).toBe(302); 36 | expect(res.headers.location).toEqual('/browse/react@16.8.0/umd/'); 37 | done(); 38 | }); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /modules/__tests__/meta-test.js: -------------------------------------------------------------------------------- 1 | import request from 'supertest'; 2 | 3 | import createServer from '../createServer.js'; 4 | 5 | describe('A request for metadata', () => { 6 | let server; 7 | beforeEach(() => { 8 | server = createServer(); 9 | }); 10 | 11 | it('returns 200', done => { 12 | request(server) 13 | .get('/react@16.8.0/?meta') 14 | .end((err, res) => { 15 | expect(res.statusCode).toBe(200); 16 | expect(res.headers['content-type']).toMatch(/\bapplication\/json\b/); 17 | done(); 18 | }); 19 | }); 20 | 21 | describe('with a package that includes a root "directory" entry', () => { 22 | it('returns 200', done => { 23 | request(server) 24 | .get('/sinuous@0.12.9/?meta') 25 | .end((err, res) => { 26 | expect(res.statusCode).toBe(200); 27 | expect(res.headers['content-type']).toMatch(/\bapplication\/json\b/); 28 | done(); 29 | }); 30 | }); 31 | }); 32 | 33 | describe('when the URL includes invalid query parameters', () => { 34 | it('removes them from the URL', done => { 35 | request(server) 36 | .get('/react@16.8.0/?meta&invalid') 37 | .end((err, res) => { 38 | expect(res.statusCode).toBe(302); 39 | expect(res.headers.location).toBe('/react@16.8.0/?meta'); 40 | done(); 41 | }); 42 | }); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /modules/__tests__/missingFile-test.js: -------------------------------------------------------------------------------- 1 | import request from 'supertest'; 2 | 3 | import createServer from '../createServer.js'; 4 | 5 | describe('A request for a non-existent file', () => { 6 | let server; 7 | beforeEach(() => { 8 | server = createServer(); 9 | }); 10 | 11 | it('returns a 404 text error', done => { 12 | request(server) 13 | .get('/preact@8.4.2/not-here.js') 14 | .end((err, res) => { 15 | expect(res.statusCode).toBe(404); 16 | expect(res.headers['content-type']).toMatch(/\btext\/plain\b/); 17 | done(); 18 | }); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /modules/__tests__/module-test.js: -------------------------------------------------------------------------------- 1 | import request from 'supertest'; 2 | 3 | import createServer from '../createServer.js'; 4 | 5 | describe('A request for a module', () => { 6 | let server; 7 | beforeEach(() => { 8 | server = createServer(); 9 | }); 10 | 11 | // TODO: More tests here 12 | 13 | describe('when the URL includes invalid query parameters', () => { 14 | it('removes them from the URL', done => { 15 | request(server) 16 | .get('/react@16.8.0/?module&invalid') 17 | .end((err, res) => { 18 | expect(res.statusCode).toBe(302); 19 | expect(res.headers.location).toBe('/react@16.8.0/?module'); 20 | done(); 21 | }); 22 | }); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /modules/__tests__/regularFile-test.js: -------------------------------------------------------------------------------- 1 | import request from 'supertest'; 2 | 3 | import createServer from '../createServer.js'; 4 | 5 | describe('A request for a JavaScript file', () => { 6 | let server; 7 | beforeEach(() => { 8 | server = createServer(); 9 | }); 10 | 11 | it('returns 200', done => { 12 | request(server) 13 | .get('/react@16.8.0/index.js') 14 | .end((err, res) => { 15 | expect(res.statusCode).toBe(200); 16 | expect(res.headers['content-type']).toMatch( 17 | /\bapplication\/javascript\b/ 18 | ); 19 | expect(res.headers['content-type']).toMatch(/\bcharset=utf-8\b/); 20 | done(); 21 | }); 22 | }); 23 | 24 | describe('from a scoped package', () => { 25 | it('returns 200', done => { 26 | request(server) 27 | .get('/@babel/core@7.5.4/lib/index.js') 28 | .end((err, res) => { 29 | expect(res.statusCode).toBe(200); 30 | expect(res.headers['content-type']).toMatch( 31 | /\bapplication\/javascript\b/ 32 | ); 33 | expect(res.headers['content-type']).toMatch(/\bcharset=utf-8\b/); 34 | done(); 35 | }); 36 | }); 37 | }); 38 | 39 | describe('when the URL has invalid query params', () => { 40 | it('removes them from the URL', done => { 41 | request(server) 42 | .get('/react@16.8.0/index.js?invalid') 43 | .end((err, res) => { 44 | expect(res.statusCode).toBe(302); 45 | expect(res.headers.location).toBe('/react@16.8.0/index.js'); 46 | done(); 47 | }); 48 | }); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /modules/__tests__/stats-test.js: -------------------------------------------------------------------------------- 1 | import request from 'supertest'; 2 | 3 | import createServer from '../createServer.js'; 4 | 5 | describe('A request for stats', () => { 6 | let server; 7 | beforeEach(() => { 8 | server = createServer(); 9 | }); 10 | 11 | it('returns a 200 JSON response', done => { 12 | request(server) 13 | .get('/api/stats') 14 | .end((err, res) => { 15 | expect(res.statusCode).toBe(200); 16 | expect(res.headers['content-type']).toMatch(/\bapplication\/json\b/); 17 | done(); 18 | }); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /modules/actions/serveBrowsePage.js: -------------------------------------------------------------------------------- 1 | import { renderToString, renderToStaticMarkup } from 'react-dom/server'; 2 | import semver from 'semver'; 3 | 4 | import BrowseApp from '../client/browse/App.js'; 5 | import MainTemplate from '../templates/MainTemplate.js'; 6 | import asyncHandler from '../utils/asyncHandler.js'; 7 | import getScripts from '../utils/getScripts.js'; 8 | import { createElement, createHTML } from '../utils/markup.js'; 9 | import { getVersionsAndTags } from '../utils/npm.js'; 10 | 11 | const doctype = ''; 12 | const globalURLs = 13 | process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'staging' 14 | ? { 15 | '@emotion/react': '/@emotion/react@11.10.5/dist/emotion-react.umd.min.js', 16 | react: '/react@18.2.0/umd/react.production.min.js', 17 | 'react-dom': '/react-dom@18.2.0/umd/react-dom.production.min.js' 18 | } 19 | : { 20 | '@emotion/react': '/@emotion/react@11.10.5/dist/emotion-react.umd.min.js', 21 | react: '/react@18.2.0/umd/react.development.js', 22 | 'react-dom': '/react-dom@18.2.0/umd/react-dom.development.js' 23 | }; 24 | 25 | function byVersion(a, b) { 26 | return semver.lt(a, b) ? -1 : semver.gt(a, b) ? 1 : 0; 27 | } 28 | 29 | async function getAvailableVersions(packageName, log) { 30 | const versionsAndTags = await getVersionsAndTags(packageName, log); 31 | return versionsAndTags ? versionsAndTags.versions.sort(byVersion) : []; 32 | } 33 | 34 | async function serveBrowsePage(req, res) { 35 | const availableVersions = await getAvailableVersions( 36 | req.packageName, 37 | req.log 38 | ); 39 | const data = { 40 | packageName: req.packageName, 41 | packageVersion: req.packageVersion, 42 | availableVersions: availableVersions, 43 | filename: req.filename, 44 | target: req.browseTarget 45 | }; 46 | const content = createHTML(renderToString(createElement(BrowseApp, data))); 47 | const elements = getScripts('browse', 'iife', globalURLs); 48 | 49 | const html = 50 | doctype + 51 | renderToStaticMarkup( 52 | createElement(MainTemplate, { 53 | title: `UNPKG - ${req.packageName}`, 54 | description: `The CDN for ${req.packageName}`, 55 | data, 56 | content, 57 | elements 58 | }) 59 | ); 60 | 61 | res 62 | .set({ 63 | 'Cache-Control': 'public, max-age=14400', // 4 hours 64 | 'Cache-Tag': 'browse' 65 | }) 66 | .send(html); 67 | } 68 | 69 | export default asyncHandler(serveBrowsePage); 70 | -------------------------------------------------------------------------------- /modules/actions/serveDirectoryBrowser.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import tar from 'tar-stream'; 3 | 4 | import asyncHandler from '../utils/asyncHandler.js'; 5 | import bufferStream from '../utils/bufferStream.js'; 6 | import getContentType from '../utils/getContentType.js'; 7 | import getIntegrity from '../utils/getIntegrity.js'; 8 | import { getPackage } from '../utils/npm.js'; 9 | import serveBrowsePage from './serveBrowsePage.js'; 10 | 11 | async function findMatchingEntries(stream, filename) { 12 | // filename = /some/dir/name 13 | return new Promise((accept, reject) => { 14 | const entries = {}; 15 | 16 | stream 17 | .pipe(tar.extract()) 18 | .on('error', reject) 19 | .on('entry', async (header, stream, next) => { 20 | const entry = { 21 | // Most packages have header names that look like `package/index.js` 22 | // so we shorten that to just `/index.js` here. A few packages use a 23 | // prefix other than `package/`. e.g. the firebase package uses the 24 | // `firebase_npm/` prefix. So we just strip the first dir name. 25 | path: header.name.replace(/^[^/]+\/?/, '/'), 26 | type: header.type 27 | }; 28 | 29 | // Dynamically create "directory" entries for all subdirectories 30 | // in this entry's path. Some tarballs omit directory entries for 31 | // some reason, so this is the "brute force" method. 32 | let dir = path.dirname(entry.path); 33 | while (dir !== '/') { 34 | if (!entries[dir] && path.dirname(dir) === filename) { 35 | entries[dir] = { path: dir, type: 'directory' }; 36 | } 37 | dir = path.dirname(dir); 38 | } 39 | 40 | // Ignore non-files and files that aren't in this directory. 41 | if (entry.type !== 'file' || path.dirname(entry.path) !== filename) { 42 | stream.resume(); 43 | stream.on('end', next); 44 | return; 45 | } 46 | 47 | try { 48 | const content = await bufferStream(stream); 49 | 50 | entry.contentType = getContentType(entry.path); 51 | entry.integrity = getIntegrity(content); 52 | entry.size = content.length; 53 | 54 | entries[entry.path] = entry; 55 | 56 | next(); 57 | } catch (error) { 58 | next(error); 59 | } 60 | }) 61 | .on('finish', () => { 62 | accept(entries); 63 | }); 64 | }); 65 | } 66 | 67 | async function serveDirectoryBrowser(req, res, next) { 68 | const stream = await getPackage(req.packageName, req.packageVersion, req.log); 69 | 70 | const filename = req.filename.slice(0, -1) || '/'; 71 | const entries = await findMatchingEntries(stream, filename); 72 | 73 | if (Object.keys(entries).length === 0) { 74 | return res.status(404).send(`Not found: ${req.packageSpec}${req.filename}`); 75 | } 76 | 77 | req.browseTarget = { 78 | path: filename, 79 | type: 'directory', 80 | details: entries 81 | }; 82 | 83 | serveBrowsePage(req, res, next); 84 | } 85 | 86 | export default asyncHandler(serveDirectoryBrowser); 87 | -------------------------------------------------------------------------------- /modules/actions/serveDirectoryMetadata.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import tar from 'tar-stream'; 3 | 4 | import asyncHandler from '../utils/asyncHandler.js'; 5 | import bufferStream from '../utils/bufferStream.js'; 6 | import getContentType from '../utils/getContentType.js'; 7 | import getIntegrity from '../utils/getIntegrity.js'; 8 | import { getPackage } from '../utils/npm.js'; 9 | 10 | async function findMatchingEntries(stream, filename) { 11 | // filename = /some/dir/name 12 | return new Promise((accept, reject) => { 13 | const entries = {}; 14 | 15 | entries[filename] = { path: filename, type: 'directory' }; 16 | 17 | stream 18 | .pipe(tar.extract()) 19 | .on('error', reject) 20 | .on('entry', async (header, stream, next) => { 21 | const entry = { 22 | // Most packages have header names that look like `package/index.js` 23 | // so we shorten that to just `/index.js` here. A few packages use a 24 | // prefix other than `package/`. e.g. the firebase package uses the 25 | // `firebase_npm/` prefix. So we just strip the first dir name. 26 | path: header.name.replace(/^[^/]+\/?/, '/'), 27 | type: header.type 28 | }; 29 | 30 | // Dynamically create "directory" entries for all subdirectories 31 | // in this entry's path. Some tarballs omit directory entries for 32 | // some reason, so this is the "brute force" method. 33 | let dir = path.dirname(entry.path); 34 | while (dir !== '/') { 35 | if (!entries[dir] && dir.startsWith(filename)) { 36 | entries[dir] = { path: dir, type: 'directory' }; 37 | } 38 | dir = path.dirname(dir); 39 | } 40 | 41 | // Ignore non-files and files that don't match the prefix. 42 | if (entry.type !== 'file' || !entry.path.startsWith(filename)) { 43 | stream.resume(); 44 | stream.on('end', next); 45 | return; 46 | } 47 | 48 | try { 49 | const content = await bufferStream(stream); 50 | 51 | entry.contentType = getContentType(entry.path); 52 | entry.integrity = getIntegrity(content); 53 | entry.lastModified = header.mtime.toUTCString(); 54 | entry.size = content.length; 55 | 56 | entries[entry.path] = entry; 57 | 58 | next(); 59 | } catch (error) { 60 | next(error); 61 | } 62 | }) 63 | .on('finish', () => { 64 | accept(entries); 65 | }); 66 | }); 67 | } 68 | 69 | function getMatchingEntries(entry, entries) { 70 | return Object.keys(entries) 71 | .filter(key => entry.path !== key && path.dirname(key) === entry.path) 72 | .map(key => entries[key]); 73 | } 74 | 75 | function getMetadata(entry, entries) { 76 | const metadata = { path: entry.path, type: entry.type }; 77 | 78 | if (entry.type === 'file') { 79 | metadata.contentType = entry.contentType; 80 | metadata.integrity = entry.integrity; 81 | metadata.lastModified = entry.lastModified; 82 | metadata.size = entry.size; 83 | } else if (entry.type === 'directory') { 84 | metadata.files = getMatchingEntries(entry, entries).map(e => 85 | getMetadata(e, entries) 86 | ); 87 | } 88 | 89 | return metadata; 90 | } 91 | 92 | async function serveDirectoryMetadata(req, res) { 93 | const stream = await getPackage(req.packageName, req.packageVersion, req.log); 94 | 95 | const filename = req.filename.slice(0, -1) || '/'; 96 | const entries = await findMatchingEntries(stream, filename); 97 | const metadata = getMetadata(entries[filename], entries); 98 | 99 | res.send(metadata); 100 | } 101 | 102 | export default asyncHandler(serveDirectoryMetadata); 103 | -------------------------------------------------------------------------------- /modules/actions/serveFile.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import etag from 'etag'; 3 | 4 | import getContentTypeHeader from '../utils/getContentTypeHeader.js'; 5 | 6 | export default function serveFile(req, res) { 7 | const tags = ['file']; 8 | 9 | const ext = path.extname(req.entry.path).substr(1); 10 | if (ext) { 11 | tags.push(`${ext}-file`); 12 | } 13 | 14 | res 15 | .set({ 16 | 'Cross-Origin-Resource-Policy': 'cross-origin', 17 | 'Content-Type': getContentTypeHeader(req.entry.contentType), 18 | 'Content-Length': req.entry.size, 19 | 'Cache-Control': 'public, max-age=31536000', // 1 year 20 | 'Last-Modified': req.entry.lastModified, 21 | ETag: etag(req.entry.content), 22 | 'Cache-Tag': tags.join(', ') 23 | }) 24 | .send(req.entry.content); 25 | } 26 | -------------------------------------------------------------------------------- /modules/actions/serveFileBrowser.js: -------------------------------------------------------------------------------- 1 | import tar from 'tar-stream'; 2 | 3 | import asyncHandler from '../utils/asyncHandler.js'; 4 | import bufferStream from '../utils/bufferStream.js'; 5 | import createDataURI from '../utils/createDataURI.js'; 6 | import getContentType from '../utils/getContentType.js'; 7 | import getIntegrity from '../utils/getIntegrity.js'; 8 | import { getPackage } from '../utils/npm.js'; 9 | import getHighlights from '../utils/getHighlights.js'; 10 | import getLanguageName from '../utils/getLanguageName.js'; 11 | 12 | import serveBrowsePage from './serveBrowsePage.js'; 13 | 14 | async function findEntry(stream, filename) { 15 | // filename = /some/file/name.js 16 | return new Promise((accept, reject) => { 17 | let foundEntry = null; 18 | 19 | stream 20 | .pipe(tar.extract()) 21 | .on('error', reject) 22 | .on('entry', async (header, stream, next) => { 23 | const entry = { 24 | // Most packages have header names that look like `package/index.js` 25 | // so we shorten that to just `/index.js` here. A few packages use a 26 | // prefix other than `package/`. e.g. the firebase package uses the 27 | // `firebase_npm/` prefix. So we just strip the first dir name. 28 | path: header.name.replace(/^[^/]+\/?/, '/'), 29 | type: header.type 30 | }; 31 | 32 | // Ignore non-files and files that don't match the name. 33 | if (entry.type !== 'file' || entry.path !== filename) { 34 | stream.resume(); 35 | stream.on('end', next); 36 | return; 37 | } 38 | 39 | try { 40 | entry.content = await bufferStream(stream); 41 | 42 | foundEntry = entry; 43 | 44 | next(); 45 | } catch (error) { 46 | next(error); 47 | } 48 | }) 49 | .on('finish', () => { 50 | accept(foundEntry); 51 | }); 52 | }); 53 | } 54 | 55 | async function serveFileBrowser(req, res) { 56 | const stream = await getPackage(req.packageName, req.packageVersion, req.log); 57 | const entry = await findEntry(stream, req.filename); 58 | 59 | if (!entry) { 60 | return res.status(404).send(`Not found: ${req.packageSpec}${req.filename}`); 61 | } 62 | 63 | const details = { 64 | contentType: getContentType(entry.path), 65 | integrity: getIntegrity(entry.content), 66 | language: getLanguageName(entry.path), 67 | size: entry.content.length 68 | }; 69 | 70 | if (/^image\//.test(details.contentType)) { 71 | details.uri = createDataURI(details.contentType, entry.content); 72 | details.highlights = null; 73 | } else { 74 | details.uri = null; 75 | details.highlights = getHighlights( 76 | entry.content.toString('utf8'), 77 | entry.path 78 | ); 79 | } 80 | 81 | req.browseTarget = { 82 | path: req.filename, 83 | type: 'file', 84 | details 85 | }; 86 | 87 | serveBrowsePage(req, res); 88 | } 89 | 90 | export default asyncHandler(serveFileBrowser); 91 | -------------------------------------------------------------------------------- /modules/actions/serveFileMetadata.js: -------------------------------------------------------------------------------- 1 | import tar from 'tar-stream'; 2 | 3 | import asyncHandler from '../utils/asyncHandler.js'; 4 | import bufferStream from '../utils/bufferStream.js'; 5 | import getContentType from '../utils/getContentType.js'; 6 | import getIntegrity from '../utils/getIntegrity.js'; 7 | import { getPackage } from '../utils/npm.js'; 8 | 9 | async function findEntry(stream, filename) { 10 | // filename = /some/file/name.js 11 | return new Promise((accept, reject) => { 12 | let foundEntry = null; 13 | 14 | stream 15 | .pipe(tar.extract()) 16 | .on('error', reject) 17 | .on('entry', async (header, stream, next) => { 18 | const entry = { 19 | // Most packages have header names that look like `package/index.js` 20 | // so we shorten that to just `/index.js` here. A few packages use a 21 | // prefix other than `package/`. e.g. the firebase package uses the 22 | // `firebase_npm/` prefix. So we just strip the first dir name. 23 | path: header.name.replace(/^[^/]+\/?/, '/'), 24 | type: header.type 25 | }; 26 | 27 | // Ignore non-files and files that don't match the name. 28 | if (entry.type !== 'file' || entry.path !== filename) { 29 | stream.resume(); 30 | stream.on('end', next); 31 | return; 32 | } 33 | 34 | try { 35 | const content = await bufferStream(stream); 36 | 37 | entry.contentType = getContentType(entry.path); 38 | entry.integrity = getIntegrity(content); 39 | entry.lastModified = header.mtime.toUTCString(); 40 | entry.size = content.length; 41 | 42 | foundEntry = entry; 43 | 44 | next(); 45 | } catch (error) { 46 | next(error); 47 | } 48 | }) 49 | .on('finish', () => { 50 | accept(foundEntry); 51 | }); 52 | }); 53 | } 54 | 55 | async function serveFileMetadata(req, res) { 56 | const stream = await getPackage(req.packageName, req.packageVersion, req.log); 57 | const entry = await findEntry(stream, req.filename); 58 | 59 | if (!entry) { 60 | // TODO: 404 61 | } 62 | 63 | res.send(entry); 64 | } 65 | 66 | export default asyncHandler(serveFileMetadata); 67 | -------------------------------------------------------------------------------- /modules/actions/serveHTMLModule.js: -------------------------------------------------------------------------------- 1 | import etag from 'etag'; 2 | import cheerio from 'cheerio'; 3 | 4 | import getContentTypeHeader from '../utils/getContentTypeHeader.js'; 5 | import rewriteBareModuleIdentifiers from '../utils/rewriteBareModuleIdentifiers.js'; 6 | 7 | export default function serveHTMLModule(req, res) { 8 | try { 9 | const $ = cheerio.load(req.entry.content.toString('utf8')); 10 | 11 | $('script[type=module]').each((index, element) => { 12 | $(element).html( 13 | rewriteBareModuleIdentifiers($(element).html(), req.packageConfig) 14 | ); 15 | }); 16 | 17 | const code = $.html(); 18 | 19 | res 20 | .set({ 21 | 'Content-Length': Buffer.byteLength(code), 22 | 'Content-Type': getContentTypeHeader(req.entry.contentType), 23 | 'Cache-Control': 'public, max-age=31536000', // 1 year 24 | ETag: etag(code), 25 | 'Cache-Tag': 'file, html-file, html-module' 26 | }) 27 | .send(code); 28 | } catch (error) { 29 | console.error(error); 30 | 31 | const errorName = error.constructor.name; 32 | const errorMessage = error.message.replace( 33 | /^.*?\/unpkg-.+?\//, 34 | `/${req.packageSpec}/` 35 | ); 36 | const codeFrame = error.codeFrame; 37 | const debugInfo = `${errorName}: ${errorMessage}\n\n${codeFrame}`; 38 | 39 | res 40 | .status(500) 41 | .type('text') 42 | .send( 43 | `Cannot generate module for ${req.packageSpec}${req.filename}\n\n${debugInfo}` 44 | ); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /modules/actions/serveJavaScriptModule.js: -------------------------------------------------------------------------------- 1 | import etag from 'etag'; 2 | 3 | import getContentTypeHeader from '../utils/getContentTypeHeader.js'; 4 | import rewriteBareModuleIdentifiers from '../utils/rewriteBareModuleIdentifiers.js'; 5 | 6 | export default function serveJavaScriptModule(req, res) { 7 | try { 8 | const code = rewriteBareModuleIdentifiers( 9 | req.entry.content.toString('utf8'), 10 | req.packageConfig 11 | ); 12 | 13 | res 14 | .set({ 15 | 'Cross-Origin-Resource-Policy': 'cross-origin', 16 | 'Content-Length': Buffer.byteLength(code), 17 | 'Content-Type': getContentTypeHeader(req.entry.contentType), 18 | 'Cache-Control': 'public, max-age=31536000', // 1 year 19 | ETag: etag(code), 20 | 'Cache-Tag': 'file, js-file, js-module' 21 | }) 22 | .send(code); 23 | } catch (error) { 24 | console.error(error); 25 | 26 | const errorName = error.constructor.name; 27 | const errorMessage = error.message.replace( 28 | /^.*?\/unpkg-.+?\//, 29 | `/${req.packageSpec}/` 30 | ); 31 | const codeFrame = error.codeFrame; 32 | const debugInfo = `${errorName}: ${errorMessage}\n\n${codeFrame}`; 33 | 34 | res 35 | .status(500) 36 | .type('text') 37 | .send( 38 | `Cannot generate module for ${req.packageSpec}${req.filename}\n\n${debugInfo}` 39 | ); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /modules/actions/serveMainPage.js: -------------------------------------------------------------------------------- 1 | import { renderToString, renderToStaticMarkup } from 'react-dom/server'; 2 | 3 | import MainApp from '../client/main/App.js'; 4 | import MainTemplate from '../templates/MainTemplate.js'; 5 | import getScripts from '../utils/getScripts.js'; 6 | import { createElement, createHTML } from '../utils/markup.js'; 7 | 8 | const doctype = ''; 9 | const globalURLs = 10 | process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'staging' 11 | ? { 12 | '@emotion/react': '/@emotion/react@11.10.5/dist/emotion-react.umd.min.js', 13 | react: '/react@18.2.0/umd/react.production.min.js', 14 | 'react-dom': '/react-dom@18.2.0/umd/react-dom.production.min.js' 15 | } 16 | : { 17 | '@emotion/react': '/@emotion/react@11.10.5/dist/emotion-react.umd.min.js', 18 | react: '/react@18.2.0/umd/react.development.js', 19 | 'react-dom': '/react-dom@18.2.0/umd/react-dom.development.js' 20 | }; 21 | 22 | export default function serveMainPage(req, res) { 23 | const content = createHTML(renderToString(createElement(MainApp))); 24 | const elements = getScripts('main', 'iife', globalURLs); 25 | 26 | const html = 27 | doctype + 28 | renderToStaticMarkup(createElement(MainTemplate, { content, elements })); 29 | 30 | res 31 | .set({ 32 | 'Cache-Control': 'public, max-age=14400', // 4 hours 33 | 'Cache-Tag': 'main' 34 | }) 35 | .send(html); 36 | } 37 | -------------------------------------------------------------------------------- /modules/actions/serveModule.js: -------------------------------------------------------------------------------- 1 | import serveHTMLModule from './serveHTMLModule.js'; 2 | import serveJavaScriptModule from './serveJavaScriptModule.js'; 3 | 4 | export default function serveModule(req, res) { 5 | if (req.entry.contentType === 'application/javascript') { 6 | return serveJavaScriptModule(req, res); 7 | } 8 | 9 | if (req.entry.contentType === 'text/html') { 10 | return serveHTMLModule(req, res); 11 | } 12 | 13 | res 14 | .status(403) 15 | .type('text') 16 | .send('module mode is available only for JavaScript and HTML files'); 17 | } 18 | -------------------------------------------------------------------------------- /modules/actions/serveStats.js: -------------------------------------------------------------------------------- 1 | import { subDays, startOfDay } from 'date-fns'; 2 | 3 | import getStats from '../utils/getStats.js'; 4 | 5 | export default function serveStats(req, res) { 6 | if (process.env.ENABLE_CLOUDFLARE !== '1') return res.send({ error: 'disabled' }); 7 | 8 | let since, until; 9 | if (req.query.period) { 10 | switch (req.query.period) { 11 | case 'last-day': 12 | until = startOfDay(new Date()); 13 | since = subDays(until, 1); 14 | break; 15 | case 'last-week': 16 | until = startOfDay(new Date()); 17 | since = subDays(until, 7); 18 | break; 19 | case 'last-month': 20 | default: 21 | until = startOfDay(new Date()); 22 | since = subDays(until, 30); 23 | } 24 | } else { 25 | until = req.query.until 26 | ? new Date(req.query.until) 27 | : startOfDay(new Date()); 28 | since = req.query.since ? new Date(req.query.since) : subDays(until, 1); 29 | } 30 | 31 | if (isNaN(since.getTime())) { 32 | return res.status(403).send({ error: '?since is not a valid date' }); 33 | } 34 | 35 | if (isNaN(until.getTime())) { 36 | return res.status(403).send({ error: '?until is not a valid date' }); 37 | } 38 | 39 | if (until <= since) { 40 | return res 41 | .status(403) 42 | .send({ error: '?until date must come after ?since date' }); 43 | } 44 | 45 | if (until >= new Date()) { 46 | return res.status(403).send({ error: '?until must be a date in the past' }); 47 | } 48 | 49 | getStats(since, until).then( 50 | stats => { 51 | res 52 | .set({ 53 | 'Cache-Control': 'public, max-age=3600', // 1 hour 54 | 'Cache-Tag': 'stats' 55 | }) 56 | .send(stats); 57 | }, 58 | error => { 59 | console.error(error); 60 | res.status(500).send({ error: 'Unable to fetch stats' }); 61 | } 62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /modules/client/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/preset-env", { "loose": true, "targets": "> 0.25%, not dead" }], 4 | "@babel/preset-react" 5 | ], 6 | "plugins": [["@babel/plugin-proposal-class-properties", { "loose": true }]] 7 | } 8 | -------------------------------------------------------------------------------- /modules/client/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /modules/client/browse.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | import App from './browse/App.js'; 5 | 6 | const props = window.__DATA__ || {}; 7 | 8 | ReactDOM.hydrateRoot(document.getElementById('root'), ); 9 | -------------------------------------------------------------------------------- /modules/client/browse/App.js: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import { Global, css, jsx } from '@emotion/react'; 3 | import { Fragment } from 'react'; 4 | import PropTypes from 'prop-types'; 5 | 6 | import { fontSans, fontMono } from '../utils/style.js'; 7 | 8 | import FolderViewer from './FolderViewer.js'; 9 | import FileViewer from './FileViewer.js'; 10 | import { TwitterIcon, GitHubIcon } from './Icons.js'; 11 | 12 | import SelectDownArrow from './images/SelectDownArrow.png'; 13 | 14 | const buildId = process.env.BUILD_ID; 15 | 16 | const globalStyles = css` 17 | html { 18 | box-sizing: border-box; 19 | } 20 | *, 21 | *:before, 22 | *:after { 23 | box-sizing: inherit; 24 | } 25 | 26 | html, 27 | body, 28 | #root { 29 | height: 100%; 30 | margin: 0; 31 | } 32 | 33 | body { 34 | ${fontSans} 35 | font-size: 16px; 36 | line-height: 1.5; 37 | overflow-wrap: break-word; 38 | background: white; 39 | color: black; 40 | } 41 | 42 | code { 43 | ${fontMono} 44 | } 45 | 46 | th, 47 | td { 48 | padding: 0; 49 | } 50 | 51 | select { 52 | font-size: inherit; 53 | } 54 | 55 | #root { 56 | display: flex; 57 | flex-direction: column; 58 | } 59 | `; 60 | 61 | // Adapted from https://github.com/highlightjs/highlight.js/blob/master/src/styles/atom-one-light.css 62 | const lightCodeStyles = css` 63 | .code-listing { 64 | background: #fbfdff; 65 | color: #383a42; 66 | } 67 | .code-comment, 68 | .code-quote { 69 | color: #a0a1a7; 70 | font-style: italic; 71 | } 72 | .code-doctag, 73 | .code-keyword, 74 | .code-link, 75 | .code-formula { 76 | color: #a626a4; 77 | } 78 | .code-section, 79 | .code-name, 80 | .code-selector-tag, 81 | .code-deletion, 82 | .code-subst { 83 | color: #e45649; 84 | } 85 | .code-literal { 86 | color: #0184bb; 87 | } 88 | .code-string, 89 | .code-regexp, 90 | .code-addition, 91 | .code-attribute, 92 | .code-meta-string { 93 | color: #50a14f; 94 | } 95 | .code-built_in, 96 | .code-class .code-title { 97 | color: #c18401; 98 | } 99 | .code-attr, 100 | .code-variable, 101 | .code-template-variable, 102 | .code-type, 103 | .code-selector-class, 104 | .code-selector-attr, 105 | .code-selector-pseudo, 106 | .code-number { 107 | color: #986801; 108 | } 109 | .code-symbol, 110 | .code-bullet, 111 | .code-meta, 112 | .code-selector-id, 113 | .code-title { 114 | color: #4078f2; 115 | } 116 | .code-emphasis { 117 | font-style: italic; 118 | } 119 | .code-strong { 120 | font-weight: bold; 121 | } 122 | `; 123 | 124 | function Link({ css, ...rest }) { 125 | return ( 126 | // eslint-disable-next-line jsx-a11y/anchor-has-content 127 | 136 | ); 137 | } 138 | 139 | function AppHeader() { 140 | return ( 141 |
142 |

149 | 150 | UNPKG 151 | 152 |

153 | {/* 154 | 159 | */} 160 |
161 | ); 162 | } 163 | 164 | function AppNavigation({ 165 | packageName, 166 | packageVersion, 167 | availableVersions, 168 | filename 169 | }) { 170 | function handleVersionChange(nextVersion) { 171 | window.location.href = window.location.href.replace( 172 | '@' + packageVersion, 173 | '@' + nextVersion 174 | ); 175 | } 176 | 177 | let breadcrumbs = []; 178 | 179 | if (filename === '/') { 180 | breadcrumbs.push(packageName); 181 | } else { 182 | let url = `/browse/${packageName}@${packageVersion}`; 183 | 184 | breadcrumbs.push({packageName}); 185 | 186 | let segments = filename 187 | .replace(/^\/+/, '') 188 | .replace(/\/+$/, '') 189 | .split('/'); 190 | let lastSegment = segments.pop(); 191 | 192 | segments.forEach(segment => { 193 | url += `/${segment}`; 194 | breadcrumbs.push({segment}); 195 | }); 196 | 197 | breadcrumbs.push(lastSegment); 198 | } 199 | 200 | return ( 201 |
212 |

220 | 230 |

231 | 236 |
237 | ); 238 | } 239 | 240 | function PackageVersionPicker({ packageVersion, availableVersions, onChange }) { 241 | function handleChange(event) { 242 | if (onChange) onChange(event.target.value); 243 | } 244 | 245 | return ( 246 |

255 | 293 |

294 | ); 295 | } 296 | 297 | function AppContent({ packageName, packageVersion, target }) { 298 | return target.type === 'directory' ? ( 299 | 300 | ) : target.type === 'file' ? ( 301 | 307 | ) : null; 308 | } 309 | 310 | export default function App({ 311 | packageName, 312 | packageVersion, 313 | availableVersions = [], 314 | filename, 315 | target 316 | }) { 317 | let maxContentWidth = 940; 318 | // TODO: Make this changeable 319 | let isFullWidth = false; 320 | 321 | return ( 322 | 323 | 324 | 325 | 326 |
327 |
334 | 335 |
336 |
343 | 349 |
350 |
361 | 366 |
367 |
368 | 369 |
376 |
387 |

388 | Build: {buildId} 389 |

390 |

391 | © {new Date().getFullYear()} UNPKG 392 |

393 |

394 | 402 | 403 | 404 | 413 | 414 | 415 |

416 |
417 |
418 |
419 | ); 420 | } 421 | 422 | if (process.env.NODE_ENV !== 'production') { 423 | const targetType = PropTypes.shape({ 424 | path: PropTypes.string.isRequired, 425 | type: PropTypes.oneOf(['directory', 'file']).isRequired, 426 | details: PropTypes.object.isRequired 427 | }); 428 | 429 | App.propTypes = { 430 | packageName: PropTypes.string.isRequired, 431 | packageVersion: PropTypes.string.isRequired, 432 | availableVersions: PropTypes.arrayOf(PropTypes.string), 433 | filename: PropTypes.string.isRequired, 434 | target: targetType.isRequired 435 | }; 436 | } 437 | -------------------------------------------------------------------------------- /modules/client/browse/ContentArea.js: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import { jsx } from '@emotion/react'; 3 | 4 | const maxWidth = 700; 5 | 6 | export function ContentArea({ children, css }) { 7 | return ( 8 |
19 | {children} 20 |
21 | ); 22 | } 23 | 24 | export function ContentAreaHeaderBar({ children, css }) { 25 | return ( 26 |
46 | {children} 47 |
48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /modules/client/browse/FileViewer.js: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import { jsx } from '@emotion/react'; 3 | import PropTypes from 'prop-types'; 4 | 5 | import { formatBytes } from '../utils/format.js'; 6 | import { createHTML } from '../utils/markup.js'; 7 | 8 | import { ContentArea, ContentAreaHeaderBar } from './ContentArea.js'; 9 | 10 | function getBasename(path) { 11 | let segments = path.split('/'); 12 | return segments[segments.length - 1]; 13 | } 14 | 15 | function ImageViewer({ path, uri }) { 16 | return ( 17 |
18 | {getBasename(path)} 19 |
20 | ); 21 | } 22 | 23 | function CodeListing({ highlights }) { 24 | let lines = highlights.slice(0); 25 | let hasTrailingNewline = lines.length && lines[lines.length - 1] === ''; 26 | if (hasTrailingNewline) { 27 | lines.pop(); 28 | } 29 | 30 | return ( 31 |
40 | 47 | 48 | {lines.map((line, index) => { 49 | let lineNumber = index + 1; 50 | 51 | return ( 52 | 53 | 68 | 79 | 80 | ); 81 | })} 82 | {!hasTrailingNewline && ( 83 | 84 | 98 | 107 | 108 | )} 109 | 110 |
66 | {lineNumber} 67 | 77 | 78 |
96 | \ 97 | 105 | No newline at end of file 106 |
111 |
112 | ); 113 | } 114 | 115 | function BinaryViewer() { 116 | return ( 117 |
118 |

No preview available.

119 |
120 | ); 121 | } 122 | 123 | export default function FileViewer({ 124 | packageName, 125 | packageVersion, 126 | path, 127 | details 128 | }) { 129 | let { highlights, uri, language, size } = details; 130 | 131 | return ( 132 | 133 | 134 | {formatBytes(size)} 135 | {language} 136 | 137 | 161 | View Raw 162 | 163 | 164 | 165 | 166 | {highlights ? ( 167 | 168 | ) : uri ? ( 169 | 170 | ) : ( 171 | 172 | )} 173 | 174 | ); 175 | } 176 | 177 | if (process.env.NODE_ENV !== 'production') { 178 | FileViewer.propTypes = { 179 | path: PropTypes.string.isRequired, 180 | details: PropTypes.shape({ 181 | contentType: PropTypes.string.isRequired, 182 | highlights: PropTypes.arrayOf(PropTypes.string), // code 183 | uri: PropTypes.string, // images 184 | integrity: PropTypes.string.isRequired, 185 | language: PropTypes.string.isRequired, 186 | size: PropTypes.number.isRequired 187 | }).isRequired 188 | }; 189 | } 190 | -------------------------------------------------------------------------------- /modules/client/browse/FolderViewer.js: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import { jsx } from '@emotion/react'; 3 | import PropTypes from 'prop-types'; 4 | import { VisuallyHidden } from '@reach/visually-hidden'; 5 | import sortBy from 'sort-by'; 6 | 7 | import { formatBytes } from '../utils/format.js'; 8 | 9 | import { ContentArea, ContentAreaHeaderBar } from './ContentArea.js'; 10 | import { FolderIcon, FileIcon, FileCodeIcon } from './Icons.js'; 11 | 12 | const linkStyle = { 13 | color: '#0076ff', 14 | textDecoration: 'none', 15 | ':hover': { 16 | textDecoration: 'underline' 17 | } 18 | }; 19 | 20 | const tableCellStyle = { 21 | paddingTop: 6, 22 | paddingRight: 3, 23 | paddingBottom: 6, 24 | paddingLeft: 3, 25 | borderTop: '1px solid #eaecef' 26 | }; 27 | 28 | const iconCellStyle = { 29 | ...tableCellStyle, 30 | color: '#424242', 31 | width: 17, 32 | paddingRight: 2, 33 | paddingLeft: 10, 34 | '@media (max-width: 700px)': { 35 | paddingLeft: 20 36 | } 37 | }; 38 | 39 | const typeCellStyle = { 40 | ...tableCellStyle, 41 | textAlign: 'right', 42 | paddingRight: 10, 43 | '@media (max-width: 700px)': { 44 | paddingRight: 20 45 | } 46 | }; 47 | 48 | function getRelName(path, base) { 49 | return path.substr(base.length > 1 ? base.length + 1 : 1); 50 | } 51 | 52 | export default function FolderViewer({ path, details: entries }) { 53 | const { subdirs, files } = Object.keys(entries).reduce( 54 | (memo, key) => { 55 | const { subdirs, files } = memo; 56 | const entry = entries[key]; 57 | 58 | if (entry.type === 'directory') { 59 | subdirs.push(entry); 60 | } else if (entry.type === 'file') { 61 | files.push(entry); 62 | } 63 | 64 | return memo; 65 | }, 66 | { subdirs: [], files: [] } 67 | ); 68 | 69 | subdirs.sort(sortBy('path')); 70 | files.sort(sortBy('path')); 71 | 72 | const rows = []; 73 | 74 | if (path !== '/') { 75 | rows.push( 76 | 77 | 78 | 79 | 80 | .. 81 | 82 | 83 | 84 | 85 | 86 | ); 87 | } 88 | 89 | subdirs.forEach(({ path: dirname }) => { 90 | const relName = getRelName(dirname, path); 91 | const href = relName + '/'; 92 | 93 | rows.push( 94 | 95 | 96 | 97 | 98 | 99 | 100 | {relName} 101 | 102 | 103 | - 104 | - 105 | 106 | ); 107 | }); 108 | 109 | files.forEach(({ path: filename, size, contentType }) => { 110 | const relName = getRelName(filename, path); 111 | const href = relName; 112 | 113 | rows.push( 114 | 115 | 116 | {contentType === 'text/plain' || contentType === 'text/markdown' ? ( 117 | 118 | ) : ( 119 | 120 | )} 121 | 122 | 123 | 124 | {relName} 125 | 126 | 127 | {formatBytes(size)} 128 | {contentType} 129 | 130 | ); 131 | }); 132 | 133 | let counts = []; 134 | if (files.length > 0) { 135 | counts.push(`${files.length} file${files.length === 1 ? '' : 's'}`); 136 | } 137 | if (subdirs.length > 0) { 138 | counts.push(`${subdirs.length} folder${subdirs.length === 1 ? '' : 's'}`); 139 | } 140 | 141 | return ( 142 | 143 | 144 | {counts.join(', ')} 145 | 146 | 147 | 163 | 164 | 165 | 168 | 171 | 174 | 177 | 178 | 179 | {rows} 180 |
166 | Icon 167 | 169 | Name 170 | 172 | Size 173 | 175 | Content Type 176 |
181 |
182 | ); 183 | } 184 | 185 | if (process.env.NODE_ENV !== 'production') { 186 | FolderViewer.propTypes = { 187 | path: PropTypes.string.isRequired, 188 | details: PropTypes.objectOf( 189 | PropTypes.shape({ 190 | path: PropTypes.string.isRequired, 191 | type: PropTypes.oneOf(['directory', 'file']).isRequired, 192 | contentType: PropTypes.string, // file only 193 | integrity: PropTypes.string, // file only 194 | size: PropTypes.number // file only 195 | }) 196 | ).isRequired 197 | }; 198 | } 199 | -------------------------------------------------------------------------------- /modules/client/browse/Icons.js: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import { jsx } from '@emotion/react'; 3 | import { 4 | GoArrowBoth, 5 | GoFile, 6 | GoFileCode, 7 | GoFileDirectory 8 | } from 'react-icons/go'; 9 | import { FaTwitter, FaGithub } from 'react-icons/fa'; 10 | 11 | function createIcon(Type, { css, ...rest }) { 12 | return ; 13 | } 14 | 15 | export function FileIcon(props) { 16 | return createIcon(GoFile, props); 17 | } 18 | 19 | export function FileCodeIcon(props) { 20 | return createIcon(GoFileCode, props); 21 | } 22 | 23 | export function FolderIcon(props) { 24 | return createIcon(GoFileDirectory, props); 25 | } 26 | 27 | export function TwitterIcon(props) { 28 | return createIcon(FaTwitter, props); 29 | } 30 | 31 | export function GitHubIcon(props) { 32 | return createIcon(FaGithub, props); 33 | } 34 | 35 | export function ArrowBothIcon(props) { 36 | return createIcon(GoArrowBoth, props); 37 | } 38 | -------------------------------------------------------------------------------- /modules/client/browse/images/DownArrow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lzwme/unpkg/7d5ae1db29690106a810b553dc92fe3a11456224/modules/client/browse/images/DownArrow.png -------------------------------------------------------------------------------- /modules/client/browse/images/SelectDownArrow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lzwme/unpkg/7d5ae1db29690106a810b553dc92fe3a11456224/modules/client/browse/images/SelectDownArrow.png -------------------------------------------------------------------------------- /modules/client/main.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | import App from './main/App.js'; 5 | 6 | ReactDOM.createRoot(document.getElementById('root')).render(); 7 | -------------------------------------------------------------------------------- /modules/client/main/App.js: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import { Global, css, jsx } from '@emotion/react'; 3 | import { Fragment, useEffect, useState } from 'react'; 4 | import PropTypes from 'prop-types'; 5 | import formatBytes from 'pretty-bytes'; 6 | import formatDate from 'date-fns/format'; 7 | import parseDate from 'date-fns/parse'; 8 | 9 | import { formatNumber, formatPercent } from '../utils/format.js'; 10 | import { fontSans, fontMono } from '../utils/style.js'; 11 | 12 | import { TwitterIcon, GitHubIcon } from './Icons.js'; 13 | import CloudflareLogo from './images/CloudflareLogo.png'; 14 | import FlyLogo from './images/FlyLogo.png'; 15 | 16 | const buildId = process.env.BUILD_ID; 17 | 18 | const globalStyles = css` 19 | html { 20 | box-sizing: border-box; 21 | } 22 | *, 23 | *:before, 24 | *:after { 25 | box-sizing: inherit; 26 | } 27 | 28 | html, 29 | body, 30 | #root { 31 | height: 100%; 32 | margin: 0; 33 | } 34 | 35 | body { 36 | ${fontSans} 37 | font-size: 16px; 38 | line-height: 1.5; 39 | overflow-wrap: break-word; 40 | background: white; 41 | color: black; 42 | } 43 | 44 | code { 45 | ${fontMono} 46 | font-size: 1rem; 47 | padding: 0 3px; 48 | background-color: #eee; 49 | } 50 | 51 | dd, 52 | ul { 53 | margin-left: 0; 54 | padding-left: 25px; 55 | } 56 | `; 57 | 58 | function Link(props) { 59 | return ( 60 | // eslint-disable-next-line jsx-a11y/anchor-has-content 61 | 69 | ); 70 | } 71 | 72 | function AboutLogo({ children }) { 73 | return
{children}
; 74 | } 75 | 76 | function AboutLogoImage(props) { 77 | // eslint-disable-next-line jsx-a11y/alt-text 78 | return ; 79 | } 80 | 81 | function Stats({ data }) { 82 | let totals = data.totals; 83 | let since = parseDate(totals.since); 84 | let until = parseDate(totals.until); 85 | 86 | return ( 87 |

88 | From {formatDate(since, 'MMM D')} to{' '} 89 | {formatDate(until, 'MMM D')} unpkg served{' '} 90 | {formatNumber(totals.requests.all)} requests and a total 91 | of {formatBytes(totals.bandwidth.all)} of data to{' '} 92 | {formatNumber(totals.uniques.all)} unique visitors,{' '} 93 | 94 | {formatPercent(totals.requests.cached / totals.requests.all, 2)}% 95 | {' '} 96 | of which were served from the cache. 97 |

98 | ); 99 | } 100 | 101 | export default function App() { 102 | let [stats, setStats] = useState( 103 | typeof window === 'object' && 104 | window.localStorage && 105 | window.localStorage.savedStats 106 | ? JSON.parse(window.localStorage.savedStats) 107 | : null 108 | ); 109 | let hasStats = !!(stats && !stats.error); 110 | let stringStats = JSON.stringify(stats); 111 | 112 | useEffect(() => { 113 | window.localStorage.savedStats = stringStats; 114 | }, [stringStats]); 115 | 116 | useEffect(() => { 117 | fetch('/api/stats?period=last-month') 118 | .then(res => res.json()) 119 | .then(setStats); 120 | }, []); 121 | 122 | return ( 123 | 124 | 125 | 126 |
127 |
128 |
129 |

139 | UNPKG 140 |

141 | 142 |

143 | unpkg is a fast, global content delivery network for everything on{' '} 144 | npm. Use it to quickly 145 | and easily load any file from any package using a URL like: 146 |

147 | 148 |
156 | unpkg.com/:package@:version/:file 157 |
158 | 159 | {hasStats && } 160 |
161 | 162 |

163 | Examples 164 |

165 | 166 |

Using a fixed version:

167 | 168 |
    169 |
  • 170 | 171 | unpkg.com/react@16.7.0/umd/react.production.min.js 172 | 173 |
  • 174 |
  • 175 | 176 | unpkg.com/react-dom@16.7.0/umd/react-dom.production.min.js 177 | 178 |
  • 179 |
180 | 181 |

182 | You may also use a{' '} 183 | semver range{' '} 184 | or a tag{' '} 185 | instead of a fixed version number, or omit the version/tag entirely 186 | to use the latest tag. 187 |

188 | 189 |
    190 |
  • 191 | 192 | unpkg.com/react@^16/umd/react.production.min.js 193 | 194 |
  • 195 |
  • 196 | 197 | unpkg.com/react/umd/react.production.min.js 198 | 199 |
  • 200 |
201 | 202 |

203 | If you omit the file path (i.e. use a “bare” URL), unpkg 204 | will serve the file specified by the unpkg field in{' '} 205 | package.json, or fall back to main. 206 |

207 | 208 |
    209 |
  • 210 | unpkg.com/jquery 211 |
  • 212 |
  • 213 | unpkg.com/three 214 |
  • 215 |
216 | 217 |

218 | Append a / at the end of a URL to view a listing of all 219 | the files in a package. 220 |

221 | 222 |
    223 |
  • 224 | unpkg.com/react/ 225 |
  • 226 |
  • 227 | unpkg.com/react-router/ 228 |
  • 229 |
230 | 231 |

232 | Query Parameters 233 |

234 | 235 |
236 |
237 | ?meta 238 |
239 |
240 | Return metadata about any file in a package as JSON (e.g. 241 | /any/file?meta) 242 |
243 | 244 |
245 | ?module 246 |
247 |
248 | Expands all{' '} 249 | 250 | “bare” import specifiers 251 | {' '} 252 | in JavaScript modules to unpkg URLs. This feature is{' '} 253 | very experimental 254 |
255 |
256 | 257 |

258 | Cache Behavior 259 |

260 | 261 |

262 | The CDN caches files based on their permanent URL, which includes 263 | the npm package version. This works because npm does not allow 264 | package authors to overwrite a package that has already been 265 | published with a different one at the same version number. 266 |

267 |

268 | Browsers are instructed (via the Cache-Control header) 269 | to cache assets indefinitely (1 year). 270 |

271 |

272 | URLs that do not specify a package version number redirect to one 273 | that does. This is the latest version when no version 274 | is specified, or the maxSatisfying version when a{' '} 275 | 276 | semver version 277 | {' '} 278 | is given. Redirects are cached for 10 minutes at the CDN, 1 minute 279 | in browsers. 280 |

281 |

282 | If you want users to be able to use the latest version when you cut 283 | a new release, the best policy is to put the version number in the 284 | URL directly in your installation instructions. This will also load 285 | more quickly because we won't have to resolve the latest 286 | version and redirect them. 287 |

288 | 289 |

290 | Workflow 291 |

292 | 293 |

294 | For npm package authors, unpkg relieves the burden of publishing 295 | your code to a CDN in addition to the npm registry. All you need to 296 | do is include your{' '} 297 | UMD build in your 298 | npm package (not your repo, that's different!). 299 |

300 | 301 |

You can do this easily using the following setup:

302 | 303 |
    304 |
  • 305 | Add the umd (or dist) directory to your{' '} 306 | .gitignore file 307 |
  • 308 |
  • 309 | Add the umd directory to your{' '} 310 | 311 | files array 312 | {' '} 313 | in package.json 314 |
  • 315 |
  • 316 | Use a build script to generate your UMD build in the{' '} 317 | umd directory when you publish 318 |
  • 319 |
320 | 321 |

322 | That's it! Now when you npm publish you'll 323 | have a version available on unpkg as well. 324 |

325 | 326 |

327 | About 328 |

329 | 330 |

331 | unpkg is an{' '} 332 | open source{' '} 333 | project built and maintained by{' '} 334 | Michael Jackson. 335 | unpkg is not affiliated with or supported by npm, Inc. in any way. 336 | Please do not contact npm for help with unpkg. Instead, please reach 337 | out to @unpkg with any 338 | questions or concerns. 339 |

340 | 341 |

342 | The unpkg CDN is powered by{' '} 343 | Cloudflare, one of 344 | the world's largest and fastest cloud network platforms.{' '} 345 | {hasStats && ( 346 | 347 | In the past month, Cloudflare served over{' '} 348 | {formatBytes(stats.totals.bandwidth.all)} to{' '} 349 | {formatNumber(stats.totals.uniques.all)} unique 350 | unpkg users all over the world. 351 | 352 | )} 353 |

354 | 355 |
372 | 373 |

374 | The origin server runs on auto-scaling infrastructure provided by{' '} 375 | Fly.io. The app servers run in 376 | 17 cities around the world, and come and go based on active 377 | requests. 378 |

379 | 380 |
387 | 388 | 389 | 390 | 391 | 392 |
393 |
394 |
395 | 396 |
403 |
414 |

415 | Build: {buildId} 416 |

417 |

418 | © {new Date().getFullYear()} UNPKG 419 |

420 |

421 | 429 | 430 | 431 | 440 | 441 | 442 |

443 |
444 |
445 | 446 | ); 447 | } 448 | 449 | if (process.env.NODE_ENV !== 'production') { 450 | App.propTypes = { 451 | location: PropTypes.object, 452 | children: PropTypes.node 453 | }; 454 | } 455 | -------------------------------------------------------------------------------- /modules/client/main/Icons.js: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import { jsx } from '@emotion/react'; 3 | import { FaTwitter, FaGithub } from 'react-icons/fa'; 4 | 5 | function createIcon(Type, { css, ...rest }) { 6 | return ; 7 | } 8 | 9 | export function TwitterIcon(props) { 10 | return createIcon(FaTwitter, props); 11 | } 12 | 13 | export function GitHubIcon(props) { 14 | return createIcon(FaGithub, props); 15 | } 16 | -------------------------------------------------------------------------------- /modules/client/main/images/CloudflareLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lzwme/unpkg/7d5ae1db29690106a810b553dc92fe3a11456224/modules/client/main/images/CloudflareLogo.png -------------------------------------------------------------------------------- /modules/client/main/images/FlyLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lzwme/unpkg/7d5ae1db29690106a810b553dc92fe3a11456224/modules/client/main/images/FlyLogo.png -------------------------------------------------------------------------------- /modules/client/utils/format.js: -------------------------------------------------------------------------------- 1 | import formatBytes from 'pretty-bytes'; 2 | 3 | export { formatBytes }; 4 | 5 | export function formatNumber(n) { 6 | const digits = String(n).split(''); 7 | const groups = []; 8 | 9 | while (digits.length) { 10 | groups.unshift(digits.splice(-3).join('')); 11 | } 12 | 13 | return groups.join(','); 14 | } 15 | 16 | export function formatPercent(n, decimals = 1) { 17 | return (n * 100).toPrecision(decimals + 2); 18 | } 19 | -------------------------------------------------------------------------------- /modules/client/utils/markup.js: -------------------------------------------------------------------------------- 1 | export function createHTML(content) { 2 | return { __html: content }; 3 | } 4 | -------------------------------------------------------------------------------- /modules/client/utils/style.js: -------------------------------------------------------------------------------- 1 | export const fontSans = ` 2 | font-family: -apple-system, 3 | BlinkMacSystemFont, 4 | "Segoe UI", 5 | "Roboto", 6 | "Oxygen", 7 | "Ubuntu", 8 | "Cantarell", 9 | "Fira Sans", 10 | "Droid Sans", 11 | "Helvetica Neue", 12 | sans-serif; 13 | `; 14 | 15 | export const fontMono = ` 16 | font-family: Menlo, 17 | Monaco, 18 | Lucida Console, 19 | Liberation Mono, 20 | DejaVu Sans Mono, 21 | Bitstream Vera Sans Mono, 22 | Courier New, 23 | monospace; 24 | `; 25 | -------------------------------------------------------------------------------- /modules/createServer.js: -------------------------------------------------------------------------------- 1 | import cors from 'cors'; 2 | import express from 'express'; 3 | import morgan from 'morgan'; 4 | 5 | import serveDirectoryBrowser from './actions/serveDirectoryBrowser.js'; 6 | import serveDirectoryMetadata from './actions/serveDirectoryMetadata.js'; 7 | import serveFileBrowser from './actions/serveFileBrowser.js'; 8 | import serveFileMetadata from './actions/serveFileMetadata.js'; 9 | import serveFile from './actions/serveFile.js'; 10 | import serveMainPage from './actions/serveMainPage.js'; 11 | import serveModule from './actions/serveModule.js'; 12 | import serveStats from './actions/serveStats.js'; 13 | 14 | import allowQuery from './middleware/allowQuery.js'; 15 | import findEntry from './middleware/findEntry.js'; 16 | import noQuery from './middleware/noQuery.js'; 17 | import redirectLegacyURLs from './middleware/redirectLegacyURLs.js'; 18 | import requestLog from './middleware/requestLog.js'; 19 | import validateFilename from './middleware/validateFilename.js'; 20 | import validatePackagePathname from './middleware/validatePackagePathname.js'; 21 | import validatePackageName from './middleware/validatePackageName.js'; 22 | import validatePackageVersion from './middleware/validatePackageVersion.js'; 23 | 24 | function createApp(callback) { 25 | const app = express(); 26 | callback(app); 27 | return app; 28 | } 29 | 30 | export default function createServer() { 31 | return createApp(app => { 32 | app.disable('x-powered-by'); 33 | app.enable('trust proxy'); 34 | app.enable('strict routing'); 35 | 36 | if (process.env.NODE_ENV === 'development') { 37 | app.use(morgan('dev')); 38 | } 39 | 40 | app.use(cors()); 41 | app.use(express.static('public', { maxAge: '1y' })); 42 | 43 | app.use(requestLog); 44 | 45 | app.get('/', serveMainPage); 46 | app.get('/api/stats', serveStats); 47 | 48 | app.use(redirectLegacyURLs); 49 | 50 | app.use( 51 | '/browse', 52 | createApp(app => { 53 | app.enable('strict routing'); 54 | 55 | app.get( 56 | '*/', 57 | noQuery(), 58 | validatePackagePathname, 59 | validatePackageName, 60 | validatePackageVersion, 61 | serveDirectoryBrowser 62 | ); 63 | 64 | app.get( 65 | '*', 66 | noQuery(), 67 | validatePackagePathname, 68 | validatePackageName, 69 | validatePackageVersion, 70 | serveFileBrowser 71 | ); 72 | }) 73 | ); 74 | 75 | // We need to route in this weird way because Express 76 | // doesn't have a way to route based on query params. 77 | const metadataApp = createApp(app => { 78 | app.enable('strict routing'); 79 | 80 | app.get( 81 | '*/', 82 | allowQuery('meta'), 83 | validatePackagePathname, 84 | validatePackageName, 85 | validatePackageVersion, 86 | validateFilename, 87 | serveDirectoryMetadata 88 | ); 89 | 90 | app.get( 91 | '*', 92 | allowQuery('meta'), 93 | validatePackagePathname, 94 | validatePackageName, 95 | validatePackageVersion, 96 | validateFilename, 97 | serveFileMetadata 98 | ); 99 | }); 100 | 101 | app.use((req, res, next) => { 102 | if (req.query.meta != null) { 103 | metadataApp(req, res); 104 | } else { 105 | next(); 106 | } 107 | }); 108 | 109 | // We need to route in this weird way because Express 110 | // doesn't have a way to route based on query params. 111 | const moduleApp = createApp(app => { 112 | app.enable('strict routing'); 113 | 114 | app.get( 115 | '*', 116 | allowQuery('module'), 117 | validatePackagePathname, 118 | validatePackageName, 119 | validatePackageVersion, 120 | validateFilename, 121 | findEntry, 122 | serveModule 123 | ); 124 | }); 125 | 126 | app.use((req, res, next) => { 127 | if (req.query.module != null) { 128 | moduleApp(req, res); 129 | } else { 130 | next(); 131 | } 132 | }); 133 | 134 | // Send old */ requests to the new /browse UI. 135 | app.get('*/', (req, res) => { 136 | res.redirect(302, '/browse' + req.url); 137 | }); 138 | 139 | app.get( 140 | '*', 141 | noQuery(), 142 | validatePackagePathname, 143 | validatePackageName, 144 | validatePackageVersion, 145 | validateFilename, 146 | findEntry, 147 | serveFile 148 | ); 149 | }); 150 | } 151 | -------------------------------------------------------------------------------- /modules/middleware/allowQuery.js: -------------------------------------------------------------------------------- 1 | import createSearch from '../utils/createSearch.js'; 2 | 3 | /** 4 | * Reject URLs with invalid query parameters to increase cache hit rates. 5 | */ 6 | export default function allowQuery(validKeys = []) { 7 | if (!Array.isArray(validKeys)) { 8 | validKeys = [validKeys]; 9 | } 10 | 11 | return (req, res, next) => { 12 | const keys = Object.keys(req.query); 13 | 14 | if (!keys.every(key => validKeys.includes(key))) { 15 | const newQuery = keys 16 | .filter(key => validKeys.includes(key)) 17 | .reduce((query, key) => { 18 | query[key] = req.query[key]; 19 | return query; 20 | }, {}); 21 | 22 | return res.redirect(302, req.baseUrl + req.path + createSearch(newQuery)); 23 | } 24 | 25 | next(); 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /modules/middleware/findEntry.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import tar from 'tar-stream'; 3 | 4 | import asyncHandler from '../utils/asyncHandler.js'; 5 | import bufferStream from '../utils/bufferStream.js'; 6 | import createPackageURL from '../utils/createPackageURL.js'; 7 | import getContentType from '../utils/getContentType.js'; 8 | import getIntegrity from '../utils/getIntegrity.js'; 9 | import { getPackage } from '../utils/npm.js'; 10 | 11 | function fileRedirect(req, res, entry) { 12 | // Redirect to the file with the extension so it's 13 | // clear which file is being served. 14 | res 15 | .set({ 16 | 'Cache-Control': 'public, max-age=31536000', // 1 year 17 | 'Cache-Tag': 'redirect, file-redirect' 18 | }) 19 | .redirect( 20 | 302, 21 | createPackageURL( 22 | req.packageName, 23 | req.packageVersion, 24 | entry.path, 25 | req.query 26 | ) 27 | ); 28 | } 29 | 30 | function indexRedirect(req, res, entry) { 31 | // Redirect to the index file so relative imports 32 | // resolve correctly. 33 | res 34 | .set({ 35 | 'Cache-Control': 'public, max-age=31536000', // 1 year 36 | 'Cache-Tag': 'redirect, index-redirect' 37 | }) 38 | .redirect( 39 | 302, 40 | createPackageURL( 41 | req.packageName, 42 | req.packageVersion, 43 | entry.path, 44 | req.query 45 | ) 46 | ); 47 | } 48 | 49 | /** 50 | * Search the given tarball for entries that match the given name. 51 | * Follows node's resolution algorithm. 52 | * https://nodejs.org/api/modules.html#modules_all_together 53 | */ 54 | function searchEntries(stream, filename) { 55 | // filename = /some/file/name.js or /some/dir/name 56 | return new Promise((accept, reject) => { 57 | const jsEntryFilename = `${filename}.js`; 58 | const jsonEntryFilename = `${filename}.json`; 59 | 60 | const matchingEntries = {}; 61 | let foundEntry; 62 | 63 | if (filename === '/') { 64 | foundEntry = matchingEntries['/'] = { name: '/', type: 'directory' }; 65 | } 66 | 67 | stream 68 | .pipe(tar.extract()) 69 | .on('error', reject) 70 | .on('entry', async (header, stream, next) => { 71 | const entry = { 72 | // Most packages have header names that look like `package/index.js` 73 | // so we shorten that to just `index.js` here. A few packages use a 74 | // prefix other than `package/`. e.g. the firebase package uses the 75 | // `firebase_npm/` prefix. So we just strip the first dir name. 76 | path: header.name.replace(/^[^/]+/g, ''), 77 | type: header.type 78 | }; 79 | 80 | // Skip non-files and files that don't match the entryName. 81 | if (entry.type !== 'file' || !entry.path.startsWith(filename)) { 82 | stream.resume(); 83 | stream.on('end', next); 84 | return; 85 | } 86 | 87 | matchingEntries[entry.path] = entry; 88 | 89 | // Dynamically create "directory" entries for all directories 90 | // that are in this file's path. Some tarballs omit these entries 91 | // for some reason, so this is the "brute force" method. 92 | let dir = path.dirname(entry.path); 93 | while (dir !== '/') { 94 | if (!matchingEntries[dir]) { 95 | matchingEntries[dir] = { name: dir, type: 'directory' }; 96 | } 97 | dir = path.dirname(dir); 98 | } 99 | 100 | if ( 101 | entry.path === filename || 102 | // Allow accessing e.g. `/index.js` or `/index.json` 103 | // using `/index` for compatibility with npm 104 | entry.path === jsEntryFilename || 105 | entry.path === jsonEntryFilename 106 | ) { 107 | if (foundEntry) { 108 | if ( 109 | foundEntry.path !== filename && 110 | (entry.path === filename || 111 | (entry.path === jsEntryFilename && 112 | foundEntry.path === jsonEntryFilename)) 113 | ) { 114 | // This entry is higher priority than the one 115 | // we already found. Replace it. 116 | delete foundEntry.content; 117 | foundEntry = entry; 118 | } 119 | } else { 120 | foundEntry = entry; 121 | } 122 | } 123 | 124 | try { 125 | const content = await bufferStream(stream); 126 | 127 | entry.contentType = getContentType(entry.path); 128 | entry.integrity = getIntegrity(content); 129 | entry.lastModified = header.mtime.toUTCString(); 130 | entry.size = content.length; 131 | 132 | // Set the content only for the foundEntry and 133 | // discard the buffer for all others. 134 | if (entry === foundEntry) { 135 | entry.content = content; 136 | } 137 | 138 | next(); 139 | } catch (error) { 140 | next(error); 141 | } 142 | }) 143 | .on('finish', () => { 144 | accept({ 145 | // If we didn't find a matching file entry, 146 | // try a directory entry with the same name. 147 | foundEntry: foundEntry || matchingEntries[filename] || null, 148 | matchingEntries: matchingEntries 149 | }); 150 | }); 151 | }); 152 | } 153 | 154 | /** 155 | * Fetch and search the archive to try and find the requested file. 156 | * Redirect to the "index" file if a directory was requested. 157 | */ 158 | async function findEntry(req, res, next) { 159 | const stream = await getPackage(req.packageName, req.packageVersion, req.log); 160 | const { foundEntry: entry, matchingEntries: entries } = await searchEntries( 161 | stream, 162 | req.filename 163 | ); 164 | 165 | if (!entry) { 166 | return res 167 | .status(404) 168 | .set({ 169 | 'Cache-Control': 'public, max-age=31536000', // 1 year 170 | 'Cache-Tag': 'missing, missing-entry' 171 | }) 172 | .type('text') 173 | .send(`Cannot find "${req.filename}" in ${req.packageSpec}`); 174 | } 175 | 176 | if (entry.type === 'file' && entry.path !== req.filename) { 177 | return fileRedirect(req, res, entry); 178 | } 179 | 180 | if (entry.type === 'directory') { 181 | // We need to redirect to some "index" file inside the directory so 182 | // our URLs work in a similar way to require("lib") in node where it 183 | // uses `lib/index.js` when `lib` is a directory. 184 | const indexEntry = 185 | entries[`${req.filename}/index.js`] || 186 | entries[`${req.filename}/index.json`]; 187 | 188 | if (indexEntry && indexEntry.type === 'file') { 189 | return indexRedirect(req, res, indexEntry); 190 | } 191 | 192 | return res 193 | .status(404) 194 | .set({ 195 | 'Cache-Control': 'public, max-age=31536000', // 1 year 196 | 'Cache-Tag': 'missing, missing-index' 197 | }) 198 | .type('text') 199 | .send(`Cannot find an index in "${req.filename}" in ${req.packageSpec}`); 200 | } 201 | 202 | req.entry = entry; 203 | 204 | next(); 205 | } 206 | 207 | export default asyncHandler(findEntry); 208 | -------------------------------------------------------------------------------- /modules/middleware/noQuery.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Strips all query params from the URL to increase cache hit rates. 3 | */ 4 | export default function noQuery() { 5 | return (req, res, next) => { 6 | const keys = Object.keys(req.query); 7 | 8 | if (keys.length) { 9 | return res.redirect(302, req.baseUrl + req.path); 10 | } 11 | 12 | next(); 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /modules/middleware/redirectLegacyURLs.js: -------------------------------------------------------------------------------- 1 | import createSearch from '../utils/createSearch.js'; 2 | 3 | /** 4 | * Redirect old URLs that we no longer support. 5 | */ 6 | export default function redirectLegacyURLs(req, res, next) { 7 | // Permanently redirect /_meta/path to /path?meta 8 | if (req.path.match(/^\/_meta\//)) { 9 | req.query.meta = ''; 10 | return res.redirect(301, req.path.substr(6) + createSearch(req.query)); 11 | } 12 | 13 | // Permanently redirect /path?json => /path?meta 14 | if (req.query.json != null) { 15 | delete req.query.json; 16 | req.query.meta = ''; 17 | return res.redirect(301, req.path + createSearch(req.query)); 18 | } 19 | 20 | next(); 21 | } 22 | -------------------------------------------------------------------------------- /modules/middleware/requestLog.js: -------------------------------------------------------------------------------- 1 | import util from 'util'; 2 | 3 | const enableDebugging = process.env.DEBUG != null; 4 | 5 | function noop() {} 6 | 7 | function createLog(req) { 8 | return { 9 | debug: enableDebugging 10 | ? (format, ...args) => { 11 | console.log(util.format(format, ...args)); 12 | } 13 | : noop, 14 | info: (format, ...args) => { 15 | console.log(util.format(format, ...args)); 16 | }, 17 | error: (format, ...args) => { 18 | console.error(util.format(format, ...args)); 19 | } 20 | }; 21 | } 22 | 23 | export default function requestLog(req, res, next) { 24 | req.log = createLog(req); 25 | next(); 26 | } 27 | -------------------------------------------------------------------------------- /modules/middleware/validateFilename.js: -------------------------------------------------------------------------------- 1 | import createPackageURL from '../utils/createPackageURL.js'; 2 | 3 | function filenameRedirect(req, res) { 4 | let filename; 5 | if (req.query.module != null) { 6 | // See https://github.com/rollup/rollup/wiki/pkg.module 7 | filename = req.packageConfig.module || req.packageConfig['jsnext:main']; 8 | 9 | if (!filename) { 10 | // https://nodejs.org/api/esm.html#esm_code_package_json_code_code_type_code_field 11 | if (req.packageConfig.type === 'module') { 12 | // Use whatever is in pkg.main or index.js 13 | filename = req.packageConfig.main || '/index.js'; 14 | } else if ( 15 | req.packageConfig.main && 16 | /\.mjs$/.test(req.packageConfig.main) 17 | ) { 18 | // Use .mjs file in pkg.main 19 | filename = req.packageConfig.main; 20 | } 21 | } 22 | 23 | if (!filename) { 24 | return res 25 | .status(404) 26 | .type('text') 27 | .send(`Package ${req.packageSpec} does not contain an ES module`); 28 | } 29 | } else if ( 30 | req.query.main && 31 | req.packageConfig[req.query.main] && 32 | typeof req.packageConfig[req.query.main] === 'string' 33 | ) { 34 | // Deprecated, see #63 35 | filename = req.packageConfig[req.query.main]; 36 | } else if ( 37 | req.packageConfig.unpkg && 38 | typeof req.packageConfig.unpkg === 'string' 39 | ) { 40 | filename = req.packageConfig.unpkg; 41 | } else if ( 42 | req.packageConfig.browser && 43 | typeof req.packageConfig.browser === 'string' 44 | ) { 45 | // Deprecated, see #63 46 | filename = req.packageConfig.browser; 47 | } else { 48 | filename = req.packageConfig.main || '/index.js'; 49 | } 50 | 51 | // Redirect to the exact filename so relative imports 52 | // and URLs resolve correctly. 53 | res 54 | .set({ 55 | 'Cache-Control': 'public, max-age=31536000', // 1 year 56 | 'Cache-Tag': 'redirect, filename-redirect' 57 | }) 58 | .redirect( 59 | 302, 60 | createPackageURL( 61 | req.packageName, 62 | req.packageVersion, 63 | filename.replace(/^[./]*/, '/'), 64 | req.query 65 | ) 66 | ); 67 | } 68 | 69 | /** 70 | * Redirect to the exact filename if the request omits one. 71 | */ 72 | export default async function validateFilename(req, res, next) { 73 | if (!req.filename) { 74 | return filenameRedirect(req, res); 75 | } 76 | 77 | next(); 78 | } 79 | -------------------------------------------------------------------------------- /modules/middleware/validatePackageName.js: -------------------------------------------------------------------------------- 1 | import validateNpmPackageName from 'validate-npm-package-name'; 2 | 3 | const hexValue = /^[a-f0-9]+$/i; 4 | 5 | function isHash(value) { 6 | return value.length === 32 && hexValue.test(value); 7 | } 8 | 9 | /** 10 | * Reject requests for invalid npm package names. 11 | */ 12 | export default function validatePackageName(req, res, next) { 13 | if (isHash(req.packageName)) { 14 | return res 15 | .status(403) 16 | .type('text') 17 | .send(`Invalid package name "${req.packageName}" (cannot be a hash)`); 18 | } 19 | 20 | const errors = validateNpmPackageName(req.packageName).errors; 21 | 22 | if (errors) { 23 | const reason = errors.join(', '); 24 | 25 | return res 26 | .status(403) 27 | .type('text') 28 | .send(`Invalid package name "${req.packageName}" (${reason})`); 29 | } 30 | 31 | next(); 32 | } 33 | -------------------------------------------------------------------------------- /modules/middleware/validatePackagePathname.js: -------------------------------------------------------------------------------- 1 | import parsePackagePathname from '../utils/parsePackagePathname.js'; 2 | 3 | /** 4 | * Parse the pathname in the URL. Reject invalid URLs. 5 | */ 6 | export default function validatePackagePathname(req, res, next) { 7 | const parsed = parsePackagePathname(req.path); 8 | 9 | if (parsed == null) { 10 | return res.status(403).send({ error: `Invalid URL: ${req.path}` }); 11 | } 12 | 13 | req.packageName = parsed.packageName; 14 | req.packageVersion = parsed.packageVersion; 15 | req.packageSpec = parsed.packageSpec; 16 | req.filename = parsed.filename; 17 | 18 | next(); 19 | } 20 | -------------------------------------------------------------------------------- /modules/middleware/validatePackageVersion.js: -------------------------------------------------------------------------------- 1 | import semver from 'semver'; 2 | 3 | import asyncHandler from '../utils/asyncHandler.js'; 4 | import createPackageURL from '../utils/createPackageURL.js'; 5 | import { getPackageConfig, getVersionsAndTags } from '../utils/npm.js'; 6 | 7 | function semverRedirect(req, res, newVersion) { 8 | res 9 | .set({ 10 | 'Cache-Control': 'public, s-maxage=600, max-age=60', // 10 mins on CDN, 1 min on clients 11 | 'Cache-Tag': 'redirect, semver-redirect' 12 | }) 13 | .redirect( 14 | 302, 15 | req.baseUrl + 16 | createPackageURL(req.packageName, newVersion, req.filename, req.query) 17 | ); 18 | } 19 | 20 | async function resolveVersion(packageName, range, log) { 21 | const versionsAndTags = await getVersionsAndTags(packageName, log); 22 | 23 | if (versionsAndTags) { 24 | const { versions, tags } = versionsAndTags; 25 | 26 | if (range in tags) { 27 | range = tags[range]; 28 | } 29 | 30 | return versions.includes(range) 31 | ? range 32 | : semver.maxSatisfying(versions, range); 33 | } 34 | 35 | return null; 36 | } 37 | 38 | /** 39 | * Check the package version/tag in the URL and make sure it's good. Also 40 | * fetch the package config and add it to req.packageConfig. Redirect to 41 | * the resolved version number if necessary. 42 | */ 43 | async function validateVersion(req, res, next) { 44 | const version = await resolveVersion( 45 | req.packageName, 46 | req.packageVersion, 47 | req.log 48 | ); 49 | 50 | if (!version) { 51 | return res 52 | .status(404) 53 | .type('text') 54 | .send(`Cannot find package ${req.packageSpec}`); 55 | } 56 | 57 | if (version !== req.packageVersion) { 58 | return semverRedirect(req, res, version); 59 | } 60 | 61 | req.packageConfig = await getPackageConfig( 62 | req.packageName, 63 | req.packageVersion, 64 | req.log 65 | ); 66 | 67 | if (!req.packageConfig) { 68 | return res 69 | .status(500) 70 | .type('text') 71 | .send(`Cannot get config for package ${req.packageSpec}`); 72 | } 73 | 74 | next(); 75 | } 76 | 77 | export default asyncHandler(validateVersion); 78 | -------------------------------------------------------------------------------- /modules/plugins/__tests__/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "jest": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /modules/plugins/__tests__/unpkgRewrite-test.js: -------------------------------------------------------------------------------- 1 | import * as babel from '@babel/core'; 2 | 3 | import unpkgRewrite from '../unpkgRewrite.js'; 4 | 5 | const testCases = [ 6 | { 7 | before: 'import React from "react";', 8 | after: 'import React from "https://unpkg.com/react@15.6.1?module";' 9 | }, 10 | { 11 | before: 'import router from "@angular/router";', 12 | after: 13 | 'import router from "https://unpkg.com/@angular/router@4.3.5?module";' 14 | }, 15 | { 16 | before: 'import map from "lodash.map";', 17 | after: 'import map from "https://unpkg.com/lodash.map@4.6.0?module";' 18 | }, 19 | { 20 | before: 'import fs from "pn/fs";', 21 | after: 'import fs from "https://unpkg.com/pn@1.0.0/fs?module";' 22 | }, 23 | { 24 | before: 'import cupcakes from "./cupcakes";', 25 | after: 'import cupcakes from "./cupcakes?module";' 26 | }, 27 | { 28 | before: 'import shoelaces from "/shoelaces";', 29 | after: 'import shoelaces from "/shoelaces?module";' 30 | }, 31 | { 32 | before: 'import something from "//something.com/whatevs";', 33 | after: 'import something from "//something.com/whatevs";' 34 | }, 35 | { 36 | before: 'import something from "http://something.com/whatevs";', 37 | after: 'import something from "http://something.com/whatevs";' 38 | }, 39 | { 40 | before: 'let ReactDOM = require("react-dom");', 41 | after: 'let ReactDOM = require("react-dom");' 42 | }, 43 | { 44 | before: 'export React from "react";', 45 | after: 'export React from "https://unpkg.com/react@15.6.1?module";' 46 | }, 47 | { 48 | before: 'export { Component } from "react";', 49 | after: 'export { Component } from "https://unpkg.com/react@15.6.1?module";' 50 | }, 51 | { 52 | before: 'export * from "react";', 53 | after: 'export * from "https://unpkg.com/react@15.6.1?module";' 54 | }, 55 | { 56 | before: 'export var message = "hello";', 57 | after: 'export var message = "hello";' 58 | }, 59 | { 60 | before: 'import("./something.js");', 61 | after: 'import("./something.js?module");' 62 | }, 63 | { 64 | before: 'import("react");', 65 | after: 'import("https://unpkg.com/react@15.6.1?module");' 66 | } 67 | ]; 68 | 69 | const origin = 'https://unpkg.com'; 70 | const dependencies = { 71 | react: '15.6.1', 72 | '@angular/router': '4.3.5', 73 | 'lodash.map': '4.6.0', 74 | pn: '1.0.0' 75 | }; 76 | 77 | describe('Rewriting imports/exports', () => { 78 | testCases.forEach(testCase => { 79 | it(`rewrites '${testCase.before}' => '${testCase.after}'`, () => { 80 | const result = babel.transform(testCase.before, { 81 | plugins: [unpkgRewrite(origin, dependencies)] 82 | }); 83 | 84 | expect(result.code).toEqual(testCase.after); 85 | }); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /modules/plugins/unpkgRewrite.js: -------------------------------------------------------------------------------- 1 | import URL from 'whatwg-url'; 2 | import warning from 'warning'; 3 | 4 | const bareIdentifierFormat = /^((?:@[^/]+\/)?[^/]+)(\/.*)?$/; 5 | 6 | function isValidURL(value) { 7 | return URL.parseURL(value) != null; 8 | } 9 | 10 | function isProbablyURLWithoutProtocol(value) { 11 | return value.substr(0, 2) === '//'; 12 | } 13 | 14 | function isAbsoluteURL(value) { 15 | return isValidURL(value) || isProbablyURLWithoutProtocol(value); 16 | } 17 | 18 | function isBareIdentifier(value) { 19 | return value.charAt(0) !== '.' && value.charAt(0) !== '/'; 20 | } 21 | 22 | function rewriteValue(/* StringLiteral */ node, origin, dependencies) { 23 | if (isAbsoluteURL(node.value)) { 24 | return; 25 | } 26 | 27 | if (isBareIdentifier(node.value)) { 28 | // "bare" identifier 29 | const match = bareIdentifierFormat.exec(node.value); 30 | const packageName = match[1]; 31 | const file = match[2] || ''; 32 | 33 | warning( 34 | dependencies[packageName], 35 | 'Missing version info for package "%s" in dependencies; falling back to "latest"', 36 | packageName 37 | ); 38 | 39 | const version = dependencies[packageName] || 'latest'; 40 | 41 | node.value = `${origin}/${packageName}@${version}${file}?module`; 42 | } else { 43 | // local path 44 | node.value = `${node.value}?module`; 45 | } 46 | } 47 | 48 | export default function unpkgRewrite(origin, dependencies = {}) { 49 | return { 50 | manipulateOptions(opts, parserOpts) { 51 | parserOpts.plugins.push( 52 | 'dynamicImport', 53 | 'exportDefaultFrom', 54 | 'exportNamespaceFrom', 55 | 'importMeta' 56 | ); 57 | }, 58 | 59 | visitor: { 60 | CallExpression(path) { 61 | if (path.node.callee.type !== 'Import') { 62 | // Some other function call, not import(); 63 | return; 64 | } 65 | 66 | rewriteValue(path.node.arguments[0], origin, dependencies); 67 | }, 68 | ExportAllDeclaration(path) { 69 | rewriteValue(path.node.source, origin, dependencies); 70 | }, 71 | ExportNamedDeclaration(path) { 72 | if (!path.node.source) { 73 | // This export has no "source", so it's probably 74 | // a local variable or function, e.g. 75 | // export { varName } 76 | // export const constName = ... 77 | // export function funcName() {} 78 | return; 79 | } 80 | 81 | rewriteValue(path.node.source, origin, dependencies); 82 | }, 83 | ImportDeclaration(path) { 84 | rewriteValue(path.node.source, origin, dependencies); 85 | } 86 | } 87 | }; 88 | } 89 | -------------------------------------------------------------------------------- /modules/server.js: -------------------------------------------------------------------------------- 1 | import createServer from './createServer.js'; 2 | 3 | const server = createServer(); 4 | const port = process.env.PORT || '8080'; 5 | 6 | server.listen(port, () => { 7 | console.log('Server listening on port %s, Ctrl+C to quit', port); 8 | }); 9 | -------------------------------------------------------------------------------- /modules/templates/MainTemplate.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | 3 | import encodeJSONForScript from '../utils/encodeJSONForScript.js'; 4 | import { 5 | createElement as e, 6 | createHTML as h, 7 | createScript as x 8 | } from '../utils/markup.js'; 9 | 10 | const promiseShim = 11 | 'window.Promise || document.write(\'\\x3Cscript src="/es6-promise@4.2.5/dist/es6-promise.min.js">\\x3C/script>\\x3Cscript>ES6Promise.polyfill()\\x3C/script>\')'; 12 | 13 | const fetchShim = 14 | 'window.fetch || document.write(\'\\x3Cscript src="/whatwg-fetch@3.0.0/dist/fetch.umd.js">\\x3C/script>\')'; 15 | 16 | export default function MainTemplate({ 17 | title = 'UNPKG', 18 | description = 'The CDN for everything on npm', 19 | favicon = '/favicon.ico', 20 | data, 21 | content = h(''), 22 | elements = [] 23 | }) { 24 | const GTAG_MEASUREMENT_ID = process.env.GTAG_MEASUREMENT_ID || ''; 25 | 26 | return e( 27 | 'html', 28 | { lang: 'en' }, 29 | e( 30 | 'head', 31 | null, 32 | // Global site tag (gtag.js) - Google Analytics 33 | !GTAG_MEASUREMENT_ID ? null : x(`var s = document.createElement('script'); 34 | s.async = 'true'; 35 | s.src='https://www.googletagmanager.com/gtag/js?id=${GTAG_MEASUREMENT_ID}'; 36 | document.head.append(s); 37 | window.dataLayer = window.dataLayer || []; 38 | function gtag(){dataLayer.push(arguments);} 39 | gtag('js', new Date());gtag('config', '${GTAG_MEASUREMENT_ID}');`), 40 | e('meta', { charSet: 'utf-8' }), 41 | e('meta', { httpEquiv: 'X-UA-Compatible', content: 'IE=edge,chrome=1' }), 42 | description && e('meta', { name: 'description', content: description }), 43 | e('meta', { 44 | name: 'viewport', 45 | content: 'width=device-width,initial-scale=1,maximum-scale=1' 46 | }), 47 | e('meta', { name: 'timestamp', content: new Date().toISOString() }), 48 | favicon && e('link', { rel: 'shortcut icon', href: favicon }), 49 | e('title', null, title), 50 | x(promiseShim), 51 | x(fetchShim), 52 | data && x(`window.__DATA__ = ${encodeJSONForScript(data)}`) 53 | ), 54 | e( 55 | 'body', 56 | null, 57 | e('div', { id: 'root', dangerouslySetInnerHTML: content }), 58 | ...elements 59 | ) 60 | ); 61 | } 62 | 63 | if (process.env.NODE_ENV !== 'production') { 64 | const htmlType = PropTypes.shape({ 65 | __html: PropTypes.string 66 | }); 67 | 68 | MainTemplate.propTypes = { 69 | title: PropTypes.string, 70 | description: PropTypes.string, 71 | favicon: PropTypes.string, 72 | data: PropTypes.any, 73 | content: htmlType, 74 | elements: PropTypes.arrayOf(PropTypes.node) 75 | }; 76 | } 77 | -------------------------------------------------------------------------------- /modules/utils/__tests__/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "jest": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /modules/utils/__tests__/createSearch-test.js: -------------------------------------------------------------------------------- 1 | import createSearch from '../createSearch.js'; 2 | 3 | describe('createSearch', () => { 4 | it('omits the trailing = for empty string values', () => { 5 | expect(createSearch({ a: 'a', b: '' })).toEqual('?a=a&b'); 6 | }); 7 | 8 | it('omits the trailing = for null/undefined values', () => { 9 | expect(createSearch({ a: 'a', b: null })).toEqual('?a=a&b'); 10 | expect(createSearch({ a: 'a', b: undefined })).toEqual('?a=a&b'); 11 | }); 12 | 13 | it('sorts keys', () => { 14 | expect(createSearch({ b: 'b', a: 'a', c: 'c' })).toEqual('?a=a&b=b&c=c'); 15 | }); 16 | 17 | it('returns an empty string when there are no params', () => { 18 | expect(createSearch({})).toEqual(''); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /modules/utils/__tests__/getContentType-test.js: -------------------------------------------------------------------------------- 1 | import getContentType from '../getContentType.js'; 2 | 3 | describe('getContentType', () => { 4 | it('returns text/plain for LICENSE|README|CHANGES|AUTHORS|Makefile', () => { 5 | expect(getContentType('AUTHORS')).toBe('text/plain'); 6 | expect(getContentType('CHANGES')).toBe('text/plain'); 7 | expect(getContentType('LICENSE')).toBe('text/plain'); 8 | expect(getContentType('Makefile')).toBe('text/plain'); 9 | expect(getContentType('PATENTS')).toBe('text/plain'); 10 | expect(getContentType('README')).toBe('text/plain'); 11 | }); 12 | 13 | it('returns text/plain for .*rc files', () => { 14 | expect(getContentType('.eslintrc')).toBe('text/plain'); 15 | expect(getContentType('.babelrc')).toBe('text/plain'); 16 | expect(getContentType('.anythingrc')).toBe('text/plain'); 17 | }); 18 | 19 | it('returns text/plain for .git* files', () => { 20 | expect(getContentType('.gitignore')).toBe('text/plain'); 21 | expect(getContentType('.gitanything')).toBe('text/plain'); 22 | }); 23 | 24 | it('returns text/plain for .*ignore files', () => { 25 | expect(getContentType('.eslintignore')).toBe('text/plain'); 26 | expect(getContentType('.anythingignore')).toBe('text/plain'); 27 | }); 28 | 29 | it('returns text/plain for .ts(x) files', () => { 30 | expect(getContentType('app.ts')).toBe('text/plain'); 31 | expect(getContentType('app.d.ts')).toBe('text/plain'); 32 | expect(getContentType('app.tsx')).toBe('text/plain'); 33 | }); 34 | 35 | it('returns text/plain for .flow files', () => { 36 | expect(getContentType('app.js.flow')).toBe('text/plain'); 37 | }); 38 | 39 | it('returns text/plain for .lock files', () => { 40 | expect(getContentType('yarn.lock')).toBe('text/plain'); 41 | }); 42 | 43 | it('returns application/json for .map files', () => { 44 | expect(getContentType('react.js.map')).toBe('application/json'); 45 | expect(getContentType('react.json.map')).toBe('application/json'); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /modules/utils/__tests__/getLanguageName-test.js: -------------------------------------------------------------------------------- 1 | import getLanguageName from '../getLanguageName.js'; 2 | 3 | describe('getLanguageName', () => { 4 | // Hard-coded overrides 5 | 6 | it('detects Flow files', () => { 7 | expect(getLanguageName('react.flow')).toBe('Flow'); 8 | }); 9 | 10 | it('detects source maps', () => { 11 | expect(getLanguageName('react.map')).toBe('Source Map (JSON)'); 12 | expect(getLanguageName('react.js.map')).toBe('Source Map (JSON)'); 13 | expect(getLanguageName('react.json.map')).toBe('Source Map (JSON)'); 14 | }); 15 | 16 | it('detects TypeScript files', () => { 17 | expect(getLanguageName('react.d.ts')).toBe('TypeScript'); 18 | expect(getLanguageName('react.tsx')).toBe('TypeScript'); 19 | }); 20 | 21 | // Content-Type lookups 22 | 23 | it('detects JavaScript files', () => { 24 | expect(getLanguageName('react.js')).toBe('JavaScript'); 25 | }); 26 | 27 | it('detects JSON files', () => { 28 | expect(getLanguageName('react.json')).toBe('JSON'); 29 | }); 30 | 31 | it('detects binary files', () => { 32 | expect(getLanguageName('ionicons.bin')).toBe('Binary'); 33 | }); 34 | 35 | it('detects EOT files', () => { 36 | expect(getLanguageName('ionicons.eot')).toBe('Embedded OpenType'); 37 | }); 38 | 39 | it('detects SVG files', () => { 40 | expect(getLanguageName('react.svg')).toBe('SVG'); 41 | }); 42 | 43 | it('detects TTF files', () => { 44 | expect(getLanguageName('ionicons.ttf')).toBe('TrueType Font'); 45 | }); 46 | 47 | it('detects WOFF files', () => { 48 | expect(getLanguageName('ionicons.woff')).toBe('WOFF'); 49 | }); 50 | 51 | it('detects WOFF2 files', () => { 52 | expect(getLanguageName('ionicons.woff2')).toBe('WOFF2'); 53 | }); 54 | 55 | it('detects CSS files', () => { 56 | expect(getLanguageName('react.css')).toBe('CSS'); 57 | }); 58 | 59 | it('detects HTML files', () => { 60 | expect(getLanguageName('react.html')).toBe('HTML'); 61 | }); 62 | 63 | it('detects JSX files', () => { 64 | expect(getLanguageName('react.jsx')).toBe('JSX'); 65 | }); 66 | 67 | it('detects Markdown files', () => { 68 | expect(getLanguageName('README.md')).toBe('Markdown'); 69 | }); 70 | 71 | it('detects plain text files', () => { 72 | expect(getLanguageName('README')).toBe('Plain Text'); 73 | expect(getLanguageName('LICENSE')).toBe('Plain Text'); 74 | }); 75 | 76 | it('detects SCSS files', () => { 77 | expect(getLanguageName('some.scss')).toBe('SCSS'); 78 | }); 79 | 80 | it('detects YAML files', () => { 81 | expect(getLanguageName('config.yml')).toBe('YAML'); 82 | expect(getLanguageName('config.yaml')).toBe('YAML'); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /modules/utils/__tests__/parsePackagePathname-test.js: -------------------------------------------------------------------------------- 1 | import parsePackagePathname from '../parsePackagePathname.js'; 2 | 3 | describe('parsePackagePathname', () => { 4 | it('parses plain packages', () => { 5 | expect(parsePackagePathname('/history@1.0.0/umd/history.min.js')).toEqual({ 6 | packageName: 'history', 7 | packageVersion: '1.0.0', 8 | packageSpec: 'history@1.0.0', 9 | filename: '/umd/history.min.js' 10 | }); 11 | }); 12 | 13 | it('parses plain packages with a hyphen in the name', () => { 14 | expect(parsePackagePathname('/query-string@5.0.0/index.js')).toEqual({ 15 | packageName: 'query-string', 16 | packageVersion: '5.0.0', 17 | packageSpec: 'query-string@5.0.0', 18 | filename: '/index.js' 19 | }); 20 | }); 21 | 22 | it('parses plain packages with no version specified', () => { 23 | expect(parsePackagePathname('/query-string/index.js')).toEqual({ 24 | packageName: 'query-string', 25 | packageVersion: 'latest', 26 | packageSpec: 'query-string@latest', 27 | filename: '/index.js' 28 | }); 29 | }); 30 | 31 | it('parses plain packages with version spec', () => { 32 | expect(parsePackagePathname('/query-string@>=4.0.0/index.js')).toEqual({ 33 | packageName: 'query-string', 34 | packageVersion: '>=4.0.0', 35 | packageSpec: 'query-string@>=4.0.0', 36 | filename: '/index.js' 37 | }); 38 | }); 39 | 40 | it('parses scoped packages', () => { 41 | expect( 42 | parsePackagePathname('/@angular/router@4.3.3/src/index.d.ts') 43 | ).toEqual({ 44 | packageName: '@angular/router', 45 | packageVersion: '4.3.3', 46 | packageSpec: '@angular/router@4.3.3', 47 | filename: '/src/index.d.ts' 48 | }); 49 | }); 50 | 51 | it('parses package names with a period in them', () => { 52 | expect(parsePackagePathname('/index.js')).toEqual({ 53 | packageName: 'index.js', 54 | packageVersion: 'latest', 55 | packageSpec: 'index.js@latest', 56 | filename: '' 57 | }); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /modules/utils/asyncHandler.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Useful for wrapping `async` request handlers in Express 3 | * so they automatically propagate errors. 4 | */ 5 | export default function asyncHandler(handler) { 6 | return (req, res, next) => { 7 | Promise.resolve(handler(req, res, next)).catch(error => { 8 | req.log.error(`Unexpected error in ${handler.name}!`); 9 | req.log.error(error.stack); 10 | 11 | next(error); 12 | }); 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /modules/utils/bufferStream.js: -------------------------------------------------------------------------------- 1 | export default function bufferStream(stream) { 2 | return new Promise((accept, reject) => { 3 | const chunks = []; 4 | 5 | stream 6 | .on('error', reject) 7 | .on('data', chunk => chunks.push(chunk)) 8 | .on('end', () => accept(Buffer.concat(chunks))); 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /modules/utils/cloudflare.js: -------------------------------------------------------------------------------- 1 | import fetch from 'isomorphic-fetch'; 2 | 3 | const cloudflareURL = 'https://api.cloudflare.com/client/v4'; 4 | const cloudflareEmail = process.env.CLOUDFLARE_EMAIL; 5 | const cloudflareKey = process.env.CLOUDFLARE_KEY; 6 | 7 | if (process.env.NODE_ENV !== 'production' && process.env.ENABLE_CLOUDFLARE === '1') { 8 | if (!cloudflareEmail) { 9 | throw new Error('Missing the $CLOUDFLARE_EMAIL environment variable'); 10 | } 11 | 12 | if (!cloudflareKey) { 13 | throw new Error('Missing the $CLOUDFLARE_KEY environment variable'); 14 | } 15 | } 16 | 17 | function get(path, headers) { 18 | return fetch(`${cloudflareURL}${path}`, { 19 | headers: Object.assign({}, headers, { 20 | 'X-Auth-Email': cloudflareEmail, 21 | 'X-Auth-Key': cloudflareKey 22 | }) 23 | }); 24 | } 25 | 26 | function getJSON(path, headers) { 27 | return get(path, headers) 28 | .then(res => { 29 | return res.json(); 30 | }) 31 | .then(data => { 32 | if (!data.success) { 33 | console.error(`cloudflare.getJSON failed at ${path}`); 34 | console.error(data); 35 | throw new Error('Failed to getJSON from Cloudflare'); 36 | } 37 | 38 | return data.result; 39 | }); 40 | } 41 | 42 | export function getZones(domains) { 43 | return Promise.all( 44 | (Array.isArray(domains) ? domains : [domains]).map(domain => 45 | getJSON(`/zones?name=${domain}`) 46 | ) 47 | ).then(results => results.reduce((memo, zones) => memo.concat(zones))); 48 | } 49 | 50 | function reduceResults(target, values) { 51 | Object.keys(values).forEach(key => { 52 | const value = values[key]; 53 | 54 | if (typeof value === 'object' && value) { 55 | target[key] = reduceResults(target[key] || {}, value); 56 | } else if (typeof value === 'number') { 57 | target[key] = (target[key] || 0) + values[key]; 58 | } 59 | }); 60 | 61 | return target; 62 | } 63 | 64 | export function getZoneAnalyticsDashboard(zones, since, until) { 65 | return Promise.all( 66 | (Array.isArray(zones) ? zones : [zones]).map(zone => { 67 | return getJSON( 68 | `/zones/${ 69 | zone.id 70 | }/analytics/dashboard?since=${since.toISOString()}&until=${until.toISOString()}` 71 | ); 72 | }) 73 | ).then(results => results.reduce(reduceResults)); 74 | } 75 | -------------------------------------------------------------------------------- /modules/utils/createDataURI.js: -------------------------------------------------------------------------------- 1 | export default function createDataURI(contentType, content) { 2 | return `data:${contentType};base64,${content.toString('base64')}`; 3 | } 4 | -------------------------------------------------------------------------------- /modules/utils/createPackageURL.js: -------------------------------------------------------------------------------- 1 | import createSearch from './createSearch.js'; 2 | 3 | export default function createPackageURL( 4 | packageName, 5 | packageVersion, 6 | filename, 7 | query 8 | ) { 9 | let url = `/${packageName}`; 10 | 11 | if (packageVersion) url += `@${packageVersion}`; 12 | if (filename) url += filename; 13 | if (query) url += createSearch(query); 14 | 15 | return url; 16 | } 17 | -------------------------------------------------------------------------------- /modules/utils/createSearch.js: -------------------------------------------------------------------------------- 1 | export default function createSearch(query) { 2 | const keys = Object.keys(query).sort(); 3 | const pairs = keys.reduce( 4 | (memo, key) => 5 | memo.concat( 6 | query[key] == null || query[key] === '' 7 | ? key 8 | : `${key}=${encodeURIComponent(query[key])}` 9 | ), 10 | [] 11 | ); 12 | 13 | return pairs.length ? `?${pairs.join('&')}` : ''; 14 | } 15 | -------------------------------------------------------------------------------- /modules/utils/encodeJSONForScript.js: -------------------------------------------------------------------------------- 1 | import jsesc from 'jsesc'; 2 | 3 | /** 4 | * Encodes some data as JSON that may safely be included in HTML. 5 | */ 6 | export default function encodeJSONForScript(data) { 7 | return jsesc(data, { json: true, isScriptContext: true }); 8 | } 9 | -------------------------------------------------------------------------------- /modules/utils/getContentType.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | 3 | import { Mime } from 'mime/lite'; 4 | import standardTypes from 'mime/types/standard.js'; 5 | import otherTypes from 'mime/types/other.js'; 6 | 7 | const mime = new Mime(standardTypes, otherTypes); 8 | 9 | mime.define( 10 | { 11 | 'text/plain': [ 12 | 'authors', 13 | 'changes', 14 | 'license', 15 | 'makefile', 16 | 'patents', 17 | 'readme', 18 | 'ts', 19 | 'flow' 20 | ] 21 | }, 22 | /* force */ true 23 | ); 24 | 25 | const textFiles = /\/?(\.[a-z]*rc|\.git[a-z]*|\.[a-z]*ignore|\.lock)$/i; 26 | 27 | export default function getContentType(file) { 28 | const name = path.basename(file); 29 | 30 | return textFiles.test(name) 31 | ? 'text/plain' 32 | : mime.getType(name) || 'text/plain'; 33 | } 34 | -------------------------------------------------------------------------------- /modules/utils/getContentTypeHeader.js: -------------------------------------------------------------------------------- 1 | export default function getContentTypeHeader(type) { 2 | return type === 'application/javascript' ? type + '; charset=utf-8' : type; 3 | } 4 | -------------------------------------------------------------------------------- /modules/utils/getHighlights.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import hljs from 'highlight.js'; 3 | 4 | import getContentType from './getContentType.js'; 5 | 6 | function escapeHTML(code) { 7 | return code 8 | .replace(/&/g, '&') 9 | .replace(//g, '>') 11 | .replace(/"/g, '"') 12 | .replace(/'/g, '''); 13 | } 14 | 15 | // These should probably be added to highlight.js auto-detection. 16 | const extLanguages = { 17 | map: 'json', 18 | mjs: 'javascript', 19 | tsbuildinfo: 'json', 20 | tsx: 'typescript', 21 | txt: 'text', 22 | vue: 'html' 23 | }; 24 | 25 | function getLanguage(file) { 26 | // Try to guess the language based on the file extension. 27 | const ext = path.extname(file).substr(1); 28 | 29 | if (ext) { 30 | return extLanguages[ext] || ext; 31 | } 32 | 33 | const contentType = getContentType(file); 34 | 35 | if (contentType === 'text/plain') { 36 | return 'text'; 37 | } 38 | 39 | return null; 40 | } 41 | 42 | function getLines(code) { 43 | return code 44 | .split('\n') 45 | .map((line, index, array) => 46 | index === array.length - 1 ? line : line + '\n' 47 | ); 48 | } 49 | 50 | /** 51 | * Returns an array of HTML strings that highlight the given source code. 52 | */ 53 | export default function getHighlights(code, file) { 54 | const language = getLanguage(file); 55 | 56 | if (!language) { 57 | return null; 58 | } 59 | 60 | if (language === 'text') { 61 | return getLines(code).map(escapeHTML); 62 | } 63 | 64 | try { 65 | let continuation = false; 66 | const hi = getLines(code).map(line => { 67 | const result = hljs.highlight(line, { language, ignoreIllegals: false }); 68 | continuation = result.top; 69 | return result; 70 | }); 71 | 72 | return hi.map(result => 73 | result.value.replace( 74 | //g, 75 | '' 76 | ) 77 | ); 78 | } catch (error) { 79 | // Probably an "unknown language" error. 80 | // console.error(error); 81 | return null; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /modules/utils/getIntegrity.js: -------------------------------------------------------------------------------- 1 | import SRIToolbox from 'sri-toolbox'; 2 | 3 | export default function getIntegrity(data) { 4 | return SRIToolbox.generate({ algorithms: ['sha384'] }, data); 5 | } 6 | -------------------------------------------------------------------------------- /modules/utils/getLanguageName.js: -------------------------------------------------------------------------------- 1 | import getContentType from './getContentType.js'; 2 | 3 | const contentTypeNames = { 4 | 'application/javascript': 'JavaScript', 5 | 'application/json': 'JSON', 6 | 'application/octet-stream': 'Binary', 7 | 'application/vnd.ms-fontobject': 'Embedded OpenType', 8 | 'application/xml': 'XML', 9 | 'image/svg+xml': 'SVG', 10 | 'font/ttf': 'TrueType Font', 11 | 'font/woff': 'WOFF', 12 | 'font/woff2': 'WOFF2', 13 | 'text/css': 'CSS', 14 | 'text/html': 'HTML', 15 | 'text/jsx': 'JSX', 16 | 'text/markdown': 'Markdown', 17 | 'text/plain': 'Plain Text', 18 | 'text/x-scss': 'SCSS', 19 | 'text/yaml': 'YAML' 20 | }; 21 | 22 | /** 23 | * Gets a human-friendly name for whatever is in the given file. 24 | */ 25 | export default function getLanguageName(file) { 26 | // Content-Type is text/plain, but we can be more descriptive. 27 | if (/\.flow$/.test(file)) return 'Flow'; 28 | if (/\.(d\.ts|tsx)$/.test(file)) return 'TypeScript'; 29 | 30 | // Content-Type is application/json, but we can be more descriptive. 31 | if (/\.map$/.test(file)) return 'Source Map (JSON)'; 32 | 33 | const contentType = getContentType(file); 34 | 35 | return contentTypeNames[contentType] || contentType; 36 | } 37 | -------------------------------------------------------------------------------- /modules/utils/getScripts.js: -------------------------------------------------------------------------------- 1 | // Virtual module id; see rollup.config.js 2 | // eslint-disable-next-line import/no-unresolved 3 | import entryManifest from 'entry-manifest'; 4 | 5 | import { createElement, createScript } from './markup.js'; 6 | 7 | function getEntryPoint(name, format) { 8 | for (let manifest of entryManifest) { 9 | let bundles = manifest[name]; 10 | if (bundles) { 11 | return bundles.find(b => b.format === format); 12 | } 13 | } 14 | 15 | return null; 16 | } 17 | 18 | function getGlobalScripts(entryPoint, globalURLs) { 19 | return entryPoint.globalImports.map(id => { 20 | if (process.env.NODE_ENV !== 'production') { 21 | if (!globalURLs[id]) { 22 | throw new Error('Missing global URL for id "%s"', id); 23 | } 24 | } 25 | 26 | return createElement('script', { src: globalURLs[id] }); 27 | }); 28 | } 29 | 30 | export default function getScripts(entryName, format, globalURLs) { 31 | const entryPoint = getEntryPoint(entryName, format); 32 | 33 | if (!entryPoint) return []; 34 | 35 | return getGlobalScripts(entryPoint, globalURLs).concat( 36 | // Inline the code for this entry point into the page 37 | // itself instead of using another