Skip to content

Commit

Permalink
New "browse" UI
Browse files Browse the repository at this point in the history
Also, separated out browse, ?meta, and ?module request handlers.

Fixes unpkg#82
  • Loading branch information
mjackson committed Jul 26, 2019
1 parent ea35b3c commit 34baab0
Show file tree
Hide file tree
Showing 57 changed files with 2,423 additions and 678 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import request from 'supertest';

import createServer from '../createServer.js';

describe('A request that targets a directory with a trailing slash', () => {
describe('A request to browse a directory', () => {
let server;
beforeEach(() => {
server = createServer();
Expand All @@ -11,7 +11,7 @@ describe('A request that targets a directory with a trailing slash', () => {
describe('when the directory exists', () => {
it('returns an HTML page', done => {
request(server)
.get('/[email protected]/umd/')
.get('/browse/[email protected]/umd/')
.end((err, res) => {
expect(res.statusCode).toBe(200);
expect(res.headers['content-type']).toMatch(/\btext\/html\b/);
Expand All @@ -21,12 +21,12 @@ describe('A request that targets a directory with a trailing slash', () => {
});

describe('when the directory does not exist', () => {
it('returns a 404 text error', done => {
it('returns a 404 HTML page', done => {
request(server)
.get('/[email protected]/not-here/')
.get('/browse/[email protected]/not-here/')
.end((err, res) => {
expect(res.statusCode).toBe(404);
expect(res.headers['content-type']).toMatch(/\btext\/plain\b/);
expect(res.headers['content-type']).toMatch(/\btext\/html\b/);
done();
});
});
Expand Down
34 changes: 34 additions & 0 deletions modules/__tests__/browseFile-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import request from 'supertest';

import createServer from '../createServer.js';

describe('A request to browse a file', () => {
let server;
beforeEach(() => {
server = createServer();
});

describe('when the file exists', () => {
it('returns an HTML page', done => {
request(server)
.get('/browse/[email protected]/umd/react.production.min.js')
.end((err, res) => {
expect(res.statusCode).toBe(200);
expect(res.headers['content-type']).toMatch(/\btext\/html\b/);
done();
});
});
});

describe('when the file does not exist', () => {
it('returns a 404 HTML page', done => {
request(server)
.get('/browse/[email protected]/not-here.js')
.end((err, res) => {
expect(res.statusCode).toBe(404);
expect(res.headers['content-type']).toMatch(/\btext\/html\b/);
done();
});
});
});
});
10 changes: 10 additions & 0 deletions modules/__tests__/legacyURLs-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,14 @@ describe('Legacy URLs', () => {
done();
});
});

it('redirect */ to /browse/*/', done => {
request(server)
.get('/[email protected]/umd/')
.end((err, res) => {
expect(res.statusCode).toBe(302);
expect(res.headers.location).toEqual('/browse/[email protected]/umd/');
done();
});
});
});
Original file line number Diff line number Diff line change
@@ -1,38 +1,36 @@
import { renderToString, renderToStaticMarkup } from 'react-dom/server';

import AutoIndexApp from '../client/autoIndex/App.js';

import MainTemplate from './utils/MainTemplate.js';
import getScripts from './utils/getScripts.js';
import { createElement, createHTML } from './utils/markupHelpers.js';
import BrowseApp from '../client/browse/App.js';
import MainTemplate from '../templates/MainTemplate.js';
import { getAvailableVersions } from '../utils/npm.js';
import getScripts from '../utils/getScripts.js';
import { createElement, createHTML } from '../utils/markup.js';

const doctype = '<!DOCTYPE html>';
const globalURLs =
process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'staging'
? {
'@emotion/core': '/@emotion/[email protected]/dist/core.umd.min.js',
react: '/react@16.7.0/umd/react.production.min.js',
'react-dom': '/react-dom@16.7.0/umd/react-dom.production.min.js'
react: '/react@16.8.6/umd/react.production.min.js',
'react-dom': '/react-dom@16.8.6/umd/react-dom.production.min.js'
}
: {
'@emotion/core': '/@emotion/[email protected]/dist/core.umd.min.js',
react: '/react@16.7.0/umd/react.development.js',
'react-dom': '/react-dom@16.7.0/umd/react-dom.development.js'
react: '/react@16.8.6/umd/react.development.js',
'react-dom': '/react-dom@16.8.6/umd/react-dom.development.js'
};

export default async function serveAutoIndexPage(req, res) {
export default async function serveBrowsePage(req, res) {
const availableVersions = await getAvailableVersions(req.packageName);
const data = {
packageName: req.packageName,
packageVersion: req.packageVersion,
availableVersions: availableVersions,
filename: req.filename,
entry: req.entry,
entries: req.entries
target: req.browseTarget
};
const content = createHTML(renderToString(createElement(AutoIndexApp, data)));
const elements = getScripts('autoIndex', 'iife', globalURLs);
const content = createHTML(renderToString(createElement(BrowseApp, data)));
const elements = getScripts('browse', 'iife', globalURLs);

const html =
doctype +
Expand All @@ -49,7 +47,7 @@ export default async function serveAutoIndexPage(req, res) {
res
.set({
'Cache-Control': 'public, max-age=14400', // 4 hours
'Cache-Tag': 'auto-index'
'Cache-Tag': 'browse'
})
.send(html);
}
81 changes: 81 additions & 0 deletions modules/actions/serveDirectoryBrowser.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import path from 'path';
import gunzip from 'gunzip-maybe';
import tar from 'tar-stream';

import bufferStream from '../utils/bufferStream.js';
import getContentType from '../utils/getContentType.js';
import getIntegrity from '../utils/getIntegrity.js';
import { getPackage } from '../utils/npm.js';
import serveBrowsePage from './serveBrowsePage.js';

async function findMatchingEntries(stream, filename) {
// filename = /some/dir/name
return new Promise((accept, reject) => {
const entries = {};

stream
.pipe(gunzip())
.pipe(tar.extract())
.on('error', reject)
.on('entry', async (header, stream, next) => {
const entry = {
// Most packages have header names that look like `package/index.js`
// so we shorten that to just `/index.js` here. A few packages use a
// prefix other than `package/`. e.g. the firebase package uses the
// `firebase_npm/` prefix. So we just strip the first dir name.
path: header.name.replace(/^[^/]+/, ''),
type: header.type
};

// Dynamically create "directory" entries for all subdirectories
// in this entry's path. Some tarballs omit directory entries for
// some reason, so this is the "brute force" method.
let dir = path.dirname(entry.path);
while (dir !== '/') {
if (!entries[dir] && path.dirname(dir) === filename) {
entries[dir] = { path: dir, type: 'directory' };
}
dir = path.dirname(dir);
}

// Ignore non-files and files that aren't in this directory.
if (entry.type !== 'file' || path.dirname(entry.path) !== filename) {
stream.resume();
stream.on('end', next);
return;
}

const content = await bufferStream(stream);

entry.contentType = getContentType(entry.path);
entry.integrity = getIntegrity(content);
entry.size = content.length;

entries[entry.path] = entry;

next();
})
.on('finish', () => {
accept(entries);
});
});
}

export default async function serveDirectoryBrowser(req, res) {
const stream = await getPackage(req.packageName, req.packageVersion);

const filename = req.filename.slice(0, -1) || '/';
const entries = await findMatchingEntries(stream, filename);

if (Object.keys(entries).length === 0) {
return res.status(404).send(`Not found: ${req.packageSpec}${req.filename}`);
}

req.browseTarget = {
path: filename,
type: 'directory',
details: entries
};

serveBrowsePage(req, res);
}
97 changes: 97 additions & 0 deletions modules/actions/serveDirectoryMetadata.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import path from 'path';
import gunzip from 'gunzip-maybe';
import tar from 'tar-stream';

import bufferStream from '../utils/bufferStream.js';
import getContentType from '../utils/getContentType.js';
import getIntegrity from '../utils/getIntegrity.js';
import { getPackage } from '../utils/npm.js';

async function findMatchingEntries(stream, filename) {
// filename = /some/dir/name
return new Promise((accept, reject) => {
const entries = {};

entries[filename] = { path: filename, type: 'directory' };

stream
.pipe(gunzip())
.pipe(tar.extract())
.on('error', reject)
.on('entry', async (header, stream, next) => {
const entry = {
// Most packages have header names that look like `package/index.js`
// so we shorten that to just `/index.js` here. A few packages use a
// prefix other than `package/`. e.g. the firebase package uses the
// `firebase_npm/` prefix. So we just strip the first dir name.
path: header.name.replace(/^[^/]+/, ''),
type: header.type
};

// Dynamically create "directory" entries for all subdirectories
// in this entry's path. Some tarballs omit directory entries for
// some reason, so this is the "brute force" method.
let dir = path.dirname(entry.path);
while (dir !== '/') {
if (!entries[dir] && dir.startsWith(filename)) {
entries[dir] = { path: dir, type: 'directory' };
}
dir = path.dirname(dir);
}

// Ignore non-files and files that don't match the prefix.
if (entry.type !== 'file' || !entry.path.startsWith(filename)) {
stream.resume();
stream.on('end', next);
return;
}

const content = await bufferStream(stream);

entry.contentType = getContentType(entry.path);
entry.integrity = getIntegrity(content);
entry.lastModified = header.mtime.toUTCString();
entry.size = content.length;

entries[entry.path] = entry;

next();
})
.on('finish', () => {
accept(entries);
});
});
}

function getMatchingEntries(entry, entries) {
return Object.keys(entries)
.filter(key => entry.path !== key && path.dirname(key) === entry.path)
.map(key => entries[key]);
}

function getMetadata(entry, entries) {
const metadata = { path: entry.path, type: entry.type };

if (entry.type === 'file') {
metadata.contentType = entry.contentType;
metadata.integrity = entry.integrity;
metadata.lastModified = entry.lastModified;
metadata.size = entry.size;
} else if (entry.type === 'directory') {
metadata.files = getMatchingEntries(entry, entries).map(e =>
getMetadata(e, entries)
);
}

return metadata;
}

export default async function serveDirectoryMetadata(req, res) {
const stream = await getPackage(req.packageName, req.packageVersion);

const filename = req.filename.slice(0, -1) || '/';
const entries = await findMatchingEntries(stream, filename);
const metadata = getMetadata(entries[filename], entries);

res.send(metadata);
}
35 changes: 18 additions & 17 deletions modules/actions/serveFile.js
Original file line number Diff line number Diff line change
@@ -1,23 +1,24 @@
import serveAutoIndexPage from './serveAutoIndexPage.js';
import serveMetadata from './serveMetadata.js';
import serveModule from './serveModule.js';
import serveStaticFile from './serveStaticFile.js';
import path from 'path';
import etag from 'etag';

/**
* Send the file, JSON metadata, or HTML directory listing.
*/
export default function serveFile(req, res) {
if (req.query.meta != null) {
return serveMetadata(req, res);
}
import getContentTypeHeader from '../utils/getContentTypeHeader.js';

if (req.entry.type === 'directory') {
return serveAutoIndexPage(req, res);
}
export default function serveFile(req, res) {
const tags = ['file'];

if (req.query.module != null) {
return serveModule(req, res);
const ext = path.extname(req.entry.path).substr(1);
if (ext) {
tags.push(`${ext}-file`);
}

serveStaticFile(req, res);
res
.set({
'Content-Type': getContentTypeHeader(req.entry.contentType),
'Content-Length': req.entry.size,
'Cache-Control': 'public, max-age=31536000', // 1 year
'Last-Modified': req.entry.lastModified,
ETag: etag(req.entry.content),
'Cache-Tag': tags.join(', ')
})
.send(req.entry.content);
}
Loading

0 comments on commit 34baab0

Please sign in to comment.