diff --git a/cjs/index.js b/cjs/index.js index fdfe49c..5c5513d 100644 --- a/cjs/index.js +++ b/cjs/index.js @@ -1,13 +1,15 @@ 'use strict'; const { - createReadStream, stat, mkdir, readFile, unlink, existsSync, writeFileSync, watchFile, unwatchFile + createReadStream, mkdir, unlink, existsSync, writeFileSync, watch } = require('fs'); const {tmpdir} = require('os'); const {dirname, extname, join, resolve} = require('path'); const ucompress = (m => m.__esModule ? /* istanbul ignore next */ m.default : /* istanbul ignore next */ m)(require('ucompress')); -const {parse} = JSON; +const json = (m => m.__esModule ? /* istanbul ignore next */ m.default : /* istanbul ignore next */ m)(require('./json.js')); +const stat = (m => m.__esModule ? /* istanbul ignore next */ m.default : /* istanbul ignore next */ m)(require('./stat.js')); + const {compressed} = ucompress; const getPath = source => (source[0] === '/' ? source : resolve(source)); @@ -18,15 +20,12 @@ const internalServerError = res => { res.end(); }; -const readAndServe = (res, asset, IfNoneMatch) => { - readFile(asset + '.json', (err, data) => { +const readAndServe = (res, asset, cacheTimeout, IfNoneMatch) => + json(asset, cacheTimeout).then( + headers => serveFile(res, asset, headers, IfNoneMatch), /* istanbul ignore next */ - if (err) - internalServerError(res); - else - serveFile(res, asset, parse(data), IfNoneMatch); - }); -}; + () => internalServerError(res) + ); const serveFile = (res, asset, headers, IfNoneMatch) => { if (headers.ETag === IfNoneMatch) { @@ -42,15 +41,95 @@ const streamFile = (res, asset, headers) => { createReadStream(asset).pipe(res); }; -module.exports = ({source, dest, headers}) => { +module.exports = ({source, dest, headers, cacheTimeout: CT}) => { const SOURCE = getPath(source); const DEST = dest ? getPath(dest) : join(tmpdir(), 'ucdn'); const options = {createFiles: true, headers}; return (req, res, next) => { const path = req.url.replace(/\?.*$/, ''); const original = SOURCE + path; - stat(original, (err, stats) => { - if (err || !stats.isFile()) { + stat(original, CT).then( + ({lastModified, size}) => { + if (path === '/favicon.ico') + streamFile(res, original, { + 'Content-Length': size, + 'Content-Type': 'image/vnd.microsoft.icon', + ...headers + }); + else { + let asset = DEST + path; + let compression = ''; + const { + ['accept-encoding']: AcceptEncoding, + ['if-none-match']: IfNoneMatch, + ['if-modified-since']: IfModifiedSince + } = req.headers; + if (compressed.has(extname(path).toLowerCase())) { + switch (true) { + /* istanbul ignore next */ + case /\bbr\b/.test(AcceptEncoding): + compression = '.br'; + break; + case /\bgzip\b/.test(AcceptEncoding): + compression = '.gzip'; + break; + /* istanbul ignore next */ + case /\bdeflate\b/.test(AcceptEncoding): + compression = '.deflate'; + break; + } + asset += compression; + } + const create = () => { + const {length} = compression; + const compress = length ? asset.slice(0, -length) : asset; + const waitForIt = compress + '.wait'; + mkdir(dirname(waitForIt), {recursive: true}, err => { + /* istanbul ignore if */ + if (err) + internalServerError(res); + else if (existsSync(waitForIt)) + watch(waitForIt, andClose).on( + 'close', + () => readAndServe(res, asset, CT, IfNoneMatch) + ); + else { + try { + writeFileSync(waitForIt, path); + ucompress(original, compress, options) + .then( + () => { + unlink(waitForIt, err => { + /* istanbul ignore if */ + if (err) + internalServerError(res); + else + readAndServe(res, asset, CT, IfNoneMatch); + }); + }, + /* istanbul ignore next */ + () => unlink(waitForIt, () => internalServerError(res)) + ); + } + catch (o_O) { + /* istanbul ignore next */ + internalServerError(res); + } + } + }); + }; + json(asset, CT).then( + headers => { + if (lastModified === IfModifiedSince) + serveFile(res, asset, headers, IfNoneMatch); + else + create(); + }, + create + ); + } + }, + () => { if (next) next(); else { @@ -58,83 +137,10 @@ module.exports = ({source, dest, headers}) => { res.end(); } } - else if (path === '/favicon.ico') - streamFile(res, original, { - 'Content-Length': stats.size, - 'Content-Type': 'image/vnd.microsoft.icon', - ...headers - }); - else { - let asset = DEST + path; - let compression = ''; - const { - ['accept-encoding']: AcceptEncoding, - ['if-none-match']: IfNoneMatch, - ['if-modified-since']: IfModifiedSince - } = req.headers; - if (compressed.has(extname(path).toLowerCase())) { - switch (true) { - /* istanbul ignore next */ - case /\bbr\b/.test(AcceptEncoding): - compression = '.br'; - break; - case /\bgzip\b/.test(AcceptEncoding): - compression = '.gzip'; - break; - /* istanbul ignore next */ - case /\bdeflate\b/.test(AcceptEncoding): - compression = '.deflate'; - break; - } - asset += compression; - } - readFile(asset + '.json', (err, data) => { - // if there was no error, be sure the source file is still the same - if (!err) { - if (new Date(stats.mtimeMs).toUTCString() === IfModifiedSince) { - serveFile(res, asset, parse(data), IfNoneMatch); - return; - } - } - // if the file was modified, re-optimize it, assuming it changed too - const {length} = compression; - const compress = length ? asset.slice(0, -length) : asset; - const waitForIt = compress + '.wait'; - mkdir(dirname(waitForIt), {recursive: true}, err => { - /* istanbul ignore next */ - if (err) - internalServerError(res); - else if (existsSync(waitForIt)) - watchFile(waitForIt, () => { - unwatchFile(waitForIt); - readAndServe(res, asset, IfNoneMatch); - }); - else { - try { - writeFileSync(waitForIt, path); - ucompress(original, compress, options) - .then( - () => { - unlink(waitForIt, err => { - /* istanbul ignore next */ - if (err) - internalServerError(res); - else - readAndServe(res, asset, IfNoneMatch); - }); - }, - /* istanbul ignore next */ - () => unlink(waitForIt, () => internalServerError(res)) - ); - } - catch (o_O) { - /* istanbul ignore next */ - internalServerError(res); - } - } - }); - }); - } - }); + ); }; }; + +function andClose() { + this.close(); +} diff --git a/cjs/json.js b/cjs/json.js new file mode 100644 index 0000000..e5b1efe --- /dev/null +++ b/cjs/json.js @@ -0,0 +1,29 @@ +'use strict'; +const {readFile} = require('fs'); + +const umap = (m => m.__esModule ? /* istanbul ignore next */ m.default : /* istanbul ignore next */ m)(require('umap')); + +const {parse} = JSON; + +const _ = new Map; +const $ = umap(_); + +const clear = asset => { + _.delete(asset); +}; + +module.exports = (asset, timeout = 1000) => ( + $.get(asset) || + $.set(asset, new Promise((res, rej) => { + readFile(asset + '.json', (err, data) => { + if (err) { + _.delete(asset); + rej(); + } + else { + res(parse(data)); + setTimeout(clear, timeout, asset); + } + }); + })) +); diff --git a/cjs/stat.js b/cjs/stat.js new file mode 100644 index 0000000..1175034 --- /dev/null +++ b/cjs/stat.js @@ -0,0 +1,30 @@ +'use strict'; +const {stat} = require('fs'); + +const umap = (m => m.__esModule ? /* istanbul ignore next */ m.default : /* istanbul ignore next */ m)(require('umap')); + +const _ = new Map; +const $ = umap(_); + +const clear = asset => { + _.delete(asset); +}; + +module.exports = (asset, timeout = 1000) => ( + $.get(asset) || + $.set(asset, new Promise((res, rej) => { + stat(asset, (err, stats) => { + if (err || !stats.isFile()) { + _.delete(asset); + rej(); + } + else { + setTimeout(clear, timeout, asset); + res({ + lastModified: new Date(stats.mtimeMs).toUTCString(), + size: stats.size + }); + } + }); + })) +); diff --git a/esm/index.js b/esm/index.js index 30e9a2a..7a52fea 100644 --- a/esm/index.js +++ b/esm/index.js @@ -1,14 +1,15 @@ import { - createReadStream, stat, mkdir, readFile, unlink, - existsSync, writeFileSync, - watchFile, unwatchFile + createReadStream, mkdir, unlink, + existsSync, writeFileSync, watch } from 'fs'; import {tmpdir} from 'os'; import {dirname, extname, join, resolve} from 'path'; import ucompress from 'ucompress'; -const {parse} = JSON; +import json from './json.js'; +import stat from './stat.js'; + const {compressed} = ucompress; const getPath = source => (source[0] === '/' ? source : resolve(source)); @@ -19,15 +20,12 @@ const internalServerError = res => { res.end(); }; -const readAndServe = (res, asset, IfNoneMatch) => { - readFile(asset + '.json', (err, data) => { +const readAndServe = (res, asset, cacheTimeout, IfNoneMatch) => + json(asset, cacheTimeout).then( + headers => serveFile(res, asset, headers, IfNoneMatch), /* istanbul ignore next */ - if (err) - internalServerError(res); - else - serveFile(res, asset, parse(data), IfNoneMatch); - }); -}; + () => internalServerError(res) + ); const serveFile = (res, asset, headers, IfNoneMatch) => { if (headers.ETag === IfNoneMatch) { @@ -43,15 +41,95 @@ const streamFile = (res, asset, headers) => { createReadStream(asset).pipe(res); }; -export default ({source, dest, headers}) => { +export default ({source, dest, headers, cacheTimeout: CT}) => { const SOURCE = getPath(source); const DEST = dest ? getPath(dest) : join(tmpdir(), 'ucdn'); const options = {createFiles: true, headers}; return (req, res, next) => { const path = req.url.replace(/\?.*$/, ''); const original = SOURCE + path; - stat(original, (err, stats) => { - if (err || !stats.isFile()) { + stat(original, CT).then( + ({lastModified, size}) => { + if (path === '/favicon.ico') + streamFile(res, original, { + 'Content-Length': size, + 'Content-Type': 'image/vnd.microsoft.icon', + ...headers + }); + else { + let asset = DEST + path; + let compression = ''; + const { + ['accept-encoding']: AcceptEncoding, + ['if-none-match']: IfNoneMatch, + ['if-modified-since']: IfModifiedSince + } = req.headers; + if (compressed.has(extname(path).toLowerCase())) { + switch (true) { + /* istanbul ignore next */ + case /\bbr\b/.test(AcceptEncoding): + compression = '.br'; + break; + case /\bgzip\b/.test(AcceptEncoding): + compression = '.gzip'; + break; + /* istanbul ignore next */ + case /\bdeflate\b/.test(AcceptEncoding): + compression = '.deflate'; + break; + } + asset += compression; + } + const create = () => { + const {length} = compression; + const compress = length ? asset.slice(0, -length) : asset; + const waitForIt = compress + '.wait'; + mkdir(dirname(waitForIt), {recursive: true}, err => { + /* istanbul ignore if */ + if (err) + internalServerError(res); + else if (existsSync(waitForIt)) + watch(waitForIt, andClose).on( + 'close', + () => readAndServe(res, asset, CT, IfNoneMatch) + ); + else { + try { + writeFileSync(waitForIt, path); + ucompress(original, compress, options) + .then( + () => { + unlink(waitForIt, err => { + /* istanbul ignore if */ + if (err) + internalServerError(res); + else + readAndServe(res, asset, CT, IfNoneMatch); + }); + }, + /* istanbul ignore next */ + () => unlink(waitForIt, () => internalServerError(res)) + ); + } + catch (o_O) { + /* istanbul ignore next */ + internalServerError(res); + } + } + }); + }; + json(asset, CT).then( + headers => { + if (lastModified === IfModifiedSince) + serveFile(res, asset, headers, IfNoneMatch); + else + create(); + }, + create + ); + } + }, + () => { if (next) next(); else { @@ -59,83 +137,10 @@ export default ({source, dest, headers}) => { res.end(); } } - else if (path === '/favicon.ico') - streamFile(res, original, { - 'Content-Length': stats.size, - 'Content-Type': 'image/vnd.microsoft.icon', - ...headers - }); - else { - let asset = DEST + path; - let compression = ''; - const { - ['accept-encoding']: AcceptEncoding, - ['if-none-match']: IfNoneMatch, - ['if-modified-since']: IfModifiedSince - } = req.headers; - if (compressed.has(extname(path).toLowerCase())) { - switch (true) { - /* istanbul ignore next */ - case /\bbr\b/.test(AcceptEncoding): - compression = '.br'; - break; - case /\bgzip\b/.test(AcceptEncoding): - compression = '.gzip'; - break; - /* istanbul ignore next */ - case /\bdeflate\b/.test(AcceptEncoding): - compression = '.deflate'; - break; - } - asset += compression; - } - readFile(asset + '.json', (err, data) => { - // if there was no error, be sure the source file is still the same - if (!err) { - if (new Date(stats.mtimeMs).toUTCString() === IfModifiedSince) { - serveFile(res, asset, parse(data), IfNoneMatch); - return; - } - } - // if the file was modified, re-optimize it, assuming it changed too - const {length} = compression; - const compress = length ? asset.slice(0, -length) : asset; - const waitForIt = compress + '.wait'; - mkdir(dirname(waitForIt), {recursive: true}, err => { - /* istanbul ignore next */ - if (err) - internalServerError(res); - else if (existsSync(waitForIt)) - watchFile(waitForIt, () => { - unwatchFile(waitForIt); - readAndServe(res, asset, IfNoneMatch); - }); - else { - try { - writeFileSync(waitForIt, path); - ucompress(original, compress, options) - .then( - () => { - unlink(waitForIt, err => { - /* istanbul ignore next */ - if (err) - internalServerError(res); - else - readAndServe(res, asset, IfNoneMatch); - }); - }, - /* istanbul ignore next */ - () => unlink(waitForIt, () => internalServerError(res)) - ); - } - catch (o_O) { - /* istanbul ignore next */ - internalServerError(res); - } - } - }); - }); - } - }); + ); }; }; + +function andClose() { + this.close(); +} diff --git a/esm/json.js b/esm/json.js new file mode 100644 index 0000000..aded27b --- /dev/null +++ b/esm/json.js @@ -0,0 +1,28 @@ +import {readFile} from 'fs'; + +import umap from 'umap'; + +const {parse} = JSON; + +const _ = new Map; +const $ = umap(_); + +const clear = asset => { + _.delete(asset); +}; + +export default (asset, timeout = 1000) => ( + $.get(asset) || + $.set(asset, new Promise((res, rej) => { + readFile(asset + '.json', (err, data) => { + if (err) { + _.delete(asset); + rej(); + } + else { + res(parse(data)); + setTimeout(clear, timeout, asset); + } + }); + })) +); diff --git a/esm/stat.js b/esm/stat.js new file mode 100644 index 0000000..4b2c56d --- /dev/null +++ b/esm/stat.js @@ -0,0 +1,29 @@ +import {stat} from 'fs'; + +import umap from 'umap'; + +const _ = new Map; +const $ = umap(_); + +const clear = asset => { + _.delete(asset); +}; + +export default (asset, timeout = 1000) => ( + $.get(asset) || + $.set(asset, new Promise((res, rej) => { + stat(asset, (err, stats) => { + if (err || !stats.isFile()) { + _.delete(asset); + rej(); + } + else { + setTimeout(clear, timeout, asset); + res({ + lastModified: new Date(stats.mtimeMs).toUTCString(), + size: stats.size + }); + } + }); + })) +); diff --git a/package.json b/package.json index 3c92e2d..82c24ef 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "build": "npm run cjs && npm run test", "cjs": "ascjs --no-default esm cjs", "coveralls": "nyc report --reporter=text-lcov | coveralls", - "test": "rm -rf test/dest && nyc node test/index.js" + "test": "rm -rf test/dest && nyc node test/index.js && rm test/index.test" }, "keywords": [ "ucompress", diff --git a/test/index.js b/test/index.js index fb6c4d3..0efa8e1 100644 --- a/test/index.js +++ b/test/index.js @@ -148,6 +148,7 @@ Promise.resolve('\x1b[1mµcdn\x1b[0m') })) .then(name => { requestHandler = cdn({ + cacheTimeout: 100, source: './test/source' }); return name; @@ -215,5 +216,45 @@ Promise.resolve('\x1b[1mµcdn\x1b[0m') }) ); })) + + .then(name => new Promise(resolve => { + console.log(name); + const path = '/benja-dark.svg'; + const request = createRequest(path); + request.headers.acceptEncoding = ''; + let i = 0; + const done = () => { + if (++i === 2) + resolve(path); + }; + requestHandler( + request, + createResponse(operations => { + console.assert(operations.length === 2, 'correct amount of operations'); + const [code, headers] = operations.shift(); + const content = operations.shift(); + console.assert(content.length < 1, 'correct content'); + console.assert(code === 200, 'correct code'); + console.assert(headers['Content-Length'] === 2478, 'correct length'); + console.assert(headers['Content-Type'] === 'image/svg+xml', 'correct mime'); + console.assert(headers['ETag'] === '"9ae-WAa0uuWT+I9+hj77"', 'correct ETag'); + done(); + }) + ); + requestHandler( + request, + createResponse(operations => { + console.assert(operations.length === 2, 'correct amount of operations'); + const [code, headers] = operations.shift(); + const content = operations.shift(); + console.assert(content.length < 1, 'correct content'); + console.assert(code === 200, 'correct code'); + console.assert(headers['Content-Length'] === 2478, 'correct length'); + console.assert(headers['Content-Type'] === 'image/svg+xml', 'correct mime'); + console.assert(headers['ETag'] === '"9ae-WAa0uuWT+I9+hj77"', 'correct ETag'); + done(); + }) + ); + })) .then(console.log) ; diff --git a/test/index.test b/test/index.test deleted file mode 100644 index ea71b61..0000000 Binary files a/test/index.test and /dev/null differ diff --git a/test/server.cjs b/test/server.cjs index abd331e..60aa6f6 100644 --- a/test/server.cjs +++ b/test/server.cjs @@ -3,6 +3,7 @@ const {join} = require('path'); const cdn = require('../cjs'); const callback = cdn({ + cacheTimeout: 10000, source: join(__dirname, 'source'), dest: join(__dirname, 'dest') });