From b3415984a2f7670f05ce329733bec54d3755f1f3 Mon Sep 17 00:00:00 2001 From: jjrv Date: Tue, 10 Nov 2015 01:53:02 +0200 Subject: [PATCH] Initial commit. --- .gitignore | 7 + .npmignore | 7 + LICENSE | 22 ++ README.md | 13 + package.json | 39 +++ src/cget.ts | 4 + src/cget/Cache.ts | 347 ++++++++++++++++++++++++++ src/cget/Task.ts | 32 +++ src/cget/TaskQueue.ts | 33 +++ src/cget/util.ts | 142 +++++++++++ src/tsconfig-browser.json | 14 ++ src/tsconfig.json | 14 ++ test/cache/example.invalid/index.html | 5 + test/serve.ts | 87 +++++++ test/tsconfig.json | 14 ++ tsd.json | 21 ++ 16 files changed, 801 insertions(+) create mode 100644 .gitignore create mode 100644 .npmignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 package.json create mode 100644 src/cget.ts create mode 100644 src/cget/Cache.ts create mode 100644 src/cget/Task.ts create mode 100644 src/cget/TaskQueue.ts create mode 100644 src/cget/util.ts create mode 100644 src/tsconfig-browser.json create mode 100644 src/tsconfig.json create mode 100644 test/cache/example.invalid/index.html create mode 100644 test/serve.ts create mode 100644 test/tsconfig.json create mode 100644 tsd.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..90d8a88 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +typings/ +*.js +*.d.ts +*.log.* +*.log +*.tgz diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..d5fda4c --- /dev/null +++ b/.npmignore @@ -0,0 +1,7 @@ +node_modules/ +typings/ +test/ +src/ +*.log.* +*.log +*.tgz diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5552ee0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2015 BusFaster Ltd + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..86989f5 --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ +cget +==== + +`cget` is a robust streaming cache for HTTP requests. +It stores downloaded files into the filesystem with names and a directory structure matching the remote paths. +This makes it easy to manually inspect the cached data. + +License +=== + +[The MIT License](https://raw.githubusercontent.com/charto/cget/master/LICENSE) + +Copyright (c) 2015 BusFaster Ltd diff --git a/package.json b/package.json new file mode 100644 index 0000000..69e0b85 --- /dev/null +++ b/package.json @@ -0,0 +1,39 @@ +{ + "name": "cget", + "version": "0.0.1", + "description": "Robust streaming cache for HTTP requests", + "main": "dist/cget.js", + "typings": "dist/cget.d.ts", + "browser": "dist/browser.js", + "scripts": { + "prepublish": "tsd install && cd src && tsc && tsc -p tsconfig-browser.json && tsc -p ../test", + "test": "node test/serve.js test/cache index.html" + }, + "author": "Juha Järvi", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/charto/cget.git" + }, + "bugs": { + "url": "https://github.com/charto/cget/issues" + }, + "homepage": "https://github.com/charto/cget#readme", + "keywords": [ + "cache", + "caching", + "request", + "fetch", + "download", + "http", + "server" + ], + "dependencies": { + "bluebird": "~3.0.5", + "request": "~2.67.0" + }, + "devDependencies": { + "tsd": "~0.6.5", + "typescript": "^1.8.0-dev.20151128" + } +} diff --git a/src/cget.ts b/src/cget.ts new file mode 100644 index 0000000..babbdac --- /dev/null +++ b/src/cget.ts @@ -0,0 +1,4 @@ +import * as _util from './cget/util'; +export {Cache} from './cget/Cache'; + +export var util = _util; diff --git a/src/cget/Cache.ts b/src/cget/Cache.ts new file mode 100644 index 0000000..1c37c76 --- /dev/null +++ b/src/cget/Cache.ts @@ -0,0 +1,347 @@ +// This file is part of cget, copyright (c) 2015 BusFaster Ltd. +// Released under the MIT license, see LICENSE. + +import * as fs from 'fs'; +import * as path from 'path'; + +import * as url from 'url'; +import * as http from 'http'; +import * as stream from 'stream'; +import * as request from 'request'; +import * as Promise from 'bluebird'; + +import {fsa, repeat, mkdirp, isDir, sanitizePath, sanitizeUrl} from './util' +import {TaskQueue} from './TaskQueue' +import {Task} from './Task' + +// TODO: continue interrupted downloads. + +Promise.longStackTraces(); + +export interface FetchOptions { + url?: string; + forceHost?: string; + forcePort?: number; +} + +export class CacheResult { + constructor(streamOut: stream.Readable, urlRemote: string) { + this.stream = streamOut; + this.url = urlRemote; + } + + stream: stream.Readable; + url: string; +} + +class FetchTask extends Task { + constructor(cache: Cache, options: FetchOptions) { + super(); + + this.cache = cache; + this.options = options; + } + + start(onFinish: (err?: NodeJS.ErrnoException) => void) { + // These fix atom-typescript syntax highlight: )) + var result = this.cache.fetchCached(this.options, onFinish).catch((err: NodeJS.ErrnoException) => { + // Re-throw unexpected errors. + if(err.code != 'ENOENT') { + onFinish(err); + throw(err); + } + + return(this.cache.fetchRemote(this.options, onFinish)); + }); + + return(result); + } + + cache: Cache; + options: FetchOptions; +} + +export class Cache { + + constructor(pathBase: string, indexName: string) { + this.pathBase = path.resolve(pathBase); + this.indexName = indexName; + } + + // Store HTTP redirects as files containing the new URL. + + addLinks(redirectList: string[], target: string) { + return(Promise.map(redirectList, (src: string) => { + this.createCachePath(src).then((cachePath: string) => + fsa.writeFile(cachePath, 'LINK: ' + target + '\n', {encoding: 'utf-8'}) + ) + })); + } + + // Get local cache file path where a remote URL should be downloaded. + + getCachePath(urlRemote: string) { + var cachePath = path.join( + this.pathBase, + sanitizePath(urlRemote.substr(urlRemote.indexOf(':') + 1)) + ); + + var makeValidPath = (isDir: boolean) => { + if(isDir) cachePath = path.join(cachePath, this.indexName); + + return(cachePath); + }; + + if(urlRemote.charAt(urlRemote.length - 1) == '/') { + return(Promise.resolve(makeValidPath(true))); + } + + return(isDir(urlRemote).then(makeValidPath)); + } + + // Like getCachePath, but create the path if is doesn't exist. + + createCachePath(urlRemote: string) { + return(this.getCachePath(urlRemote).then((cachePath: string) => { + return(mkdirp(path.dirname(cachePath)).then(() => cachePath)); + })); + } + + // Check if there's a cached link redirecting the URL. + + static checkRemoteLink(cachePath: string) { + return(fsa.open(cachePath, 'r').then((fd: number) => { + var buf = new Buffer(6); + + return(fsa.read(fd, buf, 0, 6, 0).then(() => { + fsa.close(fd); + + if(buf.equals(new Buffer('LINK: ', 'ascii'))) { + return(fsa.readFile(cachePath, {encoding: 'utf-8'}).then((link: string) => { + var urlRemote = link.substr(6).replace(/\s+$/, ''); + + return(urlRemote); + })); + } else return(null); + })); + })); + } + + // Fetch URL from cache or download it if not available yet. + // Returns the file's URL after redirections and a readable stream of its contents. + + fetch(options: FetchOptions) { + return(this.fetchQueue.add(new FetchTask(this, { + url: sanitizeUrl(options.url), + forceHost: options.forceHost, + forcePort: options.forcePort + }))); + } + + fetchCached(options: FetchOptions, onFinish: (err?: NodeJS.ErrnoException) => void) { + // These fix atom-typescript syntax highlight: )) + var urlRemote = options.url; + console.log('BEGIN CACHED ' + urlRemote); + + // Any errors shouldn't be handled here, but instead in the caller. + + var cachePath = this.getCachePath(urlRemote); + var targetPath = cachePath.then(Cache.checkRemoteLink).then((urlRemote: string) => { + if(urlRemote) return(this.getCachePath(urlRemote)); + else return(cachePath); + }); + + return(targetPath.then((targetPath: string) => { + var streamIn = fs.createReadStream(targetPath, {encoding: 'utf-8'}); + + streamIn.on('end', () => { + console.log('FINISH CACHED ' + urlRemote); + onFinish(); + }); + + return(new CacheResult( + streamIn, + urlRemote + )); + })); + } + + fetchRemote(options: FetchOptions, onFinish: (err?: NodeJS.ErrnoException) => void) { + // These fix atom-typescript syntax highlight: )) + var urlRemote = options.url; + console.log('BEGIN REMOTE ' + urlRemote); + + var redirectList: string[] = []; + var found = false; + var resolve: (result: any) => void; + var reject: (err: any) => void; + var promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }) + + function die(err: NodeJS.ErrnoException) { + // Abort and report. + if(streamRequest) streamRequest.abort(); +console.error('Got error:'); +console.error(err); +console.error('Downloading URL:'); +console.error(urlRemote); + reject(err); + onFinish(err); + throw(err); + } + + var streamBuffer = new stream.PassThrough(); + + var streamRequest = request.get({ + url: Cache.forceRedirect(urlRemote, options), + followRedirect: (res: http.IncomingMessage) => { + redirectList.push(urlRemote); + urlRemote = url.resolve(urlRemote, res.headers.location); + + this.fetchCached({ + url: urlRemote, + forceHost: options.forceHost, + forcePort: options.forcePort + }, onFinish).then((result: CacheResult) => { + // File was already found in cache so stop downloading. + + streamRequest.abort(); + + if(found) return; + found = true; + + this.addLinks(redirectList, urlRemote).finally(() => { + resolve(result); + }); + }).catch((err: NodeJS.ErrnoException) => { + if(err.code != 'ENOENT' && err.code != 'ENOTDIR') { + // Weird! + die(err); + } + }); + + return(true); + } + }); + + streamRequest.on('error', (err: NodeJS.ErrnoException) => { + // Check if retrying makes sense for this error. + if(( + 'EAI_AGAIN ECONNREFUSED ECONNRESET EHOSTUNREACH ' + + 'ENOTFOUND EPIPE ESOCKETTIMEDOUT ETIMEDOUT ' + ).indexOf(err.code) < 0) { + die(err); + } + + console.error('SHOULD RETRY'); + + throw(err); + }); + + streamRequest.on('response', (res: http.IncomingMessage) => { + if(found) return; + found = true; + + var code = res.statusCode; + + if(code != 200) { + if(code < 500 || code >= 600) { + var err = new Error(code + ' ' + res.statusMessage); + + // TODO: Cache the HTTP error. + + die(err); + } + + console.error('SHOULD RETRY'); + + throw(err); + } + + streamRequest.pipe(streamBuffer); + + this.createCachePath(urlRemote).then((cachePath: string) => { + var streamOut = fs.createWriteStream(cachePath); + + streamOut.on('finish', () => { + // Output stream file handle stays open after piping unless manually closed. + + streamOut.close(); + }); + + streamBuffer.pipe(streamOut); + + return(this.addLinks(redirectList, urlRemote).finally(() => { + resolve(new CacheResult( + streamBuffer as any as stream.Readable, + urlRemote + )); + })); + }).catch(die); + }); + + streamRequest.on('end', () => { + console.log('FINISH REMOTE ' + urlRemote); + onFinish(); + }); + + if(options.forceHost || options.forcePort) { + // Monkey-patch request to support forceHost when running tests. + + (streamRequest as any).chartoOptions = options; + } + + return(promise); + } + + static forceRedirect(urlRemote: string, options: FetchOptions) { + if(!options.forceHost && !options.forcePort) return(urlRemote); + + var urlParts = url.parse(urlRemote); + var changed = false; + + if(!urlParts.hostname) return(urlRemote); + + if(options.forceHost && urlParts.hostname != options.forceHost) { + urlParts.hostname = options.forceHost; + changed = true; + } + + if(options.forcePort && urlParts.port != '' + options.forcePort) { + urlParts.port = '' + options.forcePort; + changed = true; + } + + if(!changed) return(urlRemote); + + urlParts.search = '?host=' + encodeURIComponent(urlParts.host); + urlParts.host = null; + + return(url.format(urlParts)); + } + + fetchQueue = new TaskQueue(); + + pathBase: string; + indexName: string; + + // Monkey-patch request to support forceHost when running tests. + + static patchRequest() { + var proto = require('request/lib/redirect.js').Redirect.prototype; + + var func = proto.redirectTo; + + proto.redirectTo = function() { + var urlRemote = func.apply(this, Array.prototype.slice.apply(arguments)); + var options: FetchOptions = this.request.chartoOptions; + + if(urlRemote && options) return(Cache.forceRedirect(urlRemote, options)); + + return(urlRemote); + }; + } + +} diff --git a/src/cget/Task.ts b/src/cget/Task.ts new file mode 100644 index 0000000..d143e7a --- /dev/null +++ b/src/cget/Task.ts @@ -0,0 +1,32 @@ +// This file is part of cget, copyright (c) 2015 BusFaster Ltd. +// Released under the MIT license, see LICENSE. + +import * as Promise from 'bluebird'; + +export class Task { + constructor(func?: () => Promise) { + this.func = func; + } + + start(onFinish: (err?: NodeJS.ErrnoException) => void) { + // These fix atom-typescript syntax highlight: )) + return(this.func().finally(onFinish)); + } + + delay() { + return(new Promise((resolve: (result: ResultType) => void, reject: (err: any) => void) => { + this.resolve = resolve; + this.reject = reject; + })); + } + + resume(onFinish: (err?: NodeJS.ErrnoException) => void) { + // These fix atom-typescript syntax highlight: )) + return(this.start(onFinish).then(this.resolve).catch(this.reject)); + } + + func: () => Promise; + + resolve: (result: ResultType) => void; + reject: (err: any) => void; +} diff --git a/src/cget/TaskQueue.ts b/src/cget/TaskQueue.ts new file mode 100644 index 0000000..66365ab --- /dev/null +++ b/src/cget/TaskQueue.ts @@ -0,0 +1,33 @@ +// This file is part of cget, copyright (c) 2015 BusFaster Ltd. +// Released under the MIT license, see LICENSE. + +import {Task} from './Task' + +export class TaskQueue { + add(task: Task) { + if(this.busyCount < TaskQueue.concurrency) { + // Start the task immediately. + + ++this.busyCount; + return(task.start(() => this.next())); + } else { + // Schedule the task and return a promise that will behave exactly + // like what task.start() returns. + + this.backlog.push(task); + return(task.delay()); + } + } + + next() { + var task = this.backlog.shift(); + + if(task) task.resume(() => this.next()); + else --this.busyCount; + } + + static concurrency = 2; + + backlog: Task[] = []; + busyCount = 0; +} diff --git a/src/cget/util.ts b/src/cget/util.ts new file mode 100644 index 0000000..28d352d --- /dev/null +++ b/src/cget/util.ts @@ -0,0 +1,142 @@ +// This file is part of cget, copyright (c) 2015 BusFaster Ltd. +// Released under the MIT license, see LICENSE. + +// Define some simple utility functions to avoid depending on other packages. + +import * as fs from 'fs'; +import * as url from 'url'; +import * as path from 'path'; +import * as Promise from 'bluebird'; + +export var fsa = { + stat: Promise.promisify(fs.stat), + open: Promise.promisify(fs.open), + close: Promise.promisify(fs.close), + rename: Promise.promisify(fs.rename), + mkdir: Promise.promisify(fs.mkdir), + read: Promise.promisify(fs.read), + readFile: Promise.promisify(fs.readFile) as any as (name: string, options: {encoding: string; flag?: string;}) => Promise, + writeFile: Promise.promisify(fs.writeFile) as (name: string, content: string, options: {encoding: string; flag?: string;}) => void +}; + +var againSymbol = {}; +var again = () => againSymbol; + +export function repeat(fn: (again: () => {}) => Promise): Promise { + return(Promise.try(() => + fn(again) + ).then((result: T) => + (result == againSymbol) ? repeat(fn) : result + )); +} + +export function extend(dst: {[key: string]: any}, src: {[key: string]: any}) { + for(var key of Object.keys(src)) { + dst[key] = src[key]; + } + + return(dst); +} + +export function clone(src: Object) { + return(extend({}, src)); +} + +export function mkdirp(pathName: string) { + var partList = path.resolve(pathName).split(path.sep); + var prefixList = partList.slice(0); + var pathPrefix: string; + + // Remove path components until an existing directory is found. + + return(repeat((again: () => {}) => { + if(!prefixList.length) return; + + pathPrefix = prefixList.join(path.sep); + + return(Promise.try(() => fsa.stat(pathPrefix)).then((stats: fs.Stats) => { + if(stats.isFile()) { + // Trying to convert a file into a directory. + // Rename the file to indexName and move it into the new directory. + + var tempPath = pathPrefix + '.' + this.makeTempSuffix(6); + + return(Promise.try(() => + fsa.rename(pathPrefix, tempPath) + ).then(() => + fsa.mkdir(pathPrefix) + ).then(() => + fsa.rename(tempPath, path.join(pathPrefix, this.indexName)) + )); + } else if(!stats.isDirectory()) { + throw(new Error('Tried to create a directory inside something weird: ' + pathPrefix)); + } + }).catch((err: NodeJS.ErrnoException) => { + // Re-throw unexpected errors. + if(err.code != 'ENOENT' && err.code != 'ENOTDIR') throw(err); + + prefixList.pop(); + return(again()); + })); + })).then(() => Promise.reduce( + // Create path components that didn't exist yet. + partList.slice(prefixList.length), + (pathPrefix: any, part: string, index: number, len: number) => { + console.error(['CREATE NEW PATH', pathPrefix, part, partList.length, prefixList.length].join('\t')); + var pathNew = pathPrefix + path.sep + part; + + return(Promise.try(() => + fsa.mkdir(pathNew) + ).catch((err: NodeJS.ErrnoException) => { + // Because of a race condition with simultaneous cache stores, + // the directory might already exist. + + if(err.code != 'EEXIST') throw(err); + }).then(() => + pathNew + )); + }, + pathPrefix + )); +} + +// Create a string of random letters and numbers. + +export function makeTempSuffix(length: number) { + return( + Math.floor((Math.random() + 1) * Math.pow(36, length)) + .toString(36) + .substr(1) + ) +} + +export function sanitizePath(path: string) { + return(path + // Remove unwanted characters. + .replace(/[^-_./0-9A-Za-z]/g, '_') + + // Remove - _ . / from beginnings of path parts. + .replace(/(^|\/)[-_./]+/g, '$1') + + // Remove - _ . / from endings of path parts. + .replace(/[-_./]+($|\/)/g, '$1') + ); +} + +export function isDir(cachePath: string) { + return(fsa.stat(cachePath).then( + (stats: fs.Stats) => stats.isDirectory() + ).catch( + (err: NodeJS.ErrnoException) => false + )); +} + +export function sanitizeUrl(urlRemote: string) { + var urlParts = url.parse(urlRemote, false, true); + var origin = urlParts.host; + + if(urlParts.pathname.charAt(0) != '/') origin += '/'; + + origin += urlParts.pathname; + return((urlParts.protocol || 'http:') + '//' + url.resolve('', origin)); +} diff --git a/src/tsconfig-browser.json b/src/tsconfig-browser.json new file mode 100644 index 0000000..14c6263 --- /dev/null +++ b/src/tsconfig-browser.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "declaration": true, + "module": "system", + "noImplicitAny": true, + "outFile": "../dist/browser.js", + "target": "es5" + }, + "files": [ + "../typings/tsd.d.ts", + + "cget.ts" + ] +} diff --git a/src/tsconfig.json b/src/tsconfig.json new file mode 100644 index 0000000..c277ecd --- /dev/null +++ b/src/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "declaration": true, + "module": "commonjs", + "noImplicitAny": true, + "outDir": "../dist", + "target": "es5" + }, + "files": [ + "../typings/tsd.d.ts", + + "cget.ts" + ] +} diff --git a/test/cache/example.invalid/index.html b/test/cache/example.invalid/index.html new file mode 100644 index 0000000..124f157 --- /dev/null +++ b/test/cache/example.invalid/index.html @@ -0,0 +1,5 @@ + + Test + +

Test

+ diff --git a/test/serve.ts b/test/serve.ts new file mode 100644 index 0000000..308317d --- /dev/null +++ b/test/serve.ts @@ -0,0 +1,87 @@ +// This file is part of cget, copyright (c) 2015 BusFaster Ltd. +// Released under the MIT license, see LICENSE. + +import * as fs from 'fs'; +import * as url from 'url'; +import * as http from 'http'; + +import {fsa, extend} from '../dist/cget/util'; +import {FetchOptions, Cache, CacheResult} from '../dist/cget/Cache'; + +var cache = new Cache(process.argv[2], process.argv[3]); + +type ArgTbl = {[key: string]: string}; + +function parseArgs(query: string) { + var result: ArgTbl = {}; + + if(query) { + for(var item of query.split('&')) { + var partList = item.split('=').map(decodeURIComponent); + + if(partList.length == 2) result[partList[0]] = partList[1]; + } + } + + return(result); +} + +function reportError(res: http.ServerResponse, code: number, header?: Object) { + var body = new Buffer(code + '\n', 'utf-8'); + + header = extend( + header || {}, + { + 'Content-Type': 'text/plain', + 'Content-Length': body.length + } + ) + + res.writeHead(code, header); + + res.end(body); +} + +var app = http.createServer((req: http.IncomingMessage, res: http.ServerResponse) => { + var urlParts = url.parse(req.url); + var args = parseArgs(urlParts.query); + var host = args['host']; + + if(!host) { + reportError(res, 400); + return; + } + + urlParts.protocol = 'http'; + urlParts.search = null; + urlParts.query = null; + urlParts.host = host; + + var cachePath = cache.getCachePath(url.format(urlParts)); + + cachePath.then(Cache.checkRemoteLink).then((urlRemote: string) => { + if(urlRemote) { + reportError(res, 302, { + 'Location': urlRemote + }); + + return; + } + + fsa.stat(cachePath.value()).then((stats: fs.Stats) => { + var header = { + 'Content-Type': 'text/plain;charset=utf-8', + 'Content-Length': stats.size + }; + + res.writeHead(200, header); + + fs.createReadStream(cachePath.value()).pipe(res); + }); + }).catch((err: NodeJS.ErrnoException) => { + console.log('404: ' + req.url); + reportError(res, 404); + }); +}); + +app.listen(12345); diff --git a/test/tsconfig.json b/test/tsconfig.json new file mode 100644 index 0000000..4d9d2b4 --- /dev/null +++ b/test/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "declaration": false, + "module": "commonjs", + "noImplicitAny": true, + "target": "es5" + }, + "files": [ + "../typings/tsd.d.ts", + "../dist/cget.d.ts", + + "serve.ts" + ] +} diff --git a/tsd.json b/tsd.json new file mode 100644 index 0000000..aba05e9 --- /dev/null +++ b/tsd.json @@ -0,0 +1,21 @@ +{ + "version": "v4", + "repo": "borisyankov/DefinitelyTyped", + "ref": "master", + "path": "typings", + "bundle": "typings/tsd.d.ts", + "installed": { + "bluebird/bluebird.d.ts": { + "commit": "fb2b3b1e068c9ff7d8f9b0851c08d37d96c95c38" + }, + "node/node.d.ts": { + "commit": "fb2b3b1e068c9ff7d8f9b0851c08d37d96c95c38" + }, + "request/request.d.ts": { + "commit": "fb2b3b1e068c9ff7d8f9b0851c08d37d96c95c38" + }, + "form-data/form-data.d.ts": { + "commit": "fb2b3b1e068c9ff7d8f9b0851c08d37d96c95c38" + } + } +}