diff --git a/bundlewatch.config.json b/bundlewatch.config.json index 4a3fb794..f3d7de16 100644 --- a/bundlewatch.config.json +++ b/bundlewatch.config.json @@ -1,13 +1,13 @@ { "files": [ - { "path": "./examples/browser-rollup/dist/v1-size.js", "maxSize": "0.8 kB" }, - { "path": "./examples/browser-rollup/dist/v3-size.js", "maxSize": "1.8 kB" }, - { "path": "./examples/browser-rollup/dist/v4-size.js", "maxSize": "0.5 kB" }, - { "path": "./examples/browser-rollup/dist/v5-size.js", "maxSize": "1.2 kB" }, + { "path": "./examples/browser-rollup/dist/v1-size.js", "maxSize": "0.9 kB" }, + { "path": "./examples/browser-rollup/dist/v3-size.js", "maxSize": "2.1 kB" }, + { "path": "./examples/browser-rollup/dist/v4-size.js", "maxSize": "0.6 kB" }, + { "path": "./examples/browser-rollup/dist/v5-size.js", "maxSize": "1.5 kB" }, { "path": "./examples/browser-webpack/dist/v1-size.js", "maxSize": "1.3 kB" }, - { "path": "./examples/browser-webpack/dist/v3-size.js", "maxSize": "2.2 kB" }, - { "path": "./examples/browser-webpack/dist/v4-size.js", "maxSize": "0.9 kB" }, - { "path": "./examples/browser-webpack/dist/v5-size.js", "maxSize": "1.6 kB" } + { "path": "./examples/browser-webpack/dist/v3-size.js", "maxSize": "2.5 kB" }, + { "path": "./examples/browser-webpack/dist/v4-size.js", "maxSize": "1.0 kB" }, + { "path": "./examples/browser-webpack/dist/v5-size.js", "maxSize": "1.9 kB" } ] } diff --git a/examples/benchmark/benchmark.html b/examples/benchmark/benchmark.html index 54be63f8..2f3bf78f 100644 --- a/examples/benchmark/benchmark.html +++ b/examples/benchmark/benchmark.html @@ -5,6 +5,8 @@ + + diff --git a/examples/benchmark/benchmark.js b/examples/benchmark/benchmark.js index dc3ff19f..cc36adcb 100644 --- a/examples/benchmark/benchmark.js +++ b/examples/benchmark/benchmark.js @@ -5,46 +5,96 @@ const uuidv1 = (typeof window !== 'undefined' && window.uuidv1) || require('uuid const uuidv4 = (typeof window !== 'undefined' && window.uuidv4) || require('uuid').v4; const uuidv3 = (typeof window !== 'undefined' && window.uuidv3) || require('uuid').v3; const uuidv5 = (typeof window !== 'undefined' && window.uuidv5) || require('uuid').v5; +const uuidParse = (typeof window !== 'undefined' && window.uuidParse) || require('uuid').parse; +const uuidStringify = + (typeof window !== 'undefined' && window.uuidStringify) || require('uuid').stringify; console.log('Starting. Tests take ~1 minute to run ...'); -const array = new Array(16); - -const suite = new Benchmark.Suite({ - onError(event) { - console.error(event.target.error); - }, -}); - -suite - .add('uuidv1()', function () { - uuidv1(); - }) - .add('uuidv1() fill existing array', function () { - try { - uuidv1(null, array, 0); - } catch (err) { - // The spec (https://tools.ietf.org/html/rfc4122#section-4.2.1.2) defines that only 10M/s v1 - // UUIDs can be generated on a single node. This library throws an error if we hit that limit - // (which can happen on modern hardware and modern Node.js versions). - } - }) - .add('uuidv4()', function () { - uuidv4(); - }) - .add('uuidv4() fill existing array', function () { - uuidv4(null, array, 0); - }) - .add('uuidv3()', function () { - uuidv3('hello.example.com', uuidv3.DNS); - }) - .add('uuidv5()', function () { - uuidv5('hello.example.com', uuidv5.DNS); - }) - .on('cycle', function (event) { - console.log(event.target.toString()); - }) - .on('complete', function () { - console.log('Fastest is ' + this.filter('fastest').map('name')); - }) - .run(); +function testParseAndStringify() { + const suite = new Benchmark.Suite({ + onError(event) { + console.error(event.target.error); + }, + }); + + const BYTES = [ + 0x0f, + 0x5a, + 0xbc, + 0xd1, + 0xc1, + 0x94, + 0x47, + 0xf3, + 0x90, + 0x5b, + 0x2d, + 0xf7, + 0x26, + 0x3a, + 0x08, + 0x4b, + ]; + + suite + .add('uuidStringify()', function () { + uuidStringify(BYTES); + }) + .add('uuidParse()', function () { + uuidParse('0f5abcd1-c194-47f3-905b-2df7263a084b'); + }) + .on('cycle', function (event) { + console.log(event.target.toString()); + }) + .on('complete', function () { + console.log('---\n'); + }) + .run(); +} + +function testGeneration() { + const array = new Array(16); + + const suite = new Benchmark.Suite({ + onError(event) { + console.error(event.target.error); + }, + }); + + suite + .add('uuidv1()', function () { + uuidv1(); + }) + .add('uuidv1() fill existing array', function () { + try { + uuidv1(null, array, 0); + } catch (err) { + // The spec (https://tools.ietf.org/html/rfc4122#section-4.2.1.2) defines that only 10M/s v1 + // UUIDs can be generated on a single node. This library throws an error if we hit that limit + // (which can happen on modern hardware and modern Node.js versions). + } + }) + .add('uuidv4()', function () { + uuidv4(); + }) + .add('uuidv4() fill existing array', function () { + uuidv4(null, array, 0); + }) + .add('uuidv3()', function () { + uuidv3('hello.example.com', uuidv3.DNS); + }) + .add('uuidv5()', function () { + uuidv5('hello.example.com', uuidv5.DNS); + }) + .on('cycle', function (event) { + console.log(event.target.toString()); + }) + .on('complete', function () { + console.log('Fastest is ' + this.filter('fastest').map('name')); + }) + .run(); +} + +testParseAndStringify(); +testGeneration(); diff --git a/package-lock.json b/package-lock.json index 0debcb87..0896f995 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12437,6 +12437,15 @@ "integrity": "sha1-Q2CxfGETatOAeDl/8RQW4Ybc+7g=", "dev": true }, + "random-seed": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/random-seed/-/random-seed-0.3.0.tgz", + "integrity": "sha1-2UXy4fOPSejViRNDG4v2u5N1Vs0=", + "dev": true, + "requires": { + "json-stringify-safe": "^5.0.1" + } + }, "range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", diff --git a/package.json b/package.json index 6dcccba7..cb150307 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "lint-staged": "10.1.3", "npm-run-all": "4.1.5", "prettier": "2.0.4", + "random-seed": "0.3.0", "rollup": "2.6.1", "rollup-plugin-terser": "5.3.0", "runmd": "1.3.2", diff --git a/rollup.config.js b/rollup.config.js index 34544dfd..920d599f 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -20,4 +20,9 @@ export default [ chunk('v3', 'uuidv3'), chunk('v4', 'uuidv4'), chunk('v5', 'uuidv5'), + + chunk('version', 'uuidVersion'), + chunk('validate', 'uuidValidate'), + chunk('parse', 'uuidParse'), + chunk('stringify', 'uuidStringify'), ]; diff --git a/src/bytesToUuid.js b/src/bytesToUuid.js deleted file mode 100644 index 0f57c69f..00000000 --- a/src/bytesToUuid.js +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Convert array of 16 byte values to UUID string format of the form: - * XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX - */ -const byteToHex = []; - -for (let i = 0; i < 256; ++i) { - byteToHex.push((i + 0x100).toString(16).substr(1)); -} - -function bytesToUuid(buf, offset_) { - const offset = offset_ || 0; - - // Note: Be careful editing this code! It's been tuned for performance - // and works in ways you may not expect. See https://github.com/uuidjs/uuid/pull/434 - return ( - byteToHex[buf[offset + 0]] + - byteToHex[buf[offset + 1]] + - byteToHex[buf[offset + 2]] + - byteToHex[buf[offset + 3]] + - '-' + - byteToHex[buf[offset + 4]] + - byteToHex[buf[offset + 5]] + - '-' + - byteToHex[buf[offset + 6]] + - byteToHex[buf[offset + 7]] + - '-' + - byteToHex[buf[offset + 8]] + - byteToHex[buf[offset + 9]] + - '-' + - byteToHex[buf[offset + 10]] + - byteToHex[buf[offset + 11]] + - byteToHex[buf[offset + 12]] + - byteToHex[buf[offset + 13]] + - byteToHex[buf[offset + 14]] + - byteToHex[buf[offset + 15]] - ).toLowerCase(); -} - -export default bytesToUuid; diff --git a/src/index.js b/src/index.js index 7702f92a..9586a544 100644 --- a/src/index.js +++ b/src/index.js @@ -5,3 +5,5 @@ export { default as v5 } from './v5.js'; export { default as REGEX } from './regex.js'; export { default as version } from './version.js'; export { default as validate } from './validate.js'; +export { default as stringify } from './stringify.js'; +export { default as parse } from './parse.js'; diff --git a/src/parse.js b/src/parse.js new file mode 100644 index 00000000..85edd846 --- /dev/null +++ b/src/parse.js @@ -0,0 +1,41 @@ +import validate from './validate.js'; + +function parse(uuid) { + if (!validate(uuid)) { + throw TypeError('Invalid UUID'); + } + + let v; + const arr = new Uint8Array(16); + + // Parse ########-....-....-....-............ + arr[0] = (v = parseInt(uuid.slice(0, 8), 16)) >>> 24; + arr[1] = (v >>> 16) & 0xff; + arr[2] = (v >>> 8) & 0xff; + arr[3] = v & 0xff; + + // Parse ........-####-....-....-............ + arr[4] = (v = parseInt(uuid.slice(9, 13), 16)) >>> 8; + arr[5] = v & 0xff; + + // Parse ........-....-####-....-............ + arr[6] = (v = parseInt(uuid.slice(14, 18), 16)) >>> 8; + arr[7] = v & 0xff; + + // Parse ........-....-....-####-............ + arr[8] = (v = parseInt(uuid.slice(19, 23), 16)) >>> 8; + arr[9] = v & 0xff; + + // Parse ........-....-....-....-############ + // (Use "/" to avoid 32-bit truncation when bit-shifting high-order bytes) + arr[10] = ((v = parseInt(uuid.slice(24, 36), 16)) / 0x10000000000) & 0xff; + arr[11] = (v / 0x100000000) & 0xff; + arr[12] = (v >>> 24) & 0xff; + arr[13] = (v >>> 16) & 0xff; + arr[14] = (v >>> 8) & 0xff; + arr[15] = v & 0xff; + + return arr; +} + +export default parse; diff --git a/src/sha1-browser.js b/src/sha1-browser.js index 2bfa2cb0..377dc24c 100644 --- a/src/sha1-browser.js +++ b/src/sha1-browser.js @@ -29,6 +29,9 @@ function sha1(bytes) { for (let i = 0; i < msg.length; ++i) { bytes.push(msg.charCodeAt(i)); } + } else if (!Array.isArray(bytes)) { + // Convert Array-like to Array + bytes = Array.prototype.slice.call(bytes); } bytes.push(0x80); diff --git a/src/stringify.js b/src/stringify.js new file mode 100644 index 00000000..17e2b0df --- /dev/null +++ b/src/stringify.js @@ -0,0 +1,52 @@ +/** + * Convert array of 16 byte values to UUID string format of the form: + * XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX + */ +const byteToHex = []; + +for (let i = 0; i < 256; ++i) { + byteToHex.push((i + 0x100).toString(16).substr(1)); +} + +function stringify(arr, offset = 0) { + // Note: Be careful editing this code! It's been tuned for performance + // and works in ways you may not expect. See https://github.com/uuidjs/uuid/pull/434 + const uuid = ( + byteToHex[arr[offset + 0]] + + byteToHex[arr[offset + 1]] + + byteToHex[arr[offset + 2]] + + byteToHex[arr[offset + 3]] + + '-' + + byteToHex[arr[offset + 4]] + + byteToHex[arr[offset + 5]] + + '-' + + byteToHex[arr[offset + 6]] + + byteToHex[arr[offset + 7]] + + '-' + + byteToHex[arr[offset + 8]] + + byteToHex[arr[offset + 9]] + + '-' + + byteToHex[arr[offset + 10]] + + byteToHex[arr[offset + 11]] + + byteToHex[arr[offset + 12]] + + byteToHex[arr[offset + 13]] + + byteToHex[arr[offset + 14]] + + byteToHex[arr[offset + 15]] + ).toLowerCase(); + + // Sanity check for valid UUID. This works because if any of + // the input array values don't map to a defined hex octet, the string length + // will get blown out (e.g. "74af23d8-85undefined2c44-...") + // + // This is a somewhat crude check, but avoids having to check each value + // individually. + if (uuid.length !== 36) { + throw new TypeError( + 'Invalid result UUID. Please ensure input is array-like, and contains 16 integer values 0-255', + ); + } + + return uuid; +} + +export default stringify; diff --git a/src/v1.js b/src/v1.js index dbf4f5ca..0643675e 100644 --- a/src/v1.js +++ b/src/v1.js @@ -1,5 +1,5 @@ import rng from './rng.js'; -import bytesToUuid from './bytesToUuid.js'; +import stringify from './stringify.js'; // **`v1()` - Generate time-based UUID** // @@ -109,7 +109,7 @@ function v1(options, buf, offset) { b[i + n] = node[n]; } - return buf || bytesToUuid(b); + return buf || stringify(b); } export default v1; diff --git a/src/v35.js b/src/v35.js index dc7775fe..e8706ff0 100644 --- a/src/v35.js +++ b/src/v35.js @@ -1,30 +1,5 @@ -import bytesToUuid from './bytesToUuid.js'; -import validate from './validate.js'; - -// Int32 to 4 bytes https://stackoverflow.com/a/12965194/3684944 -function numberToBytes(num, bytes, offset) { - for (let i = 0; i < 4; ++i) { - const byte = num & 0xff; - // Fill the 4 bytes right-to-left. - bytes[offset + 3 - i] = byte; - num = (num - byte) / 256; - } -} - -function uuidToBytes(uuid) { - if (!validate(uuid)) { - return []; - } - - const bytes = new Array(16); - - numberToBytes(parseInt(uuid.slice(0, 8), 16), bytes, 0); - numberToBytes(parseInt(uuid.slice(9, 13) + uuid.slice(14, 18), 16), bytes, 4); - numberToBytes(parseInt(uuid.slice(19, 23) + uuid.slice(24, 28), 16), bytes, 8); - numberToBytes(parseInt(uuid.slice(28), 16), bytes, 12); - - return bytes; -} +import stringify from './stringify.js'; +import parse from './parse.js'; function stringToBytes(str) { str = unescape(encodeURIComponent(str)); // UTF8 escape @@ -48,19 +23,21 @@ export default function (name, version, hashfunc) { } if (typeof namespace === 'string') { - namespace = uuidToBytes(namespace); + namespace = parse(namespace); } - if (!Array.isArray(value)) { - throw TypeError('value must be an array of bytes'); + if (namespace.length !== 16) { + throw TypeError('Namespace must be array-like (16 iterable integer values, 0-255)'); } - if (!Array.isArray(namespace) || namespace.length !== 16) { - throw TypeError('namespace must be uuid string or an Array of 16 byte values'); - } + // Compute hash of namespace and value, Per 4.3 + // Future: Use spread syntax when supported on all platforms, e.g. `bytes = + // hashfunc([...namespace, ... value])` + let bytes = new Uint8Array(16 + value.length); + bytes.set(namespace); + bytes.set(value, namespace.length); + bytes = hashfunc(bytes); - // Per 4.3 - const bytes = hashfunc(namespace.concat(value)); bytes[6] = (bytes[6] & 0x0f) | version; bytes[8] = (bytes[8] & 0x3f) | 0x80; @@ -74,7 +51,7 @@ export default function (name, version, hashfunc) { return buf; } - return bytesToUuid(bytes); + return stringify(bytes); } // Function#name is not settable on some platforms (#270) diff --git a/src/v4.js b/src/v4.js index 16765828..520613a4 100644 --- a/src/v4.js +++ b/src/v4.js @@ -1,5 +1,5 @@ import rng from './rng.js'; -import bytesToUuid from './bytesToUuid.js'; +import stringify from './stringify.js'; function v4(options, buf, offset) { options = options || {}; @@ -21,7 +21,7 @@ function v4(options, buf, offset) { return buf; } - return bytesToUuid(rnds); + return stringify(rnds); } export default v4; diff --git a/test/unit/parse.test.js b/test/unit/parse.test.js new file mode 100644 index 00000000..7137d953 --- /dev/null +++ b/test/unit/parse.test.js @@ -0,0 +1,68 @@ +import assert from 'assert'; +import uuidv4 from '../../src/v4.js'; +import parse from '../../src/parse.js'; +import stringify from '../../src/stringify.js'; +import gen from 'random-seed'; + +// Use deterministic PRNG for reproducable tests +const rand = gen.create('He who wonders discovers that this in itself is wonder.'); +function rng(bytes = []) { + for (let i = 0; i < 16; i++) { + bytes[i] = rand(256); + } + return bytes; +} + +describe('parse', () => { + test('String -> bytes parsing', () => { + assert.deepStrictEqual( + parse('0f5abcd1-c194-47f3-905b-2df7263a084b'), + Uint8Array.from([ + 0x0f, + 0x5a, + 0xbc, + 0xd1, + 0xc1, + 0x94, + 0x47, + 0xf3, + 0x90, + 0x5b, + 0x2d, + 0xf7, + 0x26, + 0x3a, + 0x08, + 0x4b, + ]), + ); + }); + + test('String -> bytes -> string symmetry for assorted uuids', () => { + for (let i = 0; i < 1000; i++) { + const uuid = uuidv4({ rng }); + assert.equal(stringify(parse(uuid)), uuid); + } + }); + + test('Case neutrality', () => { + // Verify upper/lower case neutrality + assert.deepStrictEqual( + parse('0f5abcd1-c194-47f3-905b-2df7263a084b'), + parse('0f5abcd1-c194-47f3-905b-2df7263a084b'.toUpperCase()), + ); + }); + + test('Null UUID case', () => { + assert.deepStrictEqual( + parse('00000000-0000-0000-0000-000000000000'), + Uint8Array.from(new Array(16).fill(0)), + ); + }); + + test('UUID validation', () => { + assert.throws(() => parse()); + assert.throws(() => parse('invalid uuid')); + assert.throws(() => parse('zyxwvuts-rqpo-nmlk-jihg-fedcba000000')); + }); +}); diff --git a/test/unit/stringify.test.js b/test/unit/stringify.test.js new file mode 100644 index 00000000..94de77d1 --- /dev/null +++ b/test/unit/stringify.test.js @@ -0,0 +1,55 @@ +import assert from 'assert'; +import stringify from '../../src/stringify.js'; + +const BYTES = [ + 0x0f, + 0x5a, + 0xbc, + 0xd1, + 0xc1, + 0x94, + 0x47, + 0xf3, + 0x90, + 0x5b, + 0x2d, + 0xf7, + 0x26, + 0x3a, + 0x08, + 0x4b, +]; + +describe('stringify', () => { + test('Stringify Array', () => { + assert.equal(stringify(BYTES), '0f5abcd1-c194-47f3-905b-2df7263a084b'); + }); + + test('Stringify TypedArray', () => { + assert.equal(stringify(Uint8Array.from(BYTES)), '0f5abcd1-c194-47f3-905b-2df7263a084b'); + assert.equal(stringify(Int32Array.from(BYTES)), '0f5abcd1-c194-47f3-905b-2df7263a084b'); + }); + + test('Stringify w/ offset', () => { + assert.equal(stringify([0, 0, 0, ...BYTES], 3), '0f5abcd1-c194-47f3-905b-2df7263a084b'); + }); + + test('Throws on not enough values', () => { + const bytes = [...BYTES]; + bytes.length = 15; + assert.throws(() => stringify(bytes)); + }); + + test('Throws on undefined value', () => { + const bytes = [...BYTES]; + delete bytes[3]; + bytes.length = 15; + assert.throws(() => stringify(bytes)); + }); + + test('Throws on invalid value', () => { + const bytes = [...BYTES]; + bytes[3] = 256; + assert.throws(() => stringify(bytes)); + }); +}); diff --git a/wdio.conf.js b/wdio.conf.js index 4be214bf..a7cea646 100644 --- a/wdio.conf.js +++ b/wdio.conf.js @@ -12,6 +12,7 @@ const commonCapabilities = { name: 'browser test', 'browserstack.local': true, 'browserstack.debug': false, + 'browserstack.console': 'errors', resolution: '1024x768', };