Created a File Class (#97)

* Created a File Class
* Test the untested parts
* ignore any d.ts file
This commit is contained in:
Jimmy Wärting
2021-05-29 11:17:26 +02:00
committed by GitHub
parent 8ef89adad4
commit 8d14e88040
8 changed files with 198 additions and 49 deletions
+1 -2
View File
@@ -62,5 +62,4 @@ typings/
# dotenv environment variables file # dotenv environment variables file
.env .env
index.d.ts *.d.ts
from.d.ts
+5 -4
View File
@@ -1,18 +1,19 @@
Changelog Changelog
========= =========
## v3.0.0-rc.0 ## v3.0.0
- Changed WeakMap for private field (require node 12) - Changed WeakMap for private field (require node 12)
- Switch to ESM - Switch to ESM
- blob.stream() return a subset of whatwg stream which is the async iterable - blob.stream() return a subset of whatwg stream which is the async iterable part
(it no longer return a node stream) (it no longer return a node stream)
- Reduced the dependency of Buffer by changing to global TextEncoder/Decoder (require node 11) - Reduced the dependency of Buffer by changing to global TextEncoder/Decoder (require node 11)
- Disabled xo since it could understand private fields (#) - Disabled xo since it could understand private fields (#)
- No longer transform the type to lowercase (https://github.com/w3c/FileAPI/issues/43) - No longer transform the type to lowercase (https://github.com/w3c/FileAPI/issues/43)
This is more loose than strict, keys should be lowercased, but values should not. This is more loose than strict, keys should be lowercased, but values should not.
It would require a more proper mime type parser - so we just made it loose. It would require a more proper mime type parser - so we just made it loose.
- index.js can now be imported by browser & deno since it no longer depends on any - index.js and file.js can now be imported by browser & deno since it no longer depends on any
core node features (but why would you? other environment can benefit from it) core node features (but why would you?)
- Implemented a File class
## v2.1.2 ## v2.1.2
- Fixed a bug where `start` in BlobDataItem was undefined (#85) - Fixed a bug where `start` in BlobDataItem was undefined (#85)
+22 -12
View File
@@ -86,7 +86,11 @@ npm install fetch-blob
// Ways to import // Ways to import
// (PS it's dependency free ESM package so regular http-import from CDN works too) // (PS it's dependency free ESM package so regular http-import from CDN works too)
import Blob from 'fetch-blob' import Blob from 'fetch-blob'
import File from 'fetch-blob/file.js'
import {Blob} from 'fetch-blob' import {Blob} from 'fetch-blob'
import {File} from 'fetch-blob/file.js'
const {Blob} = await import('fetch-blob') const {Blob} = await import('fetch-blob')
@@ -105,27 +109,33 @@ globalThis.ReadableStream.from(blob.stream())
``` ```
### Blob part backed up by filesystem ### Blob part backed up by filesystem
To use, install [domexception](https://github.com/jsdom/domexception).
```sh `fetch-blob/from.js` comes packed with tools to convert any filepath into either a Blob or a File
npm install fetch-blob domexception It will not read the content into memory. It will only stat the file for last modified date and file size.
```
```js ```js
// The default export is sync and use fs.stat to retrieve size & last modified // The default export is sync and use fs.stat to retrieve size & last modified as a blob
import blobFromSync from 'fetch-blob/from.js' import blobFromSync from 'fetch-blob/from.js'
import {Blob, blobFrom, blobFromSync} from 'fetch-blob/from.js' import {File, Blob, blobFrom, blobFromSync, fileFrom, fileFromSync} from 'fetch-blob/from.js'
const fsBlob1 = blobFromSync('./2-GiB-file.bin') const fsFile = fileFromSync('./2-GiB-file.bin', 'application/octet-stream')
const fsBlob2 = await blobFrom('./2-GiB-file.bin') const fsBlob = await blobFrom('./2-GiB-file.mp4')
// Not a 4 GiB memory snapshot, just holds 3 references // Not a 4 GiB memory snapshot, just holds references
// points to where data is located on the disk // points to where data is located on the disk
const blob = new Blob([fsBlob1, fsBlob2, 'memory']) const blob = new Blob([fsFile, fsBlob, 'memory', new Uint8Array(10)])
console.log(blob.size) // 4 GiB console.log(blob.size) // ~4 GiB
``` ```
See the [MDN documentation](https://developer.mozilla.org/en-US/docs/Web/API/Blob) and [tests](https://github.com/node-fetch/fetch-blob/blob/master/test.js) for more details. `blobFrom|blobFromSync|fileFrom|fileFromSync(path, [mimetype])`
### Creating Blobs backed up by other async sources
Our Blob & File class are more generic then any other polyfills in the way that it can accept any blob look-a-like item
An example of this is that our blob implementation can be constructed with parts coming from [BlobDataItem](https://github.com/node-fetch/fetch-blob/blob/8ef89adad40d255a3bbd55cf38b88597c1cd5480/from.js#L32) (aka a filepath) or from [buffer.Blob](https://nodejs.org/api/buffer.html#buffer_new_buffer_blob_sources_options), It dose not have to implement all the methods - just enough that it can be read/understood by our Blob implementation. The minium requirements is that it has `Symbol.toStringTag`, `size`, `slice()` and either a `stream()` or a `arrayBuffer()` method. If you then wrap it in our Blob or File `new Blob([blobDataItem])` then you get all of the other methods that should be implemented in a blob or file
An example of this could be to create a file or blob like item coming from a remote HTTP request. Or from a DataBase
See the [MDN documentation](https://developer.mozilla.org/en-US/docs/Web/API/Blob) and [tests](https://github.com/node-fetch/fetch-blob/blob/master/test.js) for more details of how to use the Blob.
[npm-image]: https://flat.badgen.net/npm/v/fetch-blob [npm-image]: https://flat.badgen.net/npm/v/fetch-blob
[npm-url]: https://www.npmjs.com/package/fetch-blob [npm-url]: https://www.npmjs.com/package/fetch-blob
+36
View File
@@ -0,0 +1,36 @@
import Blob from './index.js';
export default class File extends Blob {
#lastModified = 0;
#name = '';
/**
* @param {*[]} fileBits
* @param {string} fileName
* @param {{lastModified?: number, type?: string}} options
*/ // @ts-ignore
constructor(fileBits, fileName, options = {}) {
if (arguments.length < 2) {
throw new TypeError(`Failed to construct 'File': 2 arguments required, but only ${arguments.length} present.`);
}
super(fileBits, options);
const modified = Number(options.lastModified);
this.#lastModified = Number.isNaN(this.#lastModified) ? modified : Date.now()
this.#name = fileName;
}
get name() {
return this.#name;
}
get lastModified() {
return this.#lastModified;
}
get [Symbol.toStringTag]() {
return "File";
}
}
export { File };
+36 -8
View File
@@ -1,26 +1,54 @@
import {statSync, createReadStream} from 'fs'; import {statSync, createReadStream} from 'fs';
import {stat} from 'fs/promises'; import {stat} from 'fs/promises';
import DOMException from 'domexception'; import {basename} from 'path';
import File from './file.js';
import Blob from './index.js'; import Blob from './index.js';
import {MessageChannel} from 'worker_threads';
const DOMException = globalThis.DOMException || (() => {
const port = new MessageChannel().port1
const ab = new ArrayBuffer(0)
try { port.postMessage(ab, [ab, ab]) }
catch (err) { return err.constructor }
})()
/** /**
* @param {string} path filepath on the disk * @param {string} path filepath on the disk
* @returns {Blob} * @param {string} [type] mimetype to use
*/ */
const blobFromSync = path => from(statSync(path), path); const blobFromSync = (path, type) => fromBlob(statSync(path), path, type);
/** /**
* @param {string} path filepath on the disk * @param {string} path filepath on the disk
* @returns {Promise<Blob>} * @param {string} [type] mimetype to use
*/ */
const blobFrom = path => stat(path).then(stat => from(stat, path)); const blobFrom = (path, type) => stat(path).then(stat => fromBlob(stat, path, type));
const from = (stat, path) => new Blob([new BlobDataItem({ /**
* @param {string} path filepath on the disk
* @param {string} [type] mimetype to use
*/
const fileFrom = (path, type) => stat(path).then(stat => fromFile(stat, path, type));
/**
* @param {string} path filepath on the disk
* @param {string} [type] mimetype to use
*/
const fileFromSync = (path, type) => fromFile(statSync(path), path, type);
const fromBlob = (stat, path, type = '') => new Blob([new BlobDataItem({
path, path,
size: stat.size, size: stat.size,
lastModified: stat.mtimeMs, lastModified: stat.mtimeMs,
start: 0 start: 0
})]); })], {type});
const fromFile = (stat, path, type = '') => new File([new BlobDataItem({
path,
size: stat.size,
lastModified: stat.mtimeMs,
start: 0
})], basename(path), { type, lastModified: stat.mtimeMs });
/** /**
* This is a blob backed up by a file on the disk * This is a blob backed up by a file on the disk
@@ -72,4 +100,4 @@ class BlobDataItem {
} }
export default blobFromSync; export default blobFromSync;
export {Blob, blobFrom, blobFromSync}; export {File, Blob, blobFrom, blobFromSync, fileFrom, fileFromSync};
+2 -4
View File
@@ -173,7 +173,7 @@ export default class Blob {
added += chunk.size added += chunk.size
} }
blobParts.push(chunk); blobParts.push(chunk);
relativeStart = 0; // All next sequental parts should start at 0 relativeStart = 0; // All next sequential parts should start at 0
// don't add the overflow to new blobParts // don't add the overflow to new blobParts
if (added >= span) { if (added >= span) {
@@ -195,9 +195,7 @@ export default class Blob {
static [Symbol.hasInstance](object) { static [Symbol.hasInstance](object) {
return ( return (
object && typeof object?.constructor === 'function' &&
typeof object === 'object' &&
typeof object.constructor === 'function' &&
( (
typeof object.stream === 'function' || typeof object.stream === 'function' ||
typeof object.arrayBuffer === 'function' typeof object.arrayBuffer === 'function'
+24 -15
View File
@@ -1,11 +1,13 @@
{ {
"name": "fetch-blob", "name": "fetch-blob",
"version": "3.0.0-rc.0", "version": "3.0.0",
"description": "A Blob implementation in Node.js, originally from node-fetch.", "description": "Blob & File implementation in Node.js, originally from node-fetch.",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",
"files": [ "files": [
"from.js", "from.js",
"file.js",
"file.d.ts",
"index.js", "index.js",
"index.d.ts", "index.d.ts",
"from.d.ts" "from.d.ts"
@@ -20,12 +22,13 @@
"repository": "https://github.com/node-fetch/fetch-blob.git", "repository": "https://github.com/node-fetch/fetch-blob.git",
"keywords": [ "keywords": [
"blob", "blob",
"file",
"node-fetch" "node-fetch"
], ],
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
}, },
"author": "David Frank", "author": "Jimmy Wärting <jimmy@warting.se> (https://jimmy.warting.se)",
"license": "MIT", "license": "MIT",
"bugs": { "bugs": {
"url": "https://github.com/node-fetch/fetch-blob/issues" "url": "https://github.com/node-fetch/fetch-blob/issues"
@@ -33,7 +36,9 @@
"homepage": "https://github.com/node-fetch/fetch-blob#readme", "homepage": "https://github.com/node-fetch/fetch-blob#readme",
"xo": { "xo": {
"rules": { "rules": {
"unicorn/import-index": "off", "unicorn/prefer-node-protocol": "off",
"unicorn/numeric-separators-style": "off",
"unicorn/prefer-spread": "off",
"import/extensions": [ "import/extensions": [
"error", "error",
"always", "always",
@@ -52,18 +57,22 @@
} }
] ]
}, },
"peerDependenciesMeta": {
"domexception": {
"optional": true
}
},
"devDependencies": { "devDependencies": {
"ava": "^3.15.0", "ava": "^3.15.0",
"c8": "^7.7.1", "c8": "^7.7.2",
"codecov": "^3.8.1", "codecov": "^3.8.2",
"domexception": "^2.0.1",
"node-fetch": "^3.0.0-beta.9", "node-fetch": "^3.0.0-beta.9",
"typescript": "^4.2.4", "typescript": "^4.3.2",
"xo": "^0.38.2" "xo": "^0.40.1"
} },
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "paypal",
"url": "https://paypal.me/jimmywarting"
}
]
} }
+72 -4
View File
@@ -1,10 +1,11 @@
import fs from 'fs'; import fs from 'fs';
import test from 'ava';
import {Response} from 'node-fetch';
import {Readable} from 'stream'; import {Readable} from 'stream';
import buffer from 'buffer'; import buffer from 'buffer';
import test from 'ava';
import {Response} from 'node-fetch';
import syncBlob, {blobFromSync, blobFrom, fileFromSync, fileFrom} from './from.js';
import File from './file.js';
import Blob from './index.js'; import Blob from './index.js';
import syncBlob, {blobFromSync, blobFrom} from './from.js';
const license = fs.readFileSync('./LICENSE', 'utf-8'); const license = fs.readFileSync('./LICENSE', 'utf-8');
@@ -164,7 +165,22 @@ test('Reading after modified should fail', async t => {
const now = new Date(); const now = new Date();
// Change modified time // Change modified time
fs.utimesSync('./LICENSE', now, now); fs.utimesSync('./LICENSE', now, now);
const error = await blob.text().catch(error => error); const error = await t.throwsAsync(blob.text());
t.is(error.constructor.name, 'DOMException');
t.is(error instanceof Error, true);
t.is(error.name, 'NotReadableError');
});
test('Reading file after modified should fail', async t => {
const file = fileFromSync('./LICENSE');
await new Promise(resolve => {
setTimeout(resolve, 100);
});
const now = new Date();
// Change modified time
fs.utimesSync('./LICENSE', now, now);
const error = await t.throwsAsync(file.text());
t.is(error.constructor.name, 'DOMException');
t.is(error instanceof Error, true); t.is(error instanceof Error, true);
t.is(error.name, 'NotReadableError'); t.is(error.name, 'NotReadableError');
}); });
@@ -239,6 +255,7 @@ test('Large chunks are divided into smaller chunks', async t => {
}); });
test('Can use named import - as well as default', async t => { test('Can use named import - as well as default', async t => {
// eslint-disable-next-line node/no-unsupported-features/es-syntax
const {Blob, default: def} = await import('./index.js'); const {Blob, default: def} = await import('./index.js');
t.is(Blob, def); t.is(Blob, def);
}); });
@@ -254,3 +271,54 @@ if (buffer.Blob) {
t.is(await blob2.text(), 'blob part'); t.is(await blob2.text(), 'blob part');
}); });
} }
test('File is a instance of blob', t => {
t.true(new File([], '') instanceof Blob);
});
test('fileFrom returns the name', async t => {
t.is((await fileFrom('./LICENSE')).name, 'LICENSE');
});
test('fileFromSync returns the name', t => {
t.is(fileFromSync('./LICENSE').name, 'LICENSE');
});
test('fileFromSync(path, type) sets the type', t => {
t.is(fileFromSync('./LICENSE', 'text/plain').type, 'text/plain');
});
test('blobFromSync(path, type) sets the type', t => {
t.is(blobFromSync('./LICENSE', 'text/plain').type, 'text/plain');
});
test('fileFrom(path, type) sets the type', async t => {
const file = await fileFrom('./LICENSE', 'text/plain');
t.is(file.type, 'text/plain');
});
test('fileFrom(path, type) read/sets the lastModified ', async t => {
const file = await fileFrom('./LICENSE', 'text/plain');
// Earlier test updates the last modified date to now
t.is(typeof file.lastModified, 'number');
// The lastModifiedDate is deprecated and removed from spec
t.false('lastModifiedDate' in file);
t.is(file.lastModified > Date.now() - 60000, true);
});
test('blobFrom(path, type) sets the type', async t => {
const blob = await blobFrom('./LICENSE', 'text/plain');
t.is(blob.type, 'text/plain');
});
test('blobFrom(path) sets empty type', async t => {
const blob = await blobFrom('./LICENSE');
t.is(blob.type, '');
});
test('new File() throws with too few args', t => {
t.throws(() => new File(), {
instanceOf: TypeError,
message: 'Failed to construct \'File\': 2 arguments required, but only 0 present.'
});
});