├── test
├── data
│ ├── hud.png
│ ├── hud2.png
│ ├── hud.json
│ └── hud2.json
├── fixtures
│ ├── data.js
│ └── spritesheetMiddleware.js
├── karma.conf.js
└── spec
│ ├── Resource.test.js
│ ├── Loader.test.js
│ └── async.test.js
├── src
├── load_strategies
│ ├── AudioLoadStrategy.ts
│ ├── VideoLoadStrategy.ts
│ ├── AbstractLoadStrategy.ts
│ ├── ImageLoadStrategy.ts
│ ├── MediaElementLoadStrategy.ts
│ └── XhrLoadStrategy.ts
├── index.ts
├── resource_type.ts
├── async
│ ├── eachSeries.ts
│ └── AsyncQueue.ts
├── utilities.ts
├── bundle.ts
├── Resource.ts
└── Loader.ts
├── .editorconfig
├── .gitignore
├── .travis.yml
├── types
└── parse-uri.d.ts
├── tsconfig.json
├── LICENSE
├── package.json
├── rollup.config.js
├── CONTRIBUTING.md
└── README.md
/test/data/hud.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/englercj/resource-loader/master/test/data/hud.png
--------------------------------------------------------------------------------
/test/data/hud2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/englercj/resource-loader/master/test/data/hud2.png
--------------------------------------------------------------------------------
/src/load_strategies/AudioLoadStrategy.ts:
--------------------------------------------------------------------------------
1 | import { MediaElementLoadStrategy, IMediaElementLoadConfig } from './MediaElementLoadStrategy';
2 |
3 | export class AudioLoadStrategy extends MediaElementLoadStrategy
4 | {
5 | constructor(config: IMediaElementLoadConfig)
6 | {
7 | super(config, 'audio');
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/src/load_strategies/VideoLoadStrategy.ts:
--------------------------------------------------------------------------------
1 | import { MediaElementLoadStrategy, IMediaElementLoadConfig } from './MediaElementLoadStrategy';
2 |
3 | export class VideoLoadStrategy extends MediaElementLoadStrategy
4 | {
5 | constructor(config: IMediaElementLoadConfig)
6 | {
7 | super(config, 'video');
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # This file is for unifying the coding style for different editors and IDEs.
2 | # More information at http://EditorConfig.org
3 | root = true
4 |
5 | [*]
6 | end_of_line = lf
7 | insert_final_newline = true
8 | trim_trailing_whitespace = true
9 | indent_style = space
10 | indent_size = 4
11 |
12 | [{package.json,bower.json,*.yml}]
13 | indent_size = 2
14 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # sublime text files
2 | *.sublime*
3 | *.*~*.TMP
4 |
5 | # temp files
6 | .DS_Store
7 | Thumbs.db
8 | Desktop.ini
9 | npm-debug.log
10 |
11 | # project files
12 | .project
13 | .idea
14 |
15 | # vim swap files
16 | *.sw*
17 |
18 | # emacs temp files
19 | *~
20 | \#*#
21 |
22 | # project ignores
23 | !.gitkeep
24 | *__temp
25 | *.sqlite
26 | .snyk
27 | .commit
28 | entry-*.js
29 | node_modules/
30 | dist/
31 | lib/
32 | docs/
33 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | sudo: false
2 |
3 | addons:
4 | firefox: "latest"
5 |
6 | language: node_js
7 |
8 | node_js:
9 | - '10'
10 | - '12'
11 |
12 | branches:
13 | only:
14 | - master
15 |
16 | cache:
17 | directories:
18 | - node_modules
19 |
20 | install:
21 | - npm install
22 |
23 | before_script:
24 | - export DISPLAY=':99.0'
25 | - Xvfb :99 -screen 0 1024x768x24 -extension RANDR &
26 |
27 | script:
28 | - npm test
29 |
--------------------------------------------------------------------------------
/test/fixtures/data.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | window.fixtureData = {
4 | url: 'http://localhost/file',
5 | baseUrl: '/base/test/data',
6 | dataUrlGif: 'data:image/gif;base64,R0lGODlhAQABAPAAAP8REf///yH5BAAAAAAALAAAAAABAAEAAAICRAEAOw==',
7 | dataUrlSvg: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPSczMCcgaGVpZ2h0PSczMCc+PGNpcmNsZSBjeD0nMTUnIGN5PScxNScgcj0nMTAnIC8+PC9zdmc+', // eslint-disable-line max-len
8 | dataJson: '[{ "id": 12, "comment": "Hey there" }]',
9 | dataJsonHeaders: { 'Content-Type': 'application/json' },
10 | };
11 |
--------------------------------------------------------------------------------
/types/parse-uri.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'parse-uri'
2 | {
3 | interface ParsedUri
4 | {
5 | source?: string;
6 | protocol?: string;
7 | authority?: string;
8 | userInfo?: string;
9 | user?: string;
10 | password?: string;
11 | host?: string;
12 | port?: string;
13 | relative?: string;
14 | path?: string;
15 | directory?: string;
16 | file?: string;
17 | query?: string;
18 | anchor?: string;
19 | }
20 |
21 | interface Options
22 | {
23 | strictMode?: boolean;
24 | }
25 |
26 | function parseUri(uri: string, options?: Options): ParsedUri;
27 | export default parseUri;
28 | }
29 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export { AbstractLoadStrategy, ILoadConfig } from './load_strategies/AbstractLoadStrategy';
2 | export { AudioLoadStrategy } from './load_strategies/AudioLoadStrategy';
3 | export { ImageLoadStrategy, IImageLoadConfig } from './load_strategies/ImageLoadStrategy';
4 | export { MediaElementLoadStrategy, IMediaElementLoadConfig } from './load_strategies/MediaElementLoadStrategy';
5 | export { VideoLoadStrategy } from './load_strategies/VideoLoadStrategy';
6 | export { XhrLoadStrategy , XhrResponseType, IXhrLoadConfig } from './load_strategies/XhrLoadStrategy';
7 |
8 | export { Loader, IAddOptions } from './Loader';
9 | export { Resource, IResourceOptions } from './Resource';
10 | export { ResourceType, ResourceState } from './resource_type';
11 |
--------------------------------------------------------------------------------
/src/resource_type.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Describes the type of data the Resource holds.
3 | */
4 | export enum ResourceType
5 | {
6 | /** The resource data type is unknown. */
7 | Unknown,
8 | /** The resource data is an ArrayBuffer. */
9 | Buffer,
10 | /** The resource data is a Blob. */
11 | Blob,
12 | /** The resource data is a parsed JSON Object. */
13 | Json,
14 | /** The resource data is a Document or
element representing parsed XML. */
15 | Xml,
16 | /** The resource data is an
element. */
17 | Image,
18 | /** The resource data is an element. */
19 | Audio,
20 | /** The resource data is an element. */
21 | Video,
22 | /** The resource data is a string. */
23 | Text,
24 | }
25 |
26 | export enum ResourceState
27 | {
28 | NotStarted,
29 | Loading,
30 | Complete,
31 | }
32 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "outDir": "./dist",
4 | "module": "es2015",
5 | "target": "es5",
6 | "moduleResolution": "node",
7 | "declaration": true,
8 | "removeComments": true,
9 | "sourceMap": true,
10 | "strict": true
11 | },
12 | "typedocOptions": {
13 | "mode": "file",
14 | "out": "docs",
15 | "excludeExternals": true,
16 | "excludeNotExported": true,
17 | "excludePrivate": true,
18 | "toc": [
19 | "AbstractLoadStrategy",
20 | "AudioLoadStrategy",
21 | "ImageLoadStrategy",
22 | "MediaElementLoadStrategy",
23 | "VideoLoadStrategy",
24 | "XhrLoadStrategy",
25 | "Loader",
26 | "Resource",
27 | "ResourceType",
28 | "ResourceState"
29 | ]
30 | },
31 | "include": [
32 | "src/**/*",
33 | "types/**/*"
34 | ]
35 | }
36 |
--------------------------------------------------------------------------------
/src/async/eachSeries.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Iterates an array in series.
3 | *
4 | * @typeparam T Element type of the array.
5 | * @param array Array to iterate.
6 | * @param iterator Function to call for each element.
7 | * @param callback Function to call when done, or on error.
8 | * @param deferNext Break synchronous each loop by calling next with a setTimeout of 1.
9 | */
10 | export function eachSeries(
11 | array: T[],
12 | iterator: (item: T, next: (err?: Error) => void) => void,
13 | callback?: (err?: Error) => void,
14 | deferNext = false) : void
15 | {
16 | let i = 0;
17 | const len = array.length;
18 |
19 | (function next(err?: Error)
20 | {
21 | if (err || i === len)
22 | {
23 | if (callback)
24 | callback(err);
25 | return;
26 | }
27 |
28 | if (deferNext)
29 | setTimeout(() => iterator(array[i++], next), 1);
30 | else
31 | iterator(array[i++], next);
32 | })();
33 | }
34 |
--------------------------------------------------------------------------------
/src/utilities.ts:
--------------------------------------------------------------------------------
1 | export type Overwrite = {
2 | [P in Exclude]: T1[P]
3 | } & T2;
4 |
5 | /**
6 | * Extracts the extension (sans '.') of the file being loaded by the resource.
7 | */
8 | export function getExtension(url: string)
9 | {
10 | const isDataUrl = url.indexOf('data:') === 0;
11 | let ext = '';
12 |
13 | if (isDataUrl)
14 | {
15 | const slashIndex = url.indexOf('/');
16 |
17 | ext = url.substring(slashIndex + 1, url.indexOf(';', slashIndex));
18 | }
19 | else
20 | {
21 | const queryStart = url.indexOf('?');
22 | const hashStart = url.indexOf('#');
23 | const index = Math.min(
24 | queryStart > -1 ? queryStart : url.length,
25 | hashStart > -1 ? hashStart : url.length
26 | );
27 |
28 | url = url.substring(0, index);
29 | ext = url.substring(url.lastIndexOf('.') + 1);
30 | }
31 |
32 | return ext.toLowerCase();
33 | }
34 |
35 | export function assertNever(x: never): never
36 | {
37 | throw new Error('Unexpected value. Should have been never.');
38 | }
39 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License
2 |
3 | Copyright (c) 2015-2019 Chad Engler
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in
13 | all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/test/karma.conf.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | module.exports = function conf(config) {
4 | config.set({
5 | basePath: '../',
6 | frameworks: ['mocha', 'sinon-chai'],
7 | autoWatch: true,
8 | logLevel: config.LOG_INFO,
9 | logColors: true,
10 | reporters: ['mocha'],
11 | browsers: ['Chrome'],
12 | browserDisconnectTimeout: 10000,
13 | browserDisconnectTolerance: 2,
14 | browserNoActivityTimeout: 30000,
15 |
16 | files: [
17 | // our code
18 | 'dist/resource-loader.js',
19 |
20 | // fixtures
21 | {
22 | pattern: 'test/fixtures/**/*.js',
23 | watched: false,
24 | included: true,
25 | served: true,
26 | },
27 |
28 | {
29 | pattern: 'test/data/**/*',
30 | watched: false,
31 | included: false,
32 | served: true,
33 | },
34 |
35 | // tests
36 | {
37 | pattern: 'test/spec/**/*.test.js',
38 | watched: true,
39 | included: true,
40 | served: true,
41 | },
42 | ],
43 |
44 | plugins: [
45 | 'karma-mocha',
46 | 'karma-sinon-chai',
47 | 'karma-mocha-reporter',
48 | 'karma-chrome-launcher',
49 | 'karma-firefox-launcher',
50 | ],
51 | });
52 |
53 | if (process.env.TRAVIS)
54 | {
55 | config.logLevel = config.LOG_DEBUG;
56 | config.browsers = ['Firefox'];
57 | }
58 | };
59 |
--------------------------------------------------------------------------------
/src/bundle.ts:
--------------------------------------------------------------------------------
1 | import { AbstractLoadStrategy } from './load_strategies/AbstractLoadStrategy';
2 | import { AudioLoadStrategy } from './load_strategies/AudioLoadStrategy';
3 | import { ImageLoadStrategy } from './load_strategies/ImageLoadStrategy';
4 | import { MediaElementLoadStrategy } from './load_strategies/MediaElementLoadStrategy';
5 | import { VideoLoadStrategy } from './load_strategies/VideoLoadStrategy';
6 | import { XhrLoadStrategy } from './load_strategies/XhrLoadStrategy';
7 |
8 | import { Loader } from './Loader';
9 | import { Resource } from './Resource';
10 | import { ResourceType, ResourceState } from './resource_type';
11 |
12 | // TODO: Hide this stuff and only expose for tests
13 | import { AsyncQueue } from './async/AsyncQueue';
14 | import { eachSeries } from './async/eachSeries';
15 | import { getExtension } from './utilities';
16 |
17 | Object.defineProperties(Loader, {
18 | AbstractLoadStrategy: { get() { return AbstractLoadStrategy; } },
19 | AudioLoadStrategy: { get() { return AudioLoadStrategy; } },
20 | ImageLoadStrategy: { get() { return ImageLoadStrategy; } },
21 | MediaElementLoadStrategy: { get() { return MediaElementLoadStrategy; } },
22 | VideoLoadStrategy: { get() { return VideoLoadStrategy; } },
23 | XhrLoadStrategy: { get() { return XhrLoadStrategy; } },
24 |
25 | Resource: { get() { return Resource; } },
26 | ResourceType: { get() { return ResourceType; } },
27 | ResourceState: { get() { return ResourceState; } },
28 |
29 | // TODO: Hide this stuff and only expose for tests
30 | async: { get() { return { AsyncQueue, eachSeries }; } },
31 | getExtension: { get() { return getExtension; } },
32 | });
33 |
34 | export default Loader;
35 |
--------------------------------------------------------------------------------
/test/fixtures/spritesheetMiddleware.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | (() => {
4 | window.spritesheetMiddleware = function spritesheetMiddlewareFactory() {
5 | return function spritesheetMiddleware(resource, next) {
6 | // skip if no data, its not json, or it isn't spritesheet data
7 | if (!resource.data || resource.type !== Loader.ResourceType.Json || !resource.data.frames) {
8 | next();
9 |
10 | return;
11 | }
12 |
13 | const route = dirname(resource.url.replace(this.baseUrl, ''));
14 |
15 | const loadOptions = {
16 | name: `${resource.name}_image`,
17 | url: `${route}/${resource.data.meta.image}`,
18 | crossOrigin: resource.crossOrigin,
19 | strategy: Loader.ImageLoadStrategy,
20 | parentResource: resource,
21 | onComplete: (/* res */) => next(),
22 | };
23 |
24 | // load the image for this sheet
25 | this.add(loadOptions);
26 | };
27 | };
28 |
29 | function dirname(path) {
30 | const result = posixSplitPath(path);
31 | const root = result[0];
32 | let dir = result[1];
33 |
34 | if (!root && !dir) {
35 | // No dirname whatsoever
36 | return '.';
37 | }
38 |
39 | if (dir) {
40 | // It has a dirname, strip trailing slash
41 | dir = dir.substr(0, dir.length - 1);
42 | }
43 |
44 | return root + dir;
45 | }
46 |
47 | const splitPathRe = /^(\/?|)([\s\S]*?)((?:\.{1,2}|[^/]+?|)(\.[^./]*|))(?:[/]*)$/;
48 |
49 | function posixSplitPath(filename) {
50 | return splitPathRe.exec(filename).slice(1);
51 | }
52 | })();
53 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "resource-loader",
3 | "version": "4.0.0-rc4",
4 | "main": "./dist/resource-loader.cjs.js",
5 | "module": "./dist/resource-loader.esm.js",
6 | "bundle": "./dist/resource-loader.js",
7 | "types": "./dist/index.d.ts",
8 | "description": "A generic asset loader, made with web games in mind.",
9 | "author": "Chad Engler ",
10 | "license": "MIT",
11 | "homepage": "https://github.com/englercj/resource-loader",
12 | "repository": {
13 | "type": "git",
14 | "url": "https://github.com/englercj/resource-loader.git"
15 | },
16 | "bugs": {
17 | "url": "https://github.com/englercj/resource-loader/issues"
18 | },
19 | "keywords": [],
20 | "files": [
21 | "dist",
22 | "typings",
23 | "package.json",
24 | "CONTRIBUTING.md",
25 | "LICENSE",
26 | "README.md"
27 | ],
28 | "scripts": {
29 | "clean": "rimraf ./dist",
30 | "prebuild": "npm run clean",
31 | "build": "rollup -c",
32 | "watch": "rollup -cw",
33 | "start": "npm run build",
34 | "test": "npm run test-dev -- --single-run",
35 | "pretest-dev": "npm run build",
36 | "test-dev": "karma start test/karma.conf.js",
37 | "docs": "typedoc",
38 | "prepublishOnly": "npm run build",
39 | "predeploy": "rimraf ./docs && npm run docs",
40 | "deploy": "gh-pages -d docs",
41 | "postpublish": "npm run deploy"
42 | },
43 | "dependencies": {
44 | "parse-uri": "^1.0.0",
45 | "type-signals": "^1.0.3"
46 | },
47 | "devDependencies": {
48 | "@rollup/plugin-commonjs": "^11.0.2",
49 | "@rollup/plugin-node-resolve": "^7.1.1",
50 | "chai": "^4.2.0",
51 | "gh-pages": "^2.2.0",
52 | "karma": "^4.4.1",
53 | "karma-chrome-launcher": "^3.1.0",
54 | "karma-firefox-launcher": "^1.3.0",
55 | "karma-mocha": "^1.3.0",
56 | "karma-mocha-reporter": "^2.2.5",
57 | "karma-sinon-chai": "^2.0.2",
58 | "mkdirp": "^1.0.3",
59 | "mocha": "^7.1.0",
60 | "npm-run-all": "^4.1.5",
61 | "rimraf": "^3.0.2",
62 | "rollup": "^1.32.1",
63 | "rollup-plugin-terser": "^5.2.0",
64 | "rollup-plugin-typescript2": "^0.26.0",
65 | "sinon": "^9.0.0",
66 | "sinon-chai": "^3.5.0",
67 | "tslib": "^1.11.1",
68 | "typedoc": "^0.16.11",
69 | "typescript": "^3.8.3"
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
1 | import typescript from 'rollup-plugin-typescript2';
2 | import resolve from '@rollup/plugin-node-resolve';
3 | import commonjs from '@rollup/plugin-commonjs';
4 | import { terser } from 'rollup-plugin-terser';
5 | import pkg from './package.json';
6 |
7 | const plugins = [
8 | typescript(),
9 | resolve(),
10 | commonjs(),
11 | ];
12 | const sourcemap = true;
13 | const freeze = false;
14 | const input = 'src/index.ts';
15 | const bundleInput = 'src/bundle.ts';
16 | const external = Object.keys(pkg.dependencies);
17 | const compiled = (new Date()).toUTCString().replace(/GMT/g, "UTC");
18 |
19 | const banner = `/*!
20 | * ${pkg.name} - v${pkg.version}
21 | * ${pkg.homepage}
22 | * Compiled ${compiled}
23 | *
24 | * ${pkg.name} is licensed under the MIT license.
25 | * http://www.opensource.org/licenses/mit-license
26 | */`;
27 |
28 | export default [
29 | {
30 | input,
31 | plugins,
32 | external,
33 | output: [
34 | {
35 | banner,
36 | file: 'dist/resource-loader.cjs.js',
37 | format: 'cjs',
38 | freeze,
39 | sourcemap,
40 | },
41 | {
42 | banner,
43 | file: 'dist/resource-loader.esm.js',
44 | format: 'esm',
45 | freeze,
46 | sourcemap,
47 | }
48 | ]
49 | },
50 | {
51 | input: bundleInput,
52 | plugins,
53 | output: {
54 | banner,
55 | name: 'Loader',
56 | file: 'dist/resource-loader.js',
57 | format: 'iife',
58 | freeze,
59 | sourcemap,
60 | }
61 | },
62 | {
63 | input: bundleInput,
64 | plugins: [].concat(plugins, terser({
65 | output: {
66 | comments(node, comment) {
67 | return comment.line === 1;
68 | },
69 | },
70 | compress: {
71 | drop_console: true,
72 | },
73 | })),
74 | output: {
75 | banner,
76 | name: 'Loader',
77 | file: 'dist/resource-loader.min.js',
78 | format: 'iife',
79 | freeze,
80 | sourcemap,
81 | }
82 | }
83 | ];
84 |
--------------------------------------------------------------------------------
/test/data/hud.json:
--------------------------------------------------------------------------------
1 | {"frames": {
2 |
3 | "0.png":
4 | {
5 | "frame": {"x":14,"y":28,"w":14,"h":14},
6 | "rotated": false,
7 | "trimmed": false,
8 | "spriteSourceSize": {"x":0,"y":0,"w":14,"h":14},
9 | "sourceSize": {"w":14,"h":14}
10 | },
11 | "1.png":
12 | {
13 | "frame": {"x":14,"y":42,"w":12,"h":14},
14 | "rotated": false,
15 | "trimmed": false,
16 | "spriteSourceSize": {"x":0,"y":0,"w":12,"h":14},
17 | "sourceSize": {"w":12,"h":14}
18 | },
19 | "2.png":
20 | {
21 | "frame": {"x":14,"y":14,"w":14,"h":14},
22 | "rotated": false,
23 | "trimmed": false,
24 | "spriteSourceSize": {"x":0,"y":0,"w":14,"h":14},
25 | "sourceSize": {"w":14,"h":14}
26 | },
27 | "3.png":
28 | {
29 | "frame": {"x":42,"y":0,"w":14,"h":14},
30 | "rotated": false,
31 | "trimmed": false,
32 | "spriteSourceSize": {"x":0,"y":0,"w":14,"h":14},
33 | "sourceSize": {"w":14,"h":14}
34 | },
35 | "4.png":
36 | {
37 | "frame": {"x":28,"y":0,"w":14,"h":14},
38 | "rotated": false,
39 | "trimmed": false,
40 | "spriteSourceSize": {"x":0,"y":0,"w":14,"h":14},
41 | "sourceSize": {"w":14,"h":14}
42 | },
43 | "5.png":
44 | {
45 | "frame": {"x":14,"y":0,"w":14,"h":14},
46 | "rotated": false,
47 | "trimmed": false,
48 | "spriteSourceSize": {"x":0,"y":0,"w":14,"h":14},
49 | "sourceSize": {"w":14,"h":14}
50 | },
51 | "6.png":
52 | {
53 | "frame": {"x":0,"y":42,"w":14,"h":14},
54 | "rotated": false,
55 | "trimmed": false,
56 | "spriteSourceSize": {"x":0,"y":0,"w":14,"h":14},
57 | "sourceSize": {"w":14,"h":14}
58 | },
59 | "7.png":
60 | {
61 | "frame": {"x":0,"y":28,"w":14,"h":14},
62 | "rotated": false,
63 | "trimmed": false,
64 | "spriteSourceSize": {"x":0,"y":0,"w":14,"h":14},
65 | "sourceSize": {"w":14,"h":14}
66 | },
67 | "8.png":
68 | {
69 | "frame": {"x":0,"y":14,"w":14,"h":14},
70 | "rotated": false,
71 | "trimmed": false,
72 | "spriteSourceSize": {"x":0,"y":0,"w":14,"h":14},
73 | "sourceSize": {"w":14,"h":14}
74 | },
75 | "9.png":
76 | {
77 | "frame": {"x":0,"y":0,"w":14,"h":14},
78 | "rotated": false,
79 | "trimmed": false,
80 | "spriteSourceSize": {"x":0,"y":0,"w":14,"h":14},
81 | "sourceSize": {"w":14,"h":14}
82 | }},
83 | "meta": {
84 | "app": "http://www.texturepacker.com",
85 | "version": "1.0",
86 | "image": "hud.png",
87 | "format": "RGBA8888",
88 | "size": {"w":64,"h":64},
89 | "scale": "1",
90 | "smartupdate": "$TexturePacker:SmartUpdate:47025c98c8b10634b75172d4ed7e7edc$"
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/test/data/hud2.json:
--------------------------------------------------------------------------------
1 | {"frames": {
2 |
3 | "0.png":
4 | {
5 | "frame": {"x":14,"y":28,"w":14,"h":14},
6 | "rotated": false,
7 | "trimmed": false,
8 | "spriteSourceSize": {"x":0,"y":0,"w":14,"h":14},
9 | "sourceSize": {"w":14,"h":14}
10 | },
11 | "1.png":
12 | {
13 | "frame": {"x":14,"y":42,"w":12,"h":14},
14 | "rotated": false,
15 | "trimmed": false,
16 | "spriteSourceSize": {"x":0,"y":0,"w":12,"h":14},
17 | "sourceSize": {"w":12,"h":14}
18 | },
19 | "2.png":
20 | {
21 | "frame": {"x":14,"y":14,"w":14,"h":14},
22 | "rotated": false,
23 | "trimmed": false,
24 | "spriteSourceSize": {"x":0,"y":0,"w":14,"h":14},
25 | "sourceSize": {"w":14,"h":14}
26 | },
27 | "3.png":
28 | {
29 | "frame": {"x":42,"y":0,"w":14,"h":14},
30 | "rotated": false,
31 | "trimmed": false,
32 | "spriteSourceSize": {"x":0,"y":0,"w":14,"h":14},
33 | "sourceSize": {"w":14,"h":14}
34 | },
35 | "4.png":
36 | {
37 | "frame": {"x":28,"y":0,"w":14,"h":14},
38 | "rotated": false,
39 | "trimmed": false,
40 | "spriteSourceSize": {"x":0,"y":0,"w":14,"h":14},
41 | "sourceSize": {"w":14,"h":14}
42 | },
43 | "5.png":
44 | {
45 | "frame": {"x":14,"y":0,"w":14,"h":14},
46 | "rotated": false,
47 | "trimmed": false,
48 | "spriteSourceSize": {"x":0,"y":0,"w":14,"h":14},
49 | "sourceSize": {"w":14,"h":14}
50 | },
51 | "6.png":
52 | {
53 | "frame": {"x":0,"y":42,"w":14,"h":14},
54 | "rotated": false,
55 | "trimmed": false,
56 | "spriteSourceSize": {"x":0,"y":0,"w":14,"h":14},
57 | "sourceSize": {"w":14,"h":14}
58 | },
59 | "7.png":
60 | {
61 | "frame": {"x":0,"y":28,"w":14,"h":14},
62 | "rotated": false,
63 | "trimmed": false,
64 | "spriteSourceSize": {"x":0,"y":0,"w":14,"h":14},
65 | "sourceSize": {"w":14,"h":14}
66 | },
67 | "8.png":
68 | {
69 | "frame": {"x":0,"y":14,"w":14,"h":14},
70 | "rotated": false,
71 | "trimmed": false,
72 | "spriteSourceSize": {"x":0,"y":0,"w":14,"h":14},
73 | "sourceSize": {"w":14,"h":14}
74 | },
75 | "9.png":
76 | {
77 | "frame": {"x":0,"y":0,"w":14,"h":14},
78 | "rotated": false,
79 | "trimmed": false,
80 | "spriteSourceSize": {"x":0,"y":0,"w":14,"h":14},
81 | "sourceSize": {"w":14,"h":14}
82 | }},
83 | "meta": {
84 | "app": "http://www.texturepacker.com",
85 | "version": "1.0",
86 | "image": "hud2.png",
87 | "format": "RGBA8888",
88 | "size": {"w":64,"h":64},
89 | "scale": "1",
90 | "smartupdate": "$TexturePacker:SmartUpdate:47025c98c8b10634b75172d4ed7e7edc$"
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/src/load_strategies/AbstractLoadStrategy.ts:
--------------------------------------------------------------------------------
1 | import { Signal } from 'type-signals';
2 | import { ResourceType } from '../resource_type';
3 |
4 | export interface ILoadConfig
5 | {
6 | // The url for this resource, relative to the baseUrl of this loader.
7 | url: string;
8 |
9 | // A base url to use for just this resource load. This can be passed in
10 | // as the base url for a subresource if desired.
11 | baseUrl?: string;
12 |
13 | // String to use for crossOrigin properties on load elements.
14 | crossOrigin?: string;
15 |
16 | // The time to wait in milliseconds before considering the load a failure.
17 | timeout?: number;
18 | }
19 |
20 | /**
21 | * @category Type Aliases
22 | */
23 | export namespace AbstractLoadStrategy
24 | {
25 | export type OnErrorSignal = (errMessage: string) => void;
26 | export type OnCompleteSignal = (type: ResourceType, data: any) => void;
27 | export type OnProgressSignal = (percent: number) => void;
28 | }
29 |
30 | /**
31 | * Base load strategy interface that all custom load strategies
32 | * are expected to inherit from and implement.
33 | * @preferred
34 | */
35 | export abstract class AbstractLoadStrategy
36 | {
37 | /**
38 | * Dispatched when the resource fails to load.
39 | */
40 | readonly onError: Signal = new Signal();
41 |
42 | /**
43 | * Dispatched once this resource has loaded, if there was an error it will
44 | * be in the `error` property.
45 | */
46 | readonly onComplete: Signal = new Signal();
47 |
48 | /**
49 | * Dispatched each time progress of this resource load updates.
50 | * Not all resources types and loader systems can support this event
51 | * so sometimes it may not be available. If the resource
52 | * is being loaded on a modern browser, using XHR, and the remote server
53 | * properly sets Content-Length headers, then this will be available.
54 | */
55 | readonly onProgress: Signal = new Signal();
56 |
57 | constructor(readonly config: C)
58 | { }
59 |
60 | /**
61 | * Load the resource described by `config`.
62 | */
63 | abstract load(): void;
64 |
65 | /**
66 | * Abort the loading of the resource.
67 | */
68 | abstract abort(): void;
69 | }
70 |
71 | export type AbstractLoadStrategyCtor =
72 | new (config: C) => AbstractLoadStrategy;
73 |
--------------------------------------------------------------------------------
/src/load_strategies/ImageLoadStrategy.ts:
--------------------------------------------------------------------------------
1 | import { AbstractLoadStrategy, ILoadConfig } from './AbstractLoadStrategy';
2 | import { ResourceType } from '../resource_type';
3 |
4 | // We can't set the `src` attribute to empty string, so on abort we set it to this 1px transparent gif
5 | const EMPTY_GIF = 'data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==';
6 |
7 | export interface IImageLoadConfig extends ILoadConfig
8 | {
9 | loadElement?: HTMLImageElement;
10 | }
11 |
12 | export class ImageLoadStrategy extends AbstractLoadStrategy
13 | {
14 | private _boundOnLoad = this._onLoad.bind(this);
15 | private _boundOnError = this._onError.bind(this);
16 | private _boundOnTimeout = this._onTimeout.bind(this);
17 |
18 | private _element = this._createElement();
19 | private _elementTimer = 0;
20 |
21 | load(): void
22 | {
23 | const config = this.config;
24 |
25 | if (config.crossOrigin)
26 | this._element.crossOrigin = config.crossOrigin;
27 |
28 | this._element.src = config.url;
29 |
30 | this._element.addEventListener('load', this._boundOnLoad, false);
31 | this._element.addEventListener('error', this._boundOnError, false);
32 |
33 | if (config.timeout)
34 | this._elementTimer = window.setTimeout(this._boundOnTimeout, config.timeout);
35 | }
36 |
37 | abort(): void
38 | {
39 | this._clearEvents();
40 | this._element.src = EMPTY_GIF;
41 | this._error('Image load aborted by the user.');
42 | }
43 |
44 | private _createElement(): HTMLImageElement
45 | {
46 | if (this.config.loadElement)
47 | return this.config.loadElement;
48 | else
49 | return document.createElement('img')
50 | }
51 |
52 | private _clearEvents(): void
53 | {
54 | clearTimeout(this._elementTimer);
55 |
56 | this._element.removeEventListener('load', this._boundOnLoad, false);
57 | this._element.removeEventListener('error', this._boundOnError, false);
58 | }
59 |
60 | private _error(errMessage: string): void
61 | {
62 | this._clearEvents();
63 | this.onError.dispatch(errMessage);
64 | }
65 |
66 | private _complete(): void
67 | {
68 | this._clearEvents();
69 | this.onComplete.dispatch(ResourceType.Image, this._element);
70 | }
71 |
72 | private _onLoad(): void
73 | {
74 | this._complete();
75 | }
76 |
77 | private _onError(): void
78 | {
79 | this._error('Image failed to load.');
80 | }
81 |
82 | private _onTimeout(): void
83 | {
84 | this._error('Image load timed out.');
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # How to contribute
2 |
3 | Please read this short guide to contributing before performing pull requests or reporting issues. The purpose
4 | of this guide is to ensure the best experience for all involved and make development as smooth as possible.
5 |
6 |
7 | ## Reporting issues
8 |
9 | To report a bug, request a feature, or even ask a question, make use of the [GitHub Issues][10] in this repo.
10 | When submitting an issue please take the following steps:
11 |
12 | **1. Search for existing issues.** Your bug may have already been fixed or addressed in an unreleased version, so
13 | be sure to search the issues first before putting in a duplicate issue.
14 |
15 | **2. Create an isolated and reproducible test case.** If you are reporting a bug, make sure you also have a minimal,
16 | runnable, code example that reproduces the problem you have.
17 |
18 | **3. Include a live example.** After narrowing your code down to only the problem areas, make use of [jsFiddle][11],
19 | [jsBin][12], or a link to your live site so that we can view a live example of the problem.
20 |
21 | **4. Share as much information as possible.** Include browser/node version affected, your OS, version of the library,
22 | steps to reproduce, etc. "X isn't working!!!1!" will probably just be closed.
23 |
24 | [10]: https://github.com/englercj/asset-loader/issues
25 | [11]: http://jsfiddle.net
26 | [12]: http://jsbin.com/
27 |
28 |
29 | ## Making Changes
30 |
31 | To build the library you will need to download node.js from [nodejs.org][20]. After it has been installed open a
32 | console and run `npm install -g gulp` to install the global `gulp` executable.
33 |
34 | After that you can clone the repository and run `npm install` inside the cloned folder. This will install
35 | dependencies necessary for building the project. You can rebuild the project by running `gulp` in the cloned
36 | folder.
37 |
38 | Once that is ready, you can make your changes and submit a Pull Request:
39 |
40 | - **Send Pull Requests to the `master` branch.** All Pull Requests must be sent to the `master` branch, which is where
41 | all "bleeding-edge" development takes place.
42 |
43 | - **Ensure changes are jshint validated.** Our JSHint configuration file is provided in the repository and you
44 | should check against it before submitting. This should happen automatically when running `gulp` in the repo directory.
45 |
46 | - **Never commit new builds.** When making a code change you should always run `gulp` which will rebuild the project
47 | so you can test, *however* please do not commit the new builds placed in `dist/` or your PR will be closed. By default
48 | the `dist/` folder is ignored so this shouldn't happen by accident.
49 |
50 | - **Only commit relevant changes.** Don't include changes that are not directly relevant to the fix you are making.
51 | The more focused a PR is, the faster it will get attention and be merged. Extra files changing only whitespace or
52 | trash files will likely get your PR closed.
53 |
54 | [20]: http://nodejs.org
55 |
56 |
57 | ## Quickie Code Style Guide
58 |
59 | Use EditorConfig and JSHint! Both tools will ensure your code is in the required styles! Either way, here are some tips:
60 |
61 | - Use 4 spaces for tabs, never tab characters.
62 |
63 | - No trailing whitespace, blank lines should have no whitespace.
64 |
65 | - Always favor strict equals `===` unless you *need* to use type coercion.
66 |
67 | - Follow conventions already in the code, and listen to jshint. Our config is set-up for a reason.
68 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Resource Loader [](https://travis-ci.org/englercj/resource-loader)
2 |
3 | A generic resource loader, made with web games in mind.
4 |
5 | ## Philosophy
6 |
7 | This library was built to make it easier to load and prepare data asynchronously. The
8 | goal was mainly to unify the many different APIs browsers expose for loading data and
9 | smooth the differences between versions and vendors.
10 |
11 | It is not a goal of this library to be a resource caching and management system,
12 | just a loader. This library is for the actual mechanism of loading data. All
13 | caching, resource management, knowing what is loaded and what isn't, deciding
14 | what to load, etc, should all exist as logic outside of this library.
15 |
16 | As a more concrete statement, your project should have a Resource Manager that
17 | stores resources and manages data lifetime. When it decides something needs to be
18 | loaded from a remote source, only then does it create a loader and load them.
19 |
20 | ## Usage
21 |
22 | ```js
23 | // ctor
24 | import { Loader } from 'resource-loader';
25 |
26 | const loader = new Loader();
27 |
28 | loader
29 | // Chainable `add` to enqueue a resource
30 | .add(url)
31 |
32 | // Chainable `use` to add a middleware that runs for each resource, *after* loading that resource.
33 | // This is useful to implement custom parsing modules (like spritesheet parsers).
34 | .use((resource, next) =>
35 | {
36 | // Be sure to call next() when you have completed your middleware work.
37 | next();
38 | })
39 |
40 | // The `load` method loads the queue of resources, and calls the passed in callback called once all
41 | // resources have loaded.
42 | .load((loader, resources) => {
43 | // resources is an object where the key is the name of the resource loaded and the value is the resource object.
44 | // They have a couple default properties:
45 | // - `url`: The URL that the resource was loaded from
46 | // - `error`: The error that happened when trying to load (if any)
47 | // - `data`: The raw data that was loaded
48 | // also may contain other properties based on the middleware that runs.
49 | });
50 |
51 | // Throughout the process multiple signals can be dispatched.
52 | loader.onStart.add(() => {}); // Called when a resource starts loading.
53 | loader.onError.add(() => {}); // Called when a resource fails to load.
54 | loader.onLoad.add(() => {}); // Called when a resource successfully loads.
55 | loader.onProgress.add(() => {}); // Called when a resource finishes loading (success or fail).
56 | loader.onComplete.add(() => {}); // Called when all resources have finished loading.
57 | ```
58 |
59 | ## Building
60 |
61 | You will need to have [node][node] setup on your machine.
62 |
63 | Then you can install dependencies and build:
64 |
65 | ```js
66 | npm i && npm run build
67 | ```
68 |
69 | That will output the built distributables to `./dist`.
70 |
71 | [node]: http://nodejs.org/
72 |
73 | ## Supported Browsers
74 |
75 | - IE 9+
76 | - FF 13+
77 | - Chrome 20+
78 | - Safari 6+
79 | - Opera 12.1+
80 |
81 | ## Upgrading to v4
82 |
83 | - Before middleware has been removed, so no more `pre` function.
84 | * If you used `pre` middleware for url parsing, use the new `urlResolver` property instead.
85 | - `crossOrigin` must now be a string if specified.
86 | - `Resource.LOAD_TYPE` enum replaced with Load Strategies.
87 | * For example, `loadType: Resource.LOAD_TYPE.IMAGE` is now `strategy: Loader.ImageLoadStrategy`.
88 | - `Resource.XHR_RESPONSE_TYPE` enum replaced with `XhrLoadStrategy.ResponseType`.
89 | * For example, `xhrType: Resource.XHR_RESPONSE_TYPE.DOCUMENT` is now `xhrType: Loader.XhrLoadStrategy.ResponseType.Document`.
90 | - Overloads for the `add` function have been simplified.
91 | * The removed overloads were not widely used. See the docs for what is now valid.
92 |
--------------------------------------------------------------------------------
/src/load_strategies/MediaElementLoadStrategy.ts:
--------------------------------------------------------------------------------
1 | import { AbstractLoadStrategy, ILoadConfig } from './AbstractLoadStrategy';
2 | import { getExtension, assertNever } from '../utilities';
3 | import { ResourceType } from '../resource_type';
4 |
5 | export interface IMediaElementLoadConfig extends ILoadConfig
6 | {
7 | sourceSet?: string[];
8 | mimeTypes?: string[];
9 | loadElement?: HTMLMediaElement;
10 | }
11 |
12 | export abstract class MediaElementLoadStrategy extends AbstractLoadStrategy
13 | {
14 | private _boundOnLoad = this._onLoad.bind(this);
15 | private _boundOnError = this._onError.bind(this);
16 | private _boundOnTimeout = this._onTimeout.bind(this);
17 |
18 | private _element = this._createElement();
19 | private _elementTimer = 0;
20 |
21 | constructor(config: IMediaElementLoadConfig, readonly elementType: ('audio' | 'video'))
22 | {
23 | super(config);
24 | }
25 |
26 | load(): void
27 | {
28 | const config = this.config;
29 |
30 | if (config.crossOrigin)
31 | this._element.crossOrigin = config.crossOrigin;
32 |
33 | const urls = config.sourceSet || [config.url];
34 |
35 | // support for CocoonJS Canvas+ runtime, lacks document.createElement('source')
36 | if ((navigator as any).isCocoonJS)
37 | {
38 | this._element.src = urls[0];
39 | }
40 | else
41 | {
42 | for (let i = 0; i < urls.length; ++i)
43 | {
44 | const url = urls[i];
45 | let mimeType = config.mimeTypes ? config.mimeTypes[i] : undefined;
46 |
47 | if (!mimeType)
48 | mimeType = `${this.elementType}/${getExtension(url)}`;
49 |
50 | const source = document.createElement('source');
51 |
52 | source.src = url;
53 | source.type = mimeType;
54 |
55 | this._element.appendChild(source);
56 | }
57 | }
58 |
59 | this._element.addEventListener('load', this._boundOnLoad, false);
60 | this._element.addEventListener('canplaythrough', this._boundOnLoad, false);
61 | this._element.addEventListener('error', this._boundOnError, false);
62 |
63 | this._element.load();
64 |
65 | if (config.timeout)
66 | this._elementTimer = window.setTimeout(this._boundOnTimeout, config.timeout);
67 | }
68 |
69 | abort(): void
70 | {
71 | this._clearEvents();
72 | while (this._element.firstChild)
73 | {
74 | this._element.removeChild(this._element.firstChild);
75 | }
76 | this._error(`${this.elementType} load aborted by the user.`);
77 | }
78 |
79 | private _createElement(): HTMLMediaElement
80 | {
81 | if (this.config.loadElement)
82 | return this.config.loadElement;
83 | else
84 | return document.createElement(this.elementType);
85 | }
86 |
87 | private _clearEvents(): void
88 | {
89 | clearTimeout(this._elementTimer);
90 |
91 | this._element.removeEventListener('load', this._boundOnLoad, false);
92 | this._element.removeEventListener('canplaythrough', this._boundOnLoad, false);
93 | this._element.removeEventListener('error', this._boundOnError, false);
94 | }
95 |
96 | private _error(errMessage: string): void
97 | {
98 | this._clearEvents();
99 | this.onError.dispatch(errMessage);
100 | }
101 |
102 | private _complete(): void
103 | {
104 | this._clearEvents();
105 |
106 | let resourceType = ResourceType.Unknown;
107 |
108 | switch (this.elementType)
109 | {
110 | case 'audio': resourceType = ResourceType.Audio; break;
111 | case 'video': resourceType = ResourceType.Video; break;
112 | default: assertNever(this.elementType);
113 | }
114 |
115 | this.onComplete.dispatch(resourceType, this._element);
116 | }
117 |
118 | private _onLoad(): void
119 | {
120 | this._complete();
121 | }
122 |
123 | private _onError(): void
124 | {
125 | this._error(`${this.elementType} failed to load.`);
126 | }
127 |
128 | private _onTimeout(): void
129 | {
130 | this._error(`${this.elementType} load timed out.`);
131 | }
132 | }
133 |
--------------------------------------------------------------------------------
/src/async/AsyncQueue.ts:
--------------------------------------------------------------------------------
1 | import { Signal } from 'type-signals';
2 |
3 | /**
4 | * Ensures a function is only called once.
5 | *
6 | * @ignore
7 | * @typeparam R Return type of the function to wrap.
8 | * @param func The function to wrap.
9 | * @return The wrapping function.
10 | */
11 | function onlyOnce(func: (...args: any[]) => R): (...args: any[]) => R
12 | {
13 | let fn: typeof func | null = func;
14 |
15 | return function onceWrapper(this: any, ...args: any[])
16 | {
17 | if (fn === null)
18 | throw new Error('Callback was already called.');
19 |
20 | const callFn = fn;
21 | fn = null;
22 | return callFn.apply(this, args);
23 | };
24 | }
25 |
26 | export type INext = (err?: Error) => void;
27 | export type IWorker = (item: T, next: INext) => void;
28 | export type IItemCallback = (...args: any[]) => void;
29 |
30 | export type OnDoneSignal = () => void;
31 | export type OnSaturatedSignal = () => void;
32 | export type OnUnsaturatedSignal = () => void;
33 | export type OnEmptySignal = () => void;
34 | export type OnDrainSignal = () => void;
35 | export type OnErrorSignal = (err: Error, data: T) => void;
36 |
37 | interface ITask
38 | {
39 | data: T;
40 | callback?: IItemCallback;
41 | }
42 |
43 | /**
44 | * Async queue.
45 | *
46 | * @typeparam T Element type of the queue.
47 | * @param worker The worker function to call for each task.
48 | * @param concurrency How many workers to run in parrallel. Must be greater than 0.
49 | * @return The async queue object.
50 | */
51 | export class AsyncQueue
52 | {
53 | private workers = 0;
54 | private buffer = 0;
55 | private paused = false;
56 |
57 | private _started = false;
58 | private _tasks: ITask[] = [];
59 |
60 | readonly onSaturated: Signal = new Signal();
61 | readonly onUnsaturated: Signal = new Signal();
62 | readonly onEmpty: Signal = new Signal();
63 | readonly onDrain: Signal = new Signal();
64 | readonly onError: Signal> = new Signal>();
65 |
66 | constructor(readonly worker: IWorker, public concurrency = 1)
67 | {
68 | if (concurrency === 0)
69 | throw new Error('Concurrency must not be zero');
70 |
71 | this.buffer = concurrency / 4;
72 | }
73 |
74 | get started() { return this._started; }
75 |
76 | reset()
77 | {
78 | this.onDrain.detachAll();
79 | this.workers = 0;
80 | this._started = false;
81 | this._tasks = [];
82 | }
83 |
84 | push(data: T, callback?: IItemCallback)
85 | {
86 | this._insert(data, false, callback);
87 | }
88 |
89 | unshift(data: T, callback?: IItemCallback)
90 | {
91 | this._insert(data, true, callback);
92 | }
93 |
94 | process()
95 | {
96 | while (!this.paused && this.workers < this.concurrency && this._tasks.length)
97 | {
98 | const task = this._tasks.shift()!;
99 |
100 | if (this._tasks.length === 0)
101 | this.onEmpty.dispatch();
102 |
103 | this.workers += 1;
104 |
105 | if (this.workers === this.concurrency)
106 | this.onSaturated.dispatch();
107 |
108 | this.worker(task.data, onlyOnce(this._next(task)));
109 | }
110 | }
111 |
112 | length()
113 | {
114 | return this._tasks.length;
115 | }
116 |
117 | running()
118 | {
119 | return this.workers;
120 | }
121 |
122 | idle()
123 | {
124 | return this._tasks.length + this.workers === 0;
125 | }
126 |
127 | pause()
128 | {
129 | if (this.paused === true)
130 | return;
131 |
132 | this.paused = true;
133 | }
134 |
135 | resume()
136 | {
137 | if (this.paused === false)
138 | return;
139 |
140 | this.paused = false;
141 |
142 | // Need to call this.process once per concurrent
143 | // worker to preserve full concurrency after pause
144 | for (let w = 1; w <= this.concurrency; w++)
145 | {
146 | this.process();
147 | }
148 | }
149 |
150 | getTask(index: number): ITask
151 | {
152 | return this._tasks[index];
153 | }
154 |
155 | private _insert(data: T, insertAtFront: boolean, callback?: IItemCallback)
156 | {
157 | if (callback != null && typeof callback !== 'function')
158 | {
159 | throw new Error('task callback must be a function');
160 | }
161 |
162 | this._started = true;
163 |
164 | if (data == null && this.idle())
165 | {
166 | // call drain immediately if there are no tasks
167 | setTimeout(() => this.onDrain.dispatch(), 1);
168 | return;
169 | }
170 |
171 | const task: ITask = { data, callback };
172 |
173 | if (insertAtFront)
174 | this._tasks.unshift(task);
175 | else
176 | this._tasks.push(task);
177 |
178 | setTimeout(() => this.process(), 1);
179 | }
180 |
181 | private _next(task: ITask)
182 | {
183 | return (err?: Error, ...args: any[]) =>
184 | {
185 | this.workers -= 1;
186 |
187 | if (task.callback)
188 | task.callback(err, ...args);
189 |
190 | if (err)
191 | this.onError.dispatch(err, task.data);
192 |
193 | if (this.workers <= (this.concurrency - this.buffer))
194 | this.onUnsaturated.dispatch();
195 |
196 | if (this.idle())
197 | this.onDrain.dispatch();
198 |
199 | this.process();
200 | };
201 | }
202 | }
203 |
--------------------------------------------------------------------------------
/src/Resource.ts:
--------------------------------------------------------------------------------
1 | import parseUri from 'parse-uri';
2 | import { Signal, SignalBinding } from 'type-signals';
3 | import { AbstractLoadStrategy, ILoadConfig, AbstractLoadStrategyCtor } from './load_strategies/AbstractLoadStrategy';
4 | import { ImageLoadStrategy } from './load_strategies/ImageLoadStrategy';
5 | import { AudioLoadStrategy } from './load_strategies/AudioLoadStrategy';
6 | import { VideoLoadStrategy } from './load_strategies/VideoLoadStrategy';
7 | import { XhrLoadStrategy } from './load_strategies/XhrLoadStrategy';
8 | import { ResourceType, ResourceState } from './resource_type';
9 | import { getExtension } from './utilities';
10 |
11 | export interface IResourceOptions extends ILoadConfig
12 | {
13 | // OVerride the load strategy to use for this one resource.
14 | strategy?: AbstractLoadStrategy | AbstractLoadStrategyCtor;
15 |
16 | // Extra info added by the user, usually for middleware.
17 | metadata?: any;
18 | }
19 |
20 | /**
21 | * @category Type Aliases
22 | */
23 | export namespace Resource
24 | {
25 | export type OnStartSignal = (resource: Resource) => void;
26 | export type OnErrorSignal = (resource: Resource) => void;
27 | export type OnCompleteSignal = (resource: Resource) => void;
28 | export type OnProgressSignal = (resource: Resource, percent: number) => void;
29 | }
30 |
31 | /**
32 | * Manages the state and loading of a resource and all child resources.
33 | * @preferred
34 | */
35 | export class Resource
36 | {
37 | private static _tempAnchor: HTMLAnchorElement | null = null;
38 |
39 | private static _defaultLoadStrategy: AbstractLoadStrategyCtor = XhrLoadStrategy;
40 | private static _loadStrategyMap: Partial> = {
41 | // images
42 | gif: ImageLoadStrategy,
43 | png: ImageLoadStrategy,
44 | bmp: ImageLoadStrategy,
45 | jpg: ImageLoadStrategy,
46 | jpeg: ImageLoadStrategy,
47 | tif: ImageLoadStrategy,
48 | tiff: ImageLoadStrategy,
49 | webp: ImageLoadStrategy,
50 | tga: ImageLoadStrategy,
51 | svg: ImageLoadStrategy,
52 | 'svg+xml': ImageLoadStrategy, // for SVG data urls
53 |
54 | // audio
55 | mp3: AudioLoadStrategy,
56 | ogg: AudioLoadStrategy,
57 | wav: AudioLoadStrategy,
58 |
59 | // videos
60 | mp4: VideoLoadStrategy,
61 | webm: VideoLoadStrategy,
62 | mov: VideoLoadStrategy,
63 | };
64 |
65 | /**
66 | * Sets the default load stragety to use when there is no extension-specific strategy.
67 | */
68 | static setDefaultLoadStrategy(strategy: AbstractLoadStrategyCtor): void
69 | {
70 | Resource._defaultLoadStrategy = strategy;
71 | }
72 |
73 | /**
74 | * Sets the load strategy to be used for a specific extension.
75 | *
76 | * @param extname The extension to set the type for, e.g. "png" or "fnt"
77 | * @param strategy The load strategy to use for loading resources with that extension.
78 | */
79 | static setLoadStrategy(extname: string, strategy: AbstractLoadStrategyCtor): void
80 | {
81 | if (extname && extname.indexOf('.') === 0)
82 | extname = extname.substring(1);
83 |
84 | if (!extname)
85 | return;
86 |
87 | Resource._loadStrategyMap[extname] = strategy;
88 | }
89 |
90 | /**
91 | * The name of this resource.
92 | */
93 | readonly name: string;
94 |
95 | /**
96 | * The child resources of this resource.
97 | */
98 | readonly children: Resource[] = [];
99 |
100 | /**
101 | * Dispatched when the resource beings to load.
102 | */
103 | readonly onStart: Signal = new Signal();
104 |
105 | /**
106 | * Dispatched each time progress of this resource load updates.
107 | * Not all resources types and loader systems can support this event
108 | * so sometimes it may not be available. If the resource
109 | * is being loaded on a modern browser, using XHR, and the remote server
110 | * properly sets Content-Length headers, then this will be available.
111 | */
112 | readonly onProgress: Signal = new Signal();
113 |
114 | /**
115 | * Dispatched once this resource has loaded, if there was an error it will
116 | * be in the `error` property.
117 | */
118 | readonly onComplete: Signal = new Signal();
119 |
120 | /**
121 | * Dispatched after this resource has had all the *after* middleware run on it.
122 | */
123 | readonly onAfterMiddleware: Signal = new Signal();
124 |
125 | /**
126 | * The data that was loaded by the resource. The type of this member is
127 | * described by the `type` member.
128 | */
129 | data: any = null;
130 |
131 | /**
132 | * Extra info added by the user, usually for middleware.
133 | */
134 | metadata: any;
135 |
136 | /**
137 | * Describes the type of the `data` member for this resource.
138 | *
139 | * @see ResourceType
140 | */
141 | type = ResourceType.Unknown;
142 |
143 | /**
144 | * The error that occurred while loading (if any).
145 | */
146 | error = '';
147 |
148 | /**
149 | * The progress chunk owned by this resource.
150 | */
151 | progressChunk = 0;
152 |
153 | /**
154 | * Storage for use privately by the Loader.
155 | * Do not touch this member.
156 | *
157 | * @ignore
158 | */
159 | _dequeue: Function = function () {};
160 |
161 | /**
162 | * Storage for use privately by the Loader.
163 | * Do not touch this member.
164 | *
165 | * @ignore
166 | */
167 | _onCompleteBinding: SignalBinding | null = null;
168 |
169 | private _strategy: AbstractLoadStrategy;
170 | private _state = ResourceState.NotStarted;
171 |
172 | /**
173 | * @param name The name of the resource to load.
174 | * @param options The options for the load strategy that will be used.
175 | */
176 | constructor(name: string, options: IResourceOptions)
177 | {
178 | this.name = name;
179 | this.metadata = options.metadata;
180 |
181 | if (typeof options.crossOrigin !== 'string')
182 | options.crossOrigin = this._determineCrossOrigin(options.url);
183 |
184 | if (options.strategy && typeof options.strategy !== 'function')
185 | {
186 | this._strategy = options.strategy;
187 |
188 | // Only `Resource` is allowed to set the config object,
189 | // it is otherwise readonly.
190 | (this._strategy as any).config = options;
191 | }
192 | else
193 | {
194 | let StrategyCtor = options.strategy;
195 |
196 | if (!StrategyCtor)
197 | StrategyCtor = Resource._loadStrategyMap[getExtension(options.url)];
198 |
199 | if (!StrategyCtor)
200 | StrategyCtor = Resource._defaultLoadStrategy;
201 |
202 | this._strategy = new StrategyCtor(options);
203 | }
204 |
205 | this._strategy.onError.add(this._error, this);
206 | this._strategy.onComplete.add(this._complete, this);
207 | this._strategy.onProgress.add(this._progress, this);
208 | }
209 |
210 | get strategy(): AbstractLoadStrategy { return this._strategy; }
211 | get url(): string { return this._strategy.config.url; }
212 | get isLoading(): boolean { return this._state === ResourceState.Loading; }
213 | get isComplete(): boolean { return this._state === ResourceState.Complete; }
214 |
215 | /**
216 | * Aborts the loading of the resource.
217 | */
218 | abort(): void
219 | {
220 | this._strategy.abort();
221 | }
222 |
223 | /**
224 | * Kicks off loading of this resource.
225 | */
226 | load(): void
227 | {
228 | this._state = ResourceState.Loading;
229 | this.onStart.dispatch(this);
230 | this._strategy.load();
231 | }
232 |
233 | private _error(errMessage: string): void
234 | {
235 | this._state = ResourceState.Complete;
236 | this.error = errMessage;
237 | this.onComplete.dispatch(this);
238 | }
239 |
240 | private _complete(type: ResourceType, data: any): void
241 | {
242 | this._state = ResourceState.Complete;
243 | this.type = type;
244 | this.data = data;
245 | this.onComplete.dispatch(this);
246 | }
247 |
248 | private _progress(percent: number): void
249 | {
250 | this.onProgress.dispatch(this, percent);
251 | }
252 |
253 | /**
254 | * Determines if a URL is crossOrigin, and if so returns the crossOrigin string.
255 | */
256 | private _determineCrossOrigin(url: string, loc = window.location): string
257 | {
258 | // data: and javascript: urls are considered same-origin
259 | if (url.indexOf('data:') === 0 || url.indexOf('javascript:') === 0)
260 | return '';
261 |
262 | // A sandboxed iframe without the 'allow-same-origin' attribute will have a special
263 | // origin designed not to match window.location.origin, and will always require
264 | // crossOrigin requests regardless of whether the location matches.
265 | if (window.origin !== window.location.origin)
266 | return 'anonymous';
267 |
268 | if (!Resource._tempAnchor)
269 | Resource._tempAnchor = document.createElement('a');
270 |
271 | // Let the browser determine the full href for the url and then parse with the
272 | // url lib. We can't use the properties of the anchor element because they
273 | // don't work in IE9 :(
274 | Resource._tempAnchor.href = url;
275 |
276 | const parsed = parseUri(Resource._tempAnchor.href, { strictMode: true });
277 |
278 | const samePort = (!parsed.port && loc.port === '') || (parsed.port === loc.port);
279 | const protocol = parsed.protocol ? `${parsed.protocol}:` : '';
280 |
281 | // if cross origin
282 | if (parsed.host !== loc.hostname || !samePort || protocol !== loc.protocol)
283 | return 'anonymous';
284 |
285 | return '';
286 | }
287 | }
288 |
--------------------------------------------------------------------------------
/src/load_strategies/XhrLoadStrategy.ts:
--------------------------------------------------------------------------------
1 | import { AbstractLoadStrategy, ILoadConfig } from './AbstractLoadStrategy';
2 | import { getExtension, assertNever } from '../utilities';
3 | import { ResourceType } from '../resource_type';
4 |
5 | // tests if CORS is supported in XHR, if not we need to use XDR
6 | // Mainly this is for IE9 support.
7 | const useXdr = !!((window as any).XDomainRequest && !('withCredentials' in (new XMLHttpRequest())));
8 |
9 | const enum HttpStatus
10 | {
11 | None = 0,
12 | Ok = 200,
13 | Empty = 204,
14 | IeEmptyBug = 1223,
15 | }
16 |
17 | /**
18 | * The XHR response types.
19 | */
20 | export enum XhrResponseType
21 | {
22 | /** string */
23 | Default = 'text',
24 | /** ArrayBuffer */
25 | Buffer = 'arraybuffer',
26 | /** Blob */
27 | Blob = 'blob',
28 | /** Document */
29 | Document = 'document',
30 | /** Object */
31 | Json = 'json',
32 | /** String */
33 | Text = 'text',
34 | };
35 |
36 | export interface IXhrLoadConfig extends ILoadConfig
37 | {
38 | xhrType?: XhrResponseType;
39 | }
40 |
41 | /**
42 | * Quick helper to get string xhr type.
43 | */
44 | function reqType(xhr: XMLHttpRequest): string
45 | {
46 | return xhr.toString().replace('object ', '');
47 | }
48 |
49 | export class XhrLoadStrategy extends AbstractLoadStrategy
50 | {
51 | static readonly ResponseType = XhrResponseType;
52 |
53 | private _boundOnLoad = this._onLoad.bind(this);
54 | private _boundOnAbort = this._onAbort.bind(this);
55 | private _boundOnError = this._onError.bind(this);
56 | private _boundOnTimeout = this._onTimeout.bind(this);
57 | private _boundOnProgress = this._onProgress.bind(this);
58 |
59 | private _xhr = this._createRequest();
60 | private _xhrType = XhrResponseType.Default;
61 |
62 | load(): void
63 | {
64 | const config = this.config;
65 | const ext = getExtension(config.url);
66 |
67 | if (typeof config.xhrType !== 'string')
68 | {
69 | config.xhrType = this._determineXhrType(ext);
70 | }
71 |
72 | const xhr = this._xhr;
73 |
74 | this._xhrType = config.xhrType || XhrResponseType.Default;
75 |
76 | // XDomainRequest has a few quirks. Occasionally it will abort requests
77 | // A way to avoid this is to make sure ALL callbacks are set even if not used
78 | // More info here: http://stackoverflow.com/questions/15786966/xdomainrequest-aborts-post-on-ie-9
79 |
80 | if (useXdr)
81 | {
82 | // XDR needs a timeout value or it breaks in IE9
83 | xhr.timeout = config.timeout || 5000;
84 |
85 | xhr.onload = this._boundOnLoad;
86 | xhr.onerror = this._boundOnError;
87 | xhr.ontimeout = this._boundOnTimeout;
88 | xhr.onprogress = this._boundOnProgress;
89 |
90 | xhr.open('GET', config.url, true);
91 |
92 | // Note: The xdr.send() call is wrapped in a timeout to prevent an issue with
93 | // the interface where some requests are lost if multiple XDomainRequests are
94 | // being sent at the same time.
95 | setTimeout(function () { xhr.send(); }, 0);
96 | }
97 | else
98 | {
99 | xhr.open('GET', config.url, true);
100 |
101 | if (config.timeout)
102 | xhr.timeout = config.timeout;
103 |
104 | // load json as text and parse it ourselves. We do this because some browsers
105 | // *cough* safari *cough* can't deal with it.
106 | if (config.xhrType === XhrResponseType.Json || config.xhrType === XhrResponseType.Document)
107 | xhr.responseType = XhrResponseType.Text;
108 | else
109 | xhr.responseType = config.xhrType;
110 |
111 | xhr.addEventListener('load', this._boundOnLoad, false);
112 | xhr.addEventListener('abort', this._boundOnAbort, false);
113 | xhr.addEventListener('error', this._boundOnError, false);
114 | xhr.addEventListener('timeout', this._boundOnTimeout, false);
115 | xhr.addEventListener('progress', this._boundOnProgress, false);
116 |
117 | xhr.send();
118 | }
119 | }
120 |
121 | abort(): void
122 | {
123 | if (useXdr)
124 | {
125 | this._clearEvents();
126 | this._xhr.abort();
127 | this._onAbort();
128 | }
129 | else
130 | {
131 | // will call the abort event
132 | this._xhr.abort();
133 | }
134 | }
135 |
136 | private _createRequest(): XMLHttpRequest
137 | {
138 | if (useXdr)
139 | return new (window as any).XDomainRequest();
140 | else
141 | return new XMLHttpRequest();
142 | }
143 |
144 | private _determineXhrType(ext: string): XhrResponseType
145 | {
146 | return XhrLoadStrategy._xhrTypeMap[ext] || XhrResponseType.Default;
147 | }
148 |
149 | private _clearEvents(): void
150 | {
151 | if (useXdr)
152 | {
153 | this._xhr.onload = null;
154 | this._xhr.onerror = null;
155 | this._xhr.ontimeout = null;
156 | this._xhr.onprogress = null;
157 | }
158 | else
159 | {
160 | this._xhr.removeEventListener('load', this._boundOnLoad, false);
161 | this._xhr.removeEventListener('abort', this._boundOnAbort, false);
162 | this._xhr.removeEventListener('error', this._boundOnError, false);
163 | this._xhr.removeEventListener('timeout', this._boundOnTimeout, false);
164 | this._xhr.removeEventListener('progress', this._boundOnProgress, false);
165 | }
166 | }
167 |
168 | private _error(errMessage: string): void
169 | {
170 | this._clearEvents();
171 | this.onError.dispatch(errMessage);
172 | }
173 |
174 | private _complete(type: ResourceType, data: any): void
175 | {
176 | this._clearEvents();
177 | this.onComplete.dispatch(type, data);
178 | }
179 |
180 | private _onLoad(): void
181 | {
182 | const xhr = this._xhr;
183 | let text = '';
184 |
185 | // XDR has no `.status`, assume 200.
186 | let status = typeof xhr.status === 'undefined' ? HttpStatus.Ok : xhr.status;
187 |
188 | // responseText is accessible only if responseType is '' or 'text' and on older browsers
189 | if (typeof xhr.responseType === 'undefined' || xhr.responseType === '' || xhr.responseType === 'text')
190 | {
191 | text = xhr.responseText;
192 | }
193 |
194 | // status can be 0 when using the `file://` protocol so we also check if a response is set.
195 | // If it has a response, we assume 200; otherwise a 0 status code with no contents is an aborted request.
196 | if (status === HttpStatus.None && (text.length > 0 || xhr.responseType === XhrResponseType.Buffer))
197 | {
198 | status = HttpStatus.Ok;
199 | }
200 | // handle IE9 bug: http://stackoverflow.com/questions/10046972/msie-returns-status-code-of-1223-for-ajax-request
201 | else if (status === HttpStatus.IeEmptyBug)
202 | {
203 | status = HttpStatus.Empty;
204 | }
205 |
206 | const flattenedStatus = Math.floor(status / 100) * 100;
207 |
208 | if (flattenedStatus !== HttpStatus.Ok)
209 | {
210 | this._error(`[${xhr.status}] ${xhr.statusText}: ${xhr.responseURL}`);
211 | return;
212 | }
213 |
214 | switch (this._xhrType)
215 | {
216 | case XhrResponseType.Buffer:
217 | this._complete(ResourceType.Buffer, xhr.response);
218 | break;
219 |
220 | case XhrResponseType.Blob:
221 | this._complete(ResourceType.Blob, xhr.response);
222 | break;
223 |
224 | case XhrResponseType.Document:
225 | this._parseDocument(text);
226 | break;
227 |
228 | case XhrResponseType.Json:
229 | this._parseJson(text);
230 | break;
231 |
232 | case XhrResponseType.Default:
233 | case XhrResponseType.Text:
234 | this._complete(ResourceType.Text, text);
235 | break;
236 |
237 | default:
238 | assertNever(this._xhrType);
239 | }
240 | }
241 |
242 | private _parseDocument(text: string): void
243 | {
244 | try
245 | {
246 | if (window.DOMParser)
247 | {
248 | const parser = new DOMParser();
249 | const data = parser.parseFromString(text, 'text/xml');
250 | this._complete(ResourceType.Xml, data);
251 | }
252 | else
253 | {
254 | const div = document.createElement('div');
255 | div.innerHTML = text;
256 | this._complete(ResourceType.Xml, div);
257 | }
258 | }
259 | catch (e)
260 | {
261 | this._error(`Error trying to parse loaded xml: ${e}`);
262 | }
263 | }
264 |
265 | private _parseJson(text: string): void
266 | {
267 | try
268 | {
269 | const data = JSON.parse(text);
270 | this._complete(ResourceType.Json, data);
271 | }
272 | catch (e)
273 | {
274 | this._error(`Error trying to parse loaded json: ${e}`);
275 | }
276 | }
277 |
278 | private _onAbort(): void
279 | {
280 | const xhr = this._xhr;
281 | this._error(`${reqType(xhr)} Request was aborted by the user.`);
282 | }
283 |
284 | private _onError(): void
285 | {
286 | const xhr = this._xhr;
287 | this._error(`${reqType(xhr)} Request failed. Status: ${xhr.status}, text: "${xhr.statusText}"`);
288 | }
289 |
290 | private _onTimeout(): void
291 | {
292 | const xhr = this._xhr;
293 | this._error(`${reqType(xhr)} Request timed out.`);
294 | }
295 |
296 | private _onProgress(event: ProgressEvent): void
297 | {
298 | if (event && event.lengthComputable)
299 | {
300 | this.onProgress.dispatch(event.loaded / event.total);
301 | }
302 | }
303 |
304 | /**
305 | * Sets the load type to be used for a specific extension.
306 | *
307 | * @param extname The extension to set the type for, e.g. "png" or "fnt"
308 | * @param xhrType The xhr type to set it to.
309 | */
310 | static setExtensionXhrType(extname: string, xhrType: XhrResponseType)
311 | {
312 | if (extname && extname.indexOf('.') === 0)
313 | extname = extname.substring(1);
314 |
315 | if (!extname)
316 | return;
317 |
318 | XhrLoadStrategy._xhrTypeMap[extname] = xhrType;
319 | }
320 |
321 | private static _xhrTypeMap: Partial> = {
322 | // xml
323 | xhtml: XhrResponseType.Document,
324 | html: XhrResponseType.Document,
325 | htm: XhrResponseType.Document,
326 | xml: XhrResponseType.Document,
327 | tmx: XhrResponseType.Document,
328 | svg: XhrResponseType.Document,
329 |
330 | // This was added to handle Tiled Tileset XML, but .tsx is also a TypeScript React Component.
331 | // Since it is way less likely for people to be loading TypeScript files instead of Tiled files,
332 | // this should probably be fine.
333 | tsx: XhrResponseType.Document,
334 |
335 | // images
336 | gif: XhrResponseType.Blob,
337 | png: XhrResponseType.Blob,
338 | bmp: XhrResponseType.Blob,
339 | jpg: XhrResponseType.Blob,
340 | jpeg: XhrResponseType.Blob,
341 | tif: XhrResponseType.Blob,
342 | tiff: XhrResponseType.Blob,
343 | webp: XhrResponseType.Blob,
344 | tga: XhrResponseType.Blob,
345 |
346 | // json
347 | json: XhrResponseType.Json,
348 |
349 | // text
350 | text: XhrResponseType.Text,
351 | txt: XhrResponseType.Text,
352 |
353 | // fonts
354 | ttf: XhrResponseType.Buffer,
355 | otf: XhrResponseType.Buffer,
356 | };
357 | }
358 |
--------------------------------------------------------------------------------
/test/spec/Resource.test.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const Resource = Loader.Resource;
4 |
5 | describe('Resource', () => {
6 | let request;
7 | let res;
8 | let xhr;
9 | let clock;
10 | const name = 'test-resource';
11 |
12 | before(() => {
13 | xhr = sinon.useFakeXMLHttpRequest();
14 | xhr.onCreate = (req) => {
15 | request = req;
16 | };
17 | clock = sinon.useFakeTimers();
18 | });
19 |
20 | after(() => {
21 | xhr.restore();
22 | clock.restore();
23 | });
24 |
25 | beforeEach(() => {
26 | request = null;
27 | res = new Resource(name, { url: fixtureData.url });
28 | });
29 |
30 | it('should construct properly with only a URL passed', () => {
31 | expect(res).to.have.property('name', name);
32 | expect(res).to.have.property('children').that.is.empty;
33 | expect(res).to.have.property('data', null);
34 | expect(res).to.have.property('type', Loader.ResourceType.Unknown);
35 | expect(res).to.have.property('error', '');
36 | expect(res).to.have.property('progressChunk', 0);
37 |
38 | expect(res).to.have.property('url', fixtureData.url);
39 | expect(res).to.have.property('isLoading', false);
40 | expect(res).to.have.property('isComplete', false);
41 |
42 | expect(res).to.have.property('onStart');
43 | expect(res).to.have.property('onProgress');
44 | expect(res).to.have.property('onComplete');
45 | expect(res).to.have.property('onAfterMiddleware');
46 | });
47 |
48 | it('should construct properly with options passed', () => {
49 | const res = new Resource(name, {
50 | url: fixtureData.url,
51 | crossOrigin: 'anonymous',
52 | strategy: Loader.ImageLoadStrategy,
53 | xhrType: Loader.XhrLoadStrategy.ResponseType.Blob,
54 | some: 'thing',
55 | });
56 |
57 | expect(res).to.have.property('name', name);
58 | expect(res).to.have.property('children').that.is.empty;
59 | expect(res).to.have.property('data', null);
60 | expect(res).to.have.property('type', Loader.ResourceType.Unknown);
61 | expect(res).to.have.property('error', '');
62 | expect(res).to.have.property('progressChunk', 0);
63 |
64 | expect(res).to.have.property('url', fixtureData.url);
65 | expect(res).to.have.property('isLoading', false);
66 | expect(res).to.have.property('isComplete', false);
67 |
68 | expect(res).to.have.property('onStart');
69 | expect(res).to.have.property('onProgress');
70 | expect(res).to.have.property('onComplete');
71 | expect(res).to.have.property('onAfterMiddleware');
72 | });
73 |
74 | describe('#abort', () => {
75 | it('should abort in-flight XHR requests', () => {
76 | res.load();
77 |
78 | res._strategy._xhr.abort = sinon.spy();
79 |
80 | res.abort();
81 |
82 | expect(res._strategy._xhr.abort).to.have.been.calledOnce;
83 | });
84 |
85 | it('should abort in-flight XDR requests');
86 |
87 | it('should abort in-flight Image requests', () => {
88 | const res = new Resource(name, {
89 | url: fixtureData.url,
90 | strategy: Loader.ImageLoadStrategy,
91 | });
92 |
93 | res.load();
94 |
95 | expect(res._strategy._element.src).to.equal(fixtureData.url);
96 |
97 | res.abort();
98 |
99 | expect(res._strategy._element.src).to.not.equal(fixtureData.url);
100 | });
101 |
102 | it('should abort in-flight Video requests', () => {
103 | const res = new Resource(name, {
104 | url: fixtureData.url,
105 | strategy: Loader.VideoLoadStrategy,
106 | });
107 |
108 | res.load();
109 |
110 | expect(res._strategy._element.firstChild).to.exist;
111 |
112 | res.abort();
113 |
114 | expect(res._strategy._element.firstChild).to.not.exist;
115 | });
116 |
117 | it('should abort in-flight Audio requests', () => {
118 | const res = new Resource(name, {
119 | url: fixtureData.url,
120 | strategy: Loader.AudioLoadStrategy,
121 | });
122 |
123 | res.load();
124 |
125 | expect(res._strategy._element.firstChild).to.exist;
126 |
127 | res.abort();
128 |
129 | expect(res._strategy._element.firstChild).to.not.exist;
130 | });
131 | });
132 |
133 | describe('#load', () => {
134 | it('should emit the start event', () => {
135 | const spy = sinon.spy();
136 |
137 | res.onStart.add(spy);
138 |
139 | res.load();
140 |
141 | expect(request).to.exist;
142 | expect(spy).to.have.been.calledWith(res);
143 | });
144 |
145 | it('should emit the complete event', () => {
146 | const spy = sinon.spy();
147 |
148 | res.onComplete.add(spy);
149 |
150 | res.load();
151 |
152 | request.respond(200, fixtureData.dataJsonHeaders, fixtureData.dataJson);
153 |
154 | expect(request).to.exist;
155 | expect(spy).to.have.been.calledWith(res);
156 | });
157 |
158 | it('should load using a data url', (done) => {
159 | const res = new Resource(name, { url: fixtureData.dataUrlGif });
160 |
161 | res.onComplete.add(() => {
162 | expect(res).to.have.property('data').instanceOf(Image)
163 | .and.is.an.instanceOf(HTMLImageElement)
164 | .and.have.property('src', fixtureData.dataUrlGif);
165 |
166 | done();
167 | });
168 |
169 | res.load();
170 | });
171 |
172 | it('should load using a svg data url', (done) => {
173 | const res = new Resource(name, { url: fixtureData.dataUrlSvg });
174 |
175 | res.onComplete.add(() => {
176 | expect(res).to.have.property('data').instanceOf(Image)
177 | .and.is.an.instanceOf(HTMLImageElement)
178 | .and.have.property('src', fixtureData.dataUrlSvg);
179 |
180 | done();
181 | });
182 |
183 | res.load();
184 | });
185 |
186 | it('should load using XHR', (done) => {
187 | res.onComplete.add(() => {
188 | expect(res).to.have.property('data', fixtureData.dataJson);
189 | done();
190 | });
191 |
192 | res.load();
193 |
194 | expect(request).to.exist;
195 |
196 | request.respond(200, fixtureData.dataJsonHeaders, fixtureData.dataJson);
197 | });
198 |
199 | it('should load using Image', () => {
200 | const res = new Resource(name, { url: fixtureData.url, strategy: Loader.ImageLoadStrategy });
201 |
202 | res.load();
203 |
204 | expect(res._strategy).to.be.an.instanceOf(Loader.ImageLoadStrategy);
205 |
206 | expect(res._strategy).to.have.property('_element')
207 | .that.is.an.instanceOf(Image)
208 | .and.is.an.instanceOf(HTMLImageElement)
209 | .and.have.property('src', fixtureData.url);
210 | });
211 |
212 | it('should load using Audio', () => {
213 | const res = new Resource(name, { url: fixtureData.url, strategy: Loader.AudioLoadStrategy });
214 |
215 | res.load();
216 |
217 | expect(res._strategy).to.be.an.instanceOf(Loader.AudioLoadStrategy);
218 |
219 | expect(res._strategy).to.have.property('_element')
220 | .that.is.an.instanceOf(HTMLAudioElement);
221 |
222 | expect(res._strategy._element.children).to.have.length(1);
223 | expect(res._strategy._element.children[0]).to.have.property('src', fixtureData.url);
224 | });
225 |
226 | it('should load using Video', () => {
227 | const res = new Resource(name, { url: fixtureData.url, strategy: Loader.VideoLoadStrategy });
228 |
229 | res.load();
230 |
231 | expect(res._strategy).to.be.an.instanceOf(Loader.VideoLoadStrategy);
232 |
233 | expect(res._strategy).to.have.property('_element')
234 | .that.is.an.instanceOf(HTMLVideoElement);
235 |
236 | expect(res._strategy._element.children).to.have.length(1);
237 | expect(res._strategy._element.children[0]).to.have.property('src', fixtureData.url);
238 | });
239 |
240 | it('should used the passed element for loading', () => {
241 | const img = new Image();
242 | const spy = sinon.spy(img, 'addEventListener');
243 | const res = new Resource(name, {
244 | url: fixtureData.url,
245 | strategy: Loader.ImageLoadStrategy,
246 | loadElement: img,
247 | });
248 |
249 | res.load();
250 |
251 | expect(spy).to.have.been.calledTwice;
252 | expect(img).to.have.property('src', fixtureData.url);
253 |
254 | spy.restore();
255 | });
256 | });
257 |
258 | describe('#load with timeout', () => {
259 | it('should abort XHR loads', (done) => {
260 | const res = new Resource(name, { url: fixtureData.url, strategy: Loader.XhrLoadStrategy, timeout: 100 });
261 |
262 | res.onComplete.add(() => {
263 | expect(res).to.have.property('error').to.be.a('string');
264 | expect(res).to.have.property('data').equal(null);
265 | done();
266 | });
267 |
268 | res.load();
269 |
270 | expect(request).to.exist;
271 | request.triggerTimeout();
272 | });
273 |
274 | it('should abort Image loads', (done) => {
275 | const res = new Resource(name, { url: fixtureData.url, strategy: Loader.ImageLoadStrategy, timeout: 1000 });
276 |
277 | res.onComplete.add(() => {
278 | expect(res).to.have.property('error').to.be.a('string');
279 | done();
280 | });
281 |
282 | res.load();
283 |
284 | expect(res._strategy).to.have.property('_element')
285 | .that.is.an.instanceOf(Image)
286 | .and.is.an.instanceOf(HTMLImageElement)
287 | .and.have.property('src', fixtureData.url);
288 |
289 | clock.tick(1100);
290 | });
291 |
292 | it('should abort Audio loads', (done) => {
293 | const res = new Resource(name, { url: fixtureData.url, strategy: Loader.AudioLoadStrategy, timeout: 1000 });
294 |
295 | res.onComplete.add(() => {
296 | expect(res).to.have.property('error').to.be.a('string');
297 | done();
298 | });
299 |
300 | res.load();
301 |
302 | expect(res._strategy).to.have.property('_element')
303 | .that.is.an.instanceOf(HTMLAudioElement);
304 |
305 | expect(res._strategy._element.children).to.have.length(1);
306 | expect(res._strategy._element.children[0]).to.have.property('src', fixtureData.url);
307 |
308 | clock.tick(1100);
309 | });
310 |
311 | it('should abort Video loads', (done) => {
312 | const res = new Resource(name, { url: fixtureData.url, strategy: Loader.VideoLoadStrategy, timeout: 1000 });
313 |
314 | res.onComplete.add(() => {
315 | expect(res).to.have.property('error').to.be.a('string');
316 | done();
317 | });
318 |
319 | res.load();
320 |
321 | expect(res._strategy).to.have.property('_element')
322 | .that.is.an.instanceOf(HTMLVideoElement);
323 |
324 | expect(res._strategy._element.children).to.have.length(1);
325 | expect(res._strategy._element.children[0]).to.have.property('src', fixtureData.url);
326 |
327 | clock.tick(1100);
328 | });
329 | });
330 |
331 | describe('#load inside cordova', () => {
332 | beforeEach(() => {
333 | xhr.status = 0;
334 | });
335 |
336 | it('should load resource even if the status is 0', () => {
337 | res._strategy._xhr.responseText = 'I am loaded resource';
338 | res._strategy._onLoad();
339 |
340 | expect(res.isComplete).to.equal(true);
341 | });
342 |
343 | it('should load resource with array buffer data', () => {
344 | res._strategy._xhr.responseType = Loader.XhrLoadStrategy.ResponseType.Buffer;
345 | res._strategy._onLoad();
346 |
347 | expect(res.isComplete).to.equal(true);
348 | });
349 | });
350 |
351 | describe('#_determineCrossOrigin', () => {
352 | it('should properly detect same-origin requests (#1)', () => {
353 | expect(res._determineCrossOrigin(
354 | 'https://google.com',
355 | { hostname: 'google.com', port: '', protocol: 'https:' }
356 | )).to.equal('');
357 | });
358 |
359 | it('should properly detect same-origin requests (#2)', () => {
360 | expect(res._determineCrossOrigin(
361 | 'https://google.com:443',
362 | { hostname: 'google.com', port: '', protocol: 'https:' }
363 | )).to.equal('');
364 | });
365 |
366 | it('should properly detect same-origin requests (#3)', () => {
367 | expect(res._determineCrossOrigin(
368 | 'http://www.google.com:5678',
369 | { hostname: 'www.google.com', port: '5678', protocol: 'http:' }
370 | )).to.equal('');
371 | });
372 |
373 | it('should properly detect cross-origin requests (#1)', () => {
374 | expect(res._determineCrossOrigin(
375 | 'https://google.com',
376 | { hostname: 'google.com', port: '123', protocol: 'https:' }
377 | )).to.equal('anonymous');
378 | });
379 |
380 | it('should properly detect cross-origin requests (#2)', () => {
381 | expect(res._determineCrossOrigin(
382 | 'https://google.com',
383 | { hostname: 'google.com', port: '', protocol: 'http:' }
384 | )).to.equal('anonymous');
385 | });
386 |
387 | it('should properly detect cross-origin requests (#3)', () => {
388 | expect(res._determineCrossOrigin(
389 | 'https://google.com',
390 | { hostname: 'googles.com', port: '', protocol: 'https:' }
391 | )).to.equal('anonymous');
392 | });
393 |
394 | it('should properly detect cross-origin requests (#4)', () => {
395 | expect(res._determineCrossOrigin(
396 | 'https://google.com',
397 | { hostname: 'www.google.com', port: '123', protocol: 'https:' }
398 | )).to.equal('anonymous');
399 | });
400 | it('should properly detect cross-origin requests (#5) - sandboxed iframe', () => {
401 | const originalOrigin = window.origin;
402 |
403 | // Set origin to 'null' to simulate sandboxed iframe without 'allow-same-origin' attribute
404 | window.origin = 'null';
405 | expect(res._determineCrossOrigin(
406 | 'http://www.google.com:5678',
407 | { hostname: 'www.google.com', port: '5678', protocol: 'http:' }
408 | )).to.equal('anonymous');
409 | // Restore origin to prevent test leakage.
410 | window.origin = originalOrigin;
411 | });
412 | });
413 |
414 | describe('#_getExtension', () => {
415 | it('should return the proper extension', () => {
416 | let url;
417 |
418 | url = 'http://www.google.com/image.png';
419 | expect(Loader.getExtension(url)).to.equal('png');
420 |
421 | url = 'http://domain.net/really/deep/path/that/goes/for/a/while/movie.wmv';
422 | expect(Loader.getExtension(url)).to.equal('wmv');
423 |
424 | url = 'http://somewhere.io/path.with.dots/and_a-bunch_of.symbols/data.txt';
425 | expect(Loader.getExtension(url)).to.equal('txt');
426 |
427 | url = 'http://nowhere.me/image.jpg?query=true&string=false&name=real';
428 | expect(Loader.getExtension(url)).to.equal('jpg');
429 |
430 | url = 'http://nowhere.me/image.jpeg?query=movie.wmv&file=data.json';
431 | expect(Loader.getExtension(url)).to.equal('jpeg');
432 |
433 | url = 'http://nowhere.me/image.jpeg?query=movie.wmv&file=data.json';
434 | expect(Loader.getExtension(url)).to.equal('jpeg');
435 |
436 | url = 'http://nowhere.me/image.jpeg?query=movie.wmv&file=data.json#/derp.mp3';
437 | expect(Loader.getExtension(url)).to.equal('jpeg');
438 |
439 | url = 'http://nowhere.me/image.jpeg?query=movie.wmv&file=data.json#/derp.mp3&?me=two';
440 | expect(Loader.getExtension(url)).to.equal('jpeg');
441 |
442 | url = 'http://nowhere.me/image.jpeg#nothing-to-see-here?query=movie.wmv&file=data.json#/derp.mp3&?me=two'; // eslint-disable-line max-len
443 | expect(Loader.getExtension(url)).to.equal('jpeg');
444 |
445 | url = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAMSURBVBhXY2BgYAAAAAQAAVzN/2kAAAAASUVORK5CYII='; // eslint-disable-line max-len
446 | expect(Loader.getExtension(url)).to.equal('png');
447 | });
448 | });
449 | });
450 |
--------------------------------------------------------------------------------
/src/Loader.ts:
--------------------------------------------------------------------------------
1 | import parseUri from 'parse-uri';
2 | import { Signal } from 'type-signals';
3 | import { AsyncQueue } from './async/AsyncQueue';
4 | import { Resource, IResourceOptions } from './Resource';
5 | import { eachSeries } from './async/eachSeries';
6 |
7 | // some constants
8 | const MAX_PROGRESS = 100;
9 | const rgxExtractUrlHash = /(#[\w-]+)?$/;
10 |
11 | /**
12 | * @category Type Aliases
13 | */
14 | export namespace Loader
15 | {
16 | export type ResourceMap = Partial>;
17 |
18 | export type OnProgressSignal = (loader: Loader, resource: Resource) => void;
19 | export type OnErrorSignal = (errMessage: string, loader: Loader, resource: Resource) => void;
20 | export type OnLoadSignal = (loader: Loader, resource: Resource) => void;
21 | export type OnStartSignal = (loader: Loader) => void;
22 | export type OnCompleteSignal = (loader: Loader, resources: ResourceMap) => void;
23 |
24 | export type MiddlewareFn = (resource: Resource, next: () => void) => void;
25 | export type UrlResolverFn = (url: string, parsed: ReturnType) => string;
26 | }
27 |
28 | interface Middleware
29 | {
30 | fn: Loader.MiddlewareFn;
31 | priority: number;
32 | }
33 |
34 | /**
35 | * Options for a call to `.add()`.
36 | */
37 | export interface IAddOptions extends IResourceOptions
38 | {
39 | // Extra values to be used by specific load strategies.
40 | [key: string]: any;
41 |
42 | // The url to load the resource from.
43 | url: string;
44 |
45 | // A base url to use for just this resource load.
46 | baseUrl?: string;
47 |
48 | // The name of the resource to load, if not passed the url is used.
49 | name?: string;
50 |
51 | // Callback to add an an onComplete signal istener.
52 | onComplete?: Resource.OnCompleteSignal;
53 |
54 | // Parent resource this newly added resource is a child of.
55 | parentResource?: Resource;
56 | }
57 |
58 | /**
59 | * Manages the state and loading of multiple resources to load.
60 | * @preferred
61 | */
62 | export class Loader
63 | {
64 | /**
65 | * The default middleware priority (50).
66 | */
67 | static readonly DefaultMiddlewarePriority = 50;
68 |
69 | /**
70 | * The progress percent of the loader going through the queue.
71 | */
72 | progress = 0;
73 |
74 | /**
75 | * Loading state of the loader, true if it is currently loading resources.
76 | */
77 | loading = false;
78 |
79 | /**
80 | * A querystring to append to every URL added to the loader.
81 | *
82 | * This should be a valid query string *without* the question-mark (`?`). The loader will
83 | * also *not* escape values for you. Make sure to escape your parameters with
84 | * [`encodeURIComponent`](https://mdn.io/encodeURIComponent) before assigning this property.
85 | *
86 | * @example
87 | * const loader = new Loader();
88 | *
89 | * loader.defaultQueryString = 'user=me&password=secret';
90 | *
91 | * // This will request 'image.png?user=me&password=secret'
92 | * loader.add('image.png').load();
93 | *
94 | * loader.reset();
95 | *
96 | * // This will request 'image.png?v=1&user=me&password=secret'
97 | * loader.add('iamge.png?v=1').load();
98 | */
99 | defaultQueryString = '';
100 |
101 | /**
102 | * All the resources for this loader keyed by name, or URL if no name was given.
103 | */
104 | resources: Loader.ResourceMap = {};
105 |
106 | /**
107 | * Dispatched once per errored resource.
108 | */
109 | readonly onError: Signal = new Signal();
110 |
111 | /**
112 | * Dispatched once per loaded resource.
113 | */
114 | readonly onLoad: Signal = new Signal();
115 |
116 | /**
117 | * Dispatched when the loader begins to process the queue.
118 | */
119 | readonly onStart: Signal = new Signal();
120 |
121 | /**
122 | * Dispatched when the queued resources all load.
123 | */
124 | readonly onComplete: Signal = new Signal();
125 |
126 | /**
127 | * Dispatched once per loaded or errored resource.
128 | */
129 | readonly onProgress: Signal = new Signal();
130 |
131 | /**
132 | * The base url for all resources loaded by this loader.
133 | */
134 | private _baseUrl = '';
135 |
136 | /**
137 | * The internal list of URL resolver functions called within `_prepareUrl`.
138 | */
139 | private _urlResolvers: Loader.UrlResolverFn[] = [];
140 |
141 | /**
142 | * The middleware to run after loading each resource.
143 | */
144 | private _middleware: Middleware[] = [];
145 |
146 | /**
147 | * The tracks the resources we are currently completing parsing for.
148 | */
149 | private _resourcesParsing: Resource[] = [];
150 |
151 | /**
152 | * The `_loadResource` function bound with this object context.
153 | */
154 | private _boundLoadResource = this._loadResource.bind(this);
155 |
156 | /**
157 | * The resources waiting to be loaded.
158 | */
159 | private _queue: AsyncQueue;
160 |
161 | /**
162 | * @param baseUrl The base url for all resources loaded by this loader.
163 | * @param concurrency The number of resources to load concurrently.
164 | */
165 | constructor(baseUrl = '', concurrency = 10)
166 | {
167 | this.baseUrl = baseUrl;
168 |
169 | this._queue = new AsyncQueue(this._boundLoadResource, concurrency);
170 | this._queue.pause();
171 |
172 | // Add default middleware. This is already sorted so no need to do that again.
173 | this._middleware = Loader._defaultMiddleware.slice();
174 | }
175 |
176 | /**
177 | * The base url for all resources loaded by this loader.
178 | * Any trailing slashes are trimmed off.
179 | */
180 | get baseUrl(): string { return this._baseUrl; }
181 |
182 | set baseUrl(url: string)
183 | {
184 | while (url.length && url.charAt(url.length - 1) === '/')
185 | {
186 | url = url.slice(0, -1);
187 | }
188 |
189 | this._baseUrl = url;
190 | }
191 |
192 | /**
193 | * Adds a resource (or multiple resources) to the loader queue.
194 | *
195 | * This function can take a wide variety of different parameters. The only thing that is always
196 | * required the url to load. All the following will work:
197 | *
198 | * ```js
199 | * loader
200 | * // name & url param syntax
201 | * .add('http://...')
202 | * .add('key', 'http://...')
203 | *
204 | * // object syntax
205 | * .add({
206 | * name: 'key3',
207 | * url: 'http://...',
208 | * onComplete: function () {},
209 | * })
210 | *
211 | * // you can also pass an array of objects or urls or both
212 | * .add([
213 | * { name: 'key4', url: 'http://...', onComplete: function () {} },
214 | * { url: 'http://...', onComplete: function () {} },
215 | * 'http://...'
216 | * ])
217 | * ```
218 | */
219 | add(url: string): this;
220 | add(name: string, url: string): this;
221 | add(options: IAddOptions): this;
222 | add(resources: (IAddOptions|string)[]): this;
223 | add(options: string | IAddOptions | (string | IAddOptions)[], url_?: string): this
224 | {
225 | // An array is a resource list.
226 | if (Array.isArray(options))
227 | {
228 | for (let i = 0; i < options.length; ++i)
229 | {
230 | // can be string or IAddOptions, but either one is fine to pass
231 | // as a param alone. The type assertion is just to appease TS.
232 | this.add(options[i] as string);
233 | }
234 |
235 | return this;
236 | }
237 |
238 | let url = '';
239 | let name = '';
240 | let baseUrl = this._baseUrl;
241 | let resOptions: IAddOptions = { url: '' };
242 |
243 | if (typeof options === 'object')
244 | {
245 | url = options.url;
246 | name = options.name || options.url;
247 | baseUrl = options.baseUrl || baseUrl;
248 | resOptions = options;
249 | }
250 | else
251 | {
252 | name = options;
253 |
254 | if (typeof url_ === 'string')
255 | url = url_;
256 | else
257 | url = name;
258 | }
259 |
260 | if (!url)
261 | throw new Error('You must specify the `url` property.');
262 |
263 | // if loading already you can only add resources that have a parent.
264 | if (this.loading && !resOptions.parentResource)
265 | {
266 | throw new Error('Cannot add root resources while the loader is running.');
267 | }
268 |
269 | // check if resource already exists.
270 | if (this.resources[name])
271 | {
272 | throw new Error(`Resource named "${name}" already exists.`);
273 | }
274 |
275 | // add base url if this isn't an absolute url
276 | url = this._prepareUrl(url, baseUrl);
277 | resOptions.url = url;
278 |
279 | const resource = new Resource(name, resOptions);
280 |
281 | this.resources[name] = resource;
282 |
283 | if (typeof resOptions.onComplete === 'function')
284 | {
285 | resource.onAfterMiddleware.once(resOptions.onComplete);
286 | }
287 |
288 | // if actively loading, make sure to adjust progress chunks for that parent and its children
289 | if (this.loading)
290 | {
291 | const parent = resOptions.parentResource!;
292 | const incompleteChildren: Resource[] = [];
293 |
294 | for (let i = 0; i < parent.children.length; ++i)
295 | {
296 | if (!parent.children[i].isComplete)
297 | {
298 | incompleteChildren.push(parent.children[i]);
299 | }
300 | }
301 |
302 | const fullChunk = parent.progressChunk * (incompleteChildren.length + 1); // +1 for parent
303 | const eachChunk = fullChunk / (incompleteChildren.length + 2); // +2 for parent & new child
304 |
305 | parent.children.push(resource);
306 | parent.progressChunk = eachChunk;
307 |
308 | for (let i = 0; i < incompleteChildren.length; ++i)
309 | {
310 | incompleteChildren[i].progressChunk = eachChunk;
311 | }
312 |
313 | resource.progressChunk = eachChunk;
314 | }
315 |
316 | // add the resource to the queue
317 | this._queue.push(resource);
318 |
319 | return this;
320 | }
321 |
322 | /**
323 | * Sets up a middleware function that will run *after* the
324 | * resource is loaded.
325 | *
326 | * You can optionally specify a priority for this middleware
327 | * which will determine the order middleware functions are run.
328 | * A lower priority value will make the function run earlier.
329 | * That is, priority 30 is run before priority 50.
330 | */
331 | use(fn: Loader.MiddlewareFn, priority: number = Loader.DefaultMiddlewarePriority): this
332 | {
333 | this._middleware.push({ fn, priority });
334 | this._middleware.sort((a, b) => a.priority - b.priority);
335 | return this;
336 | }
337 |
338 | /**
339 | * Resets the queue of the loader to prepare for a new load.
340 | */
341 | reset(): this
342 | {
343 | this.progress = 0;
344 | this.loading = false;
345 |
346 | this._queue.reset();
347 | this._queue.pause();
348 |
349 | // abort all resource loads
350 | for (const k in this.resources)
351 | {
352 | const res = this.resources[k];
353 |
354 | if (!res)
355 | continue;
356 |
357 | if (res._onCompleteBinding)
358 | res._onCompleteBinding.detach();
359 |
360 | if (res.isLoading)
361 | res.abort();
362 | }
363 |
364 | this.resources = {};
365 |
366 | return this;
367 | }
368 |
369 | /**
370 | * Starts loading the queued resources.
371 | */
372 | load(cb?: Loader.OnCompleteSignal): this
373 | {
374 | if (typeof cb === 'function')
375 | this.onComplete.once(cb);
376 |
377 | // if the queue has already started we are done here
378 | if (this.loading)
379 | return this;
380 |
381 | if (this._queue.idle())
382 | {
383 | this._onStart();
384 | this._onComplete();
385 | }
386 | else
387 | {
388 | // distribute progress chunks
389 | const numTasks = this._queue.length();
390 | const chunk = MAX_PROGRESS / numTasks;
391 |
392 | for (let i = 0; i < this._queue.length(); ++i)
393 | {
394 | this._queue.getTask(i).data.progressChunk = chunk;
395 | }
396 |
397 | // notify we are starting
398 | this._onStart();
399 |
400 | // start loading
401 | this._queue.resume();
402 | }
403 |
404 | return this;
405 | }
406 |
407 | /**
408 | * The number of resources to load concurrently.
409 | */
410 | get concurrency(): number
411 | {
412 | return this._queue.concurrency;
413 | }
414 |
415 | set concurrency(concurrency)
416 | {
417 | this._queue.concurrency = concurrency;
418 | }
419 |
420 | /**
421 | * Add a function that can be used to modify the url just prior
422 | * to `baseUrl` and `defaultQueryString` being applied.
423 | */
424 | addUrlResolver(func: Loader.UrlResolverFn): this
425 | {
426 | this._urlResolvers.push(func);
427 | return this;
428 | }
429 |
430 | /**
431 | * Prepares a url for usage based on the configuration of this object
432 | */
433 | private _prepareUrl(url: string, baseUrl: string): string
434 | {
435 | let parsed = parseUri(url, { strictMode: true });
436 |
437 | this._urlResolvers.forEach(resolver => {
438 | url = resolver(url, parsed);
439 | parsed = parseUri(url, { strictMode: true });
440 | });
441 |
442 | // Only add `baseUrl` for urls that are not absolute.
443 | if (!parsed.protocol && url.indexOf('//') !== 0)
444 | {
445 | // if the url doesn't start with a slash, then add one inbetween.
446 | if (baseUrl.length && url.charAt(0) !== '/')
447 | url = `${baseUrl}/${url}`;
448 | else
449 | url = baseUrl + url;
450 | }
451 |
452 | // if we need to add a default querystring, there is a bit more work
453 | if (this.defaultQueryString)
454 | {
455 | const match = rgxExtractUrlHash.exec(url);
456 |
457 | if (match)
458 | {
459 | const hash = match[0];
460 |
461 | url = url.substr(0, url.length - hash.length);
462 |
463 | if (url.indexOf('?') !== -1)
464 | url += `&${this.defaultQueryString}`;
465 | else
466 | url += `?${this.defaultQueryString}`;
467 |
468 | url += hash;
469 | }
470 | }
471 |
472 | return url;
473 | }
474 |
475 | /**
476 | * Loads a single resource.
477 | */
478 | private _loadResource(resource: Resource, dequeue: Function): void
479 | {
480 | resource._dequeue = dequeue;
481 | resource._onCompleteBinding = resource.onComplete.once(this._onLoad, this);
482 | resource.load();
483 | }
484 |
485 | /**
486 | * Called once loading has started.
487 | */
488 | private _onStart(): void
489 | {
490 | this.progress = 0;
491 | this.loading = true;
492 | this.onStart.dispatch(this);
493 | }
494 |
495 | /**
496 | * Called once each resource has loaded.
497 | */
498 | private _onComplete(): void
499 | {
500 | this.progress = MAX_PROGRESS;
501 | this.loading = false;
502 | this.onComplete.dispatch(this, this.resources);
503 | }
504 |
505 | /**
506 | * Called each time a resources is loaded.
507 | */
508 | private _onLoad(resource: Resource): void
509 | {
510 | resource._onCompleteBinding = null;
511 |
512 | // remove this resource from the async queue, and add it to our list
513 | // of resources that are being parsed
514 | this._resourcesParsing.push(resource);
515 | resource._dequeue();
516 |
517 | // run all the after middleware for this resource
518 | eachSeries(
519 | this._middleware,
520 | (middleware, next) =>
521 | {
522 | middleware.fn.call(this, resource, next);
523 | },
524 | () =>
525 | {
526 | resource.onAfterMiddleware.dispatch(resource);
527 |
528 | this.progress = Math.min(MAX_PROGRESS, this.progress + resource.progressChunk);
529 | this.onProgress.dispatch(this, resource);
530 |
531 | if (resource.error)
532 | this.onError.dispatch(resource.error, this, resource);
533 | else
534 | this.onLoad.dispatch(this, resource);
535 |
536 | this._resourcesParsing.splice(this._resourcesParsing.indexOf(resource), 1);
537 |
538 | // do completion check
539 | if (this._queue.idle() && this._resourcesParsing.length === 0)
540 | this._onComplete();
541 | },
542 | true);
543 | }
544 |
545 | /**
546 | * A default array of middleware to run after loading each resource.
547 | * Each of these middlewares are added to any new Loader instances when they are created.
548 | */
549 | private static _defaultMiddleware: Middleware[] = [];
550 |
551 | /**
552 | * Sets up a middleware function that will run *after* the
553 | * resource is loaded.
554 | *
555 | * You can optionally specify a priority for this middleware
556 | * which will determine the order middleware functions are run.
557 | * A lower priority value will make the function run earlier.
558 | * That is, priority 30 is run before priority 50.
559 | */
560 | static use(fn: Loader.MiddlewareFn, priority = Loader.DefaultMiddlewarePriority): typeof Loader
561 | {
562 | Loader._defaultMiddleware.push({ fn, priority });
563 | Loader._defaultMiddleware.sort((a, b) => a.priority - b.priority);
564 | return Loader;
565 | }
566 | }
567 |
--------------------------------------------------------------------------------
/test/spec/Loader.test.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | describe('Loader', () => {
4 | let loader = null;
5 |
6 | beforeEach(() => {
7 | loader = new Loader(fixtureData.baseUrl);
8 | });
9 |
10 | describe('.use', () => {
11 | it('should add a middleware that runs after loading a resource', () => {
12 | Loader.use(() => { /* empty */ });
13 |
14 | expect(Loader._defaultMiddleware).to.have.length(1);
15 |
16 | loader = new Loader(fixtureData.baseUrl);
17 |
18 | expect(loader._middleware).to.have.length(1);
19 | });
20 |
21 | after(() => {
22 | Loader._defaultMiddleware.length = 0;
23 | });
24 | });
25 |
26 | it('should have exported correctly', () => {
27 | expect(Loader).to.have.property('DefaultMiddlewarePriority', 50);
28 |
29 | expect(Loader).to.have.property('AbstractLoadStrategy');
30 | expect(Loader).to.have.property('AudioLoadStrategy');
31 | expect(Loader).to.have.property('ImageLoadStrategy');
32 | expect(Loader).to.have.property('MediaElementLoadStrategy');
33 | expect(Loader).to.have.property('VideoLoadStrategy');
34 | expect(Loader).to.have.property('XhrLoadStrategy');
35 | expect(Loader).to.have.property('Resource');
36 | expect(Loader).to.have.property('ResourceType');
37 | expect(Loader).to.have.property('ResourceState');
38 | expect(Loader).to.have.property('async');
39 |
40 | expect(Loader).to.have.property('use');
41 | });
42 |
43 | it('should have correct properties', () => {
44 | expect(loader).to.have.property('baseUrl', fixtureData.baseUrl);
45 | expect(loader).to.have.property('progress', 0);
46 | expect(loader).to.have.property('concurrency', 10);
47 | expect(loader).to.have.property('loading', false);
48 | expect(loader).to.have.property('defaultQueryString', '');
49 | expect(loader).to.have.property('resources');
50 |
51 | expect(loader).to.have.property('onError');
52 | expect(loader).to.have.property('onLoad');
53 | expect(loader).to.have.property('onStart');
54 | expect(loader).to.have.property('onComplete');
55 | expect(loader).to.have.property('onProgress');
56 | });
57 |
58 | it('should have correct public methods', () => {
59 | expect(loader).to.have.property('add').instanceOf(Function);
60 | expect(loader).to.have.property('use').instanceOf(Function);
61 | expect(loader).to.have.property('reset').instanceOf(Function);
62 | expect(loader).to.have.property('load').instanceOf(Function);
63 | });
64 |
65 | describe('#baseUrl', () => {
66 | it('trims trailing slashes', () => {
67 | loader.baseUrl = '/a/b/';
68 | expect(loader.baseUrl).to.equal('/a/b');
69 |
70 | loader.baseUrl = '/c/d';
71 | expect(loader.baseUrl).to.equal('/c/d');
72 | });
73 | });
74 |
75 | describe('#addUrlResolver', () => {
76 | it('calls addUrlResolver', () => {
77 | const spy = sinon.spy();
78 |
79 | loader.addUrlResolver(() => { spy(); return ''; });
80 | loader._prepareUrl('', '');
81 |
82 | expect(spy).to.have.been.calledOnce;
83 | });
84 |
85 | it('uses the result of addUrlResolver', () => {
86 | loader.addUrlResolver((s) => s.replace('{token}', 'test'));
87 |
88 | const s = loader._prepareUrl('/{token}/', '/some/base/url');
89 |
90 | expect(s).to.equal('/some/base/url/test/');
91 | });
92 |
93 | it('calls multiple urlResolver, in order', () => {
94 | const spy1 = sinon.spy(s => s + '/foo');
95 | const spy2 = sinon.spy(s => s + '/bar');
96 |
97 | loader.addUrlResolver(spy1)
98 | .addUrlResolver(spy2);
99 |
100 | const s = loader._prepareUrl('init', '');
101 |
102 | expect(spy1).to.have.been.calledOnce;
103 | expect(spy2).to.have.been.calledOnce;
104 | expect(s).to.equal('init/foo/bar');
105 | });
106 |
107 | it('supports multiple functions as urlResolver', () => {
108 | loader.addUrlResolver((s) => s.replace('{token}', 'foo'))
109 | .addUrlResolver((s) => s.replace('{token2}', 'bar'));
110 |
111 | const s = loader._prepareUrl('/{token}/{token2}/', '/some/base/url');
112 |
113 | expect(s).to.equal('/some/base/url/foo/bar/');
114 | });
115 | });
116 |
117 | describe('#add', () => {
118 | const name = 'test-resource';
119 | const options = {
120 | crossOrigin: 'anonymous',
121 | };
122 |
123 | function callback() { /* empty */ }
124 |
125 | function checkResource(res, checks = {})
126 | {
127 | expect(res).to.be.an.instanceOf(Resource);
128 | expect(res).to.have.property('name', checks.name || checks.url || name);
129 | expect(res).to.have.property('url', checks.url || fixtureData.url);
130 | expect(res).to.have.property('_strategy').that.is.an.instanceOf(checks.strategy || Loader.XhrLoadStrategy);
131 |
132 | const co = typeof checks.crossOrigin === 'string'
133 | ? checks.crossOrigin
134 | : options.crossOrigin;
135 |
136 | expect(res._strategy.config).to.have.property('crossOrigin', co);
137 | }
138 |
139 | it('creates a resource using overload: (url)', () => {
140 | loader.add(fixtureData.url);
141 |
142 | expect(loader._queue.length()).to.equal(1);
143 |
144 | const res = loader._queue._tasks[0].data;
145 |
146 | checkResource(res, { name: fixtureData.url });
147 |
148 | expect(res.onAfterMiddleware.handlers())
149 | .to.be.empty;
150 | });
151 |
152 | it('creates a resource using overload (name, url)', () => {
153 | loader.add(name, fixtureData.url);
154 |
155 | expect(loader._queue.length()).to.equal(1);
156 |
157 | const res = loader._queue._tasks[0].data;
158 |
159 | checkResource(res);
160 |
161 | expect(res.onAfterMiddleware.handlers())
162 | .to.be.empty;
163 | });
164 |
165 | it('creates a resource using overload ({ url})', () => {
166 | loader.add({ url: fixtureData.url });
167 |
168 | expect(loader._queue.length()).to.equal(1);
169 |
170 | const res = loader._queue._tasks[0].data;
171 |
172 | checkResource(res, { name: fixtureData.url });
173 |
174 | expect(res.onAfterMiddleware.handlers())
175 | .to.be.empty;
176 | });
177 |
178 | it('creates a resource using overload ({ name, url })', () => {
179 | loader.add({ name, url: fixtureData.url });
180 |
181 | expect(loader._queue.length()).to.equal(1);
182 |
183 | const res = loader._queue._tasks[0].data;
184 |
185 | checkResource(res);
186 |
187 | expect(res.onAfterMiddleware.handlers())
188 | .to.be.empty;
189 | });
190 |
191 | it('creates a resource using overload ({ name, url, onComplete })', () => {
192 | loader.add({ name, url: fixtureData.url, onComplete: callback });
193 |
194 | expect(loader._queue.length()).to.equal(1);
195 |
196 | const res = loader._queue._tasks[0].data;
197 |
198 | checkResource(res);
199 |
200 | expect(res.onAfterMiddleware.handlers())
201 | .to.not.be.empty
202 | .and.to.equal([callback]);
203 | });
204 |
205 | it('creates a resource using overload ({ url, onComplete })', () => {
206 | loader.add({ url: fixtureData.url, onComplete: callback });
207 |
208 | expect(loader._queue.length()).to.equal(1);
209 |
210 | const res = loader._queue._tasks[0].data;
211 |
212 | checkResource(res, { name: fixtureData.url });
213 |
214 | expect(res.onAfterMiddleware.handlers())
215 | .to.not.be.empty
216 | .and.to.equal([callback]);
217 | });
218 |
219 | it('creates two resources using overload ([url1, url2])', () => {
220 | loader.add([fixtureData.url, fixtureData.dataUrlGif]);
221 |
222 | expect(loader._queue.length()).to.equal(2);
223 |
224 | const res0 = loader._queue._tasks[0].data;
225 | checkResource(res0, { url: fixtureData.url });
226 |
227 | expect(res0.onAfterMiddleware.handlers())
228 | .to.be.empty;
229 |
230 | const res1 = loader._queue._tasks[1].data;
231 | checkResource(res1, {
232 | url: fixtureData.dataUrlGif,
233 | strategy: Loader.ImageLoadStrategy,
234 | crossOrigin: '',
235 | });
236 |
237 | expect(res1.onAfterMiddleware.handlers())
238 | .to.be.empty;
239 | });
240 |
241 | it('throws an error if url isn\'t passed', () => {
242 | expect(loader.add).to.throw(Error);
243 | expect(() => loader.add(options)).to.throw(Error);
244 | expect(() => loader.add(callback)).to.throw(Error);
245 | expect(() => loader.add(options, callback)).to.throw(Error);
246 | });
247 |
248 | it('throws an error if we are already loading and you have no parent resource', () => {
249 | loader.add(fixtureData.url);
250 |
251 | loader.load();
252 |
253 | expect(() => loader.add(fixtureData.dataUrlGif)).to.throw(Error);
254 | });
255 | });
256 |
257 | describe('#use', () => {
258 | it('should add a middleware that runs after loading a resource', () => {
259 | loader.use(() => { /* empty */ });
260 |
261 | expect(loader._middleware).to.have.length(1);
262 | });
263 | });
264 |
265 | describe('#reset', () => {
266 | it('should reset the loading state of the loader', () => {
267 | loader.loading = true;
268 | expect(loader.loading).to.equal(true);
269 |
270 | loader.reset();
271 | expect(loader.loading).to.equal(false);
272 | });
273 |
274 | it('should reset the progress of the loader', () => {
275 | loader.progress = 100;
276 | expect(loader.progress).to.equal(100);
277 |
278 | loader.reset();
279 | expect(loader.progress).to.equal(0);
280 | });
281 |
282 | it('should reset the queue/buffer of the loader', () => {
283 | loader._queue.push('me');
284 | expect(loader._queue.length()).to.equal(1);
285 | expect(loader._queue.started).to.equal(true);
286 |
287 | loader.reset();
288 | expect(loader._queue.length()).to.equal(0);
289 | expect(loader._queue.started).to.equal(false);
290 | });
291 |
292 | it('should reset the resources of the loader', () => {
293 | loader.add(fixtureData.url);
294 | expect(loader.resources).to.not.be.empty;
295 |
296 | loader.reset();
297 | expect(loader.resources).to.be.empty;
298 | });
299 |
300 | it('with unloaded items continues to work', (done) => {
301 | const loader = new Loader(fixtureData.baseUrl, 2);
302 |
303 | loader.add(['hud.png', 'hud2.png', 'hud.json']).load();
304 |
305 | setTimeout(() => {
306 | const spy = sinon.spy();
307 |
308 | loader.reset();
309 | loader.add({ url: 'hud2.json', onComplete: spy }).load(() => {
310 | expect(spy).to.have.been.calledOnce;
311 | done();
312 | });
313 | }, 0);
314 | });
315 | });
316 |
317 | describe('#load', () => {
318 | it('should call start/complete when add was not called', (done) => {
319 | const spy = sinon.spy();
320 | const spy2 = sinon.spy();
321 |
322 | loader.onStart.add(spy);
323 | loader.onComplete.add(spy2);
324 |
325 | loader.load(() => {
326 | expect(spy).to.have.been.calledOnce;
327 | expect(spy2).to.have.been.calledOnce;
328 | done();
329 | });
330 | });
331 |
332 | it('should call start/complete when given an empty set of resources', (done) => {
333 | const spy = sinon.spy();
334 | const spy2 = sinon.spy();
335 |
336 | loader.onStart.add(spy);
337 | loader.onComplete.add(spy2);
338 |
339 | loader.add([]).load(() => {
340 | expect(spy).to.have.been.calledOnce;
341 | expect(spy2).to.have.been.calledOnce;
342 | done();
343 | });
344 | });
345 |
346 | it('should run middleware, after loading a resource', (done) => {
347 | const callOrder = [];
348 | const spy1 = sinon.spy((res, next) => { callOrder.push(1); next(); });
349 | const spy2 = sinon.spy((res, next) => { callOrder.push(2); next(); });
350 |
351 | loader.use(spy1);
352 | loader.use(spy2);
353 |
354 | loader.add(fixtureData.dataUrlGif);
355 |
356 | loader.load(() => {
357 | expect(callOrder).to.eql([1, 2]);
358 | expect(spy1).to.have.been.calledOnce;
359 | expect(spy2).to.have.been.calledOnce;
360 | done();
361 | });
362 | });
363 |
364 | it('should run middleware in priority order, after loading a resource', (done) => {
365 | const callOrder = [];
366 | const spy1 = sinon.spy((res, next) => { callOrder.push(1); next(); });
367 | const spy2 = sinon.spy((res, next) => { callOrder.push(2); next(); });
368 |
369 | loader.use(spy1);
370 | loader.use(spy2, 40);
371 |
372 | loader.add(fixtureData.dataUrlGif);
373 |
374 | loader.load(() => {
375 | expect(callOrder).to.eql([2, 1]);
376 | expect(spy1).to.have.been.calledOnce;
377 | expect(spy2).to.have.been.calledOnce;
378 | done();
379 | });
380 | });
381 |
382 | it('should properly load the resource', (done) => {
383 | const spy = sinon.spy((loader, resources) => {
384 | expect(spy).to.have.been.calledOnce;
385 | expect(loader.progress).to.equal(100);
386 | expect(loader.loading).to.equal(false);
387 | expect(loader.resources).to.equal(resources);
388 |
389 | expect(resources).to.not.be.empty;
390 | expect(resources.res).to.be.ok;
391 | expect(resources.res.isComplete).to.be.true;
392 |
393 | done();
394 | });
395 |
396 | loader.add('res', fixtureData.dataUrlGif);
397 |
398 | loader.load(spy);
399 | });
400 | });
401 |
402 | describe('#_prepareUrl', () => {
403 | it('should return the url as-is for absolute urls', () => {
404 | const u1 = 'http://domain.com/image.png';
405 | const u2 = 'https://domain.com';
406 | const u3 = '//myshare/image.png';
407 | const u4 = '//myshare/image.png?v=1#me';
408 |
409 | expect(loader._prepareUrl(u1, loader.baseUrl)).to.equal(u1);
410 | expect(loader._prepareUrl(u2, loader.baseUrl)).to.equal(u2);
411 | expect(loader._prepareUrl(u3, loader.baseUrl)).to.equal(u3);
412 | expect(loader._prepareUrl(u4, loader.baseUrl)).to.equal(u4);
413 | });
414 |
415 | it('should add the baseUrl for relative urls', () => {
416 | const b = fixtureData.baseUrl;
417 | const u1 = 'image.png';
418 | const u2 = '/image.png';
419 | const u3 = 'image.png?v=1';
420 | const u4 = '/image.png?v=1#me';
421 |
422 | expect(loader._prepareUrl(u1, loader.baseUrl)).to.equal(`${b}/${u1}`);
423 | expect(loader._prepareUrl(u2, loader.baseUrl)).to.equal(`${b}${u2}`);
424 | expect(loader._prepareUrl(u3, loader.baseUrl)).to.equal(`${b}/${u3}`);
425 | expect(loader._prepareUrl(u4, loader.baseUrl)).to.equal(`${b}${u4}`);
426 | });
427 |
428 | it('should add the queryString when set', () => {
429 | const b = fixtureData.baseUrl;
430 | const u1 = 'image.png';
431 | const u2 = '/image.png';
432 |
433 | loader.defaultQueryString = 'u=me&p=secret';
434 |
435 | expect(loader._prepareUrl(u1, loader.baseUrl))
436 | .to.equal(`${b}/${u1}?${loader.defaultQueryString}`);
437 |
438 | expect(loader._prepareUrl(u2, loader.baseUrl))
439 | .to.equal(`${b}${u2}?${loader.defaultQueryString}`);
440 | });
441 |
442 | it('should add the defaultQueryString when set', () => {
443 | const b = fixtureData.baseUrl;
444 | const u1 = 'image.png';
445 | const u2 = '/image.png';
446 |
447 | loader.defaultQueryString = 'u=me&p=secret';
448 |
449 | expect(loader._prepareUrl(u1, loader.baseUrl))
450 | .to.equal(`${b}/${u1}?${loader.defaultQueryString}`);
451 |
452 | expect(loader._prepareUrl(u2, loader.baseUrl))
453 | .to.equal(`${b}${u2}?${loader.defaultQueryString}`);
454 | });
455 |
456 | it('should add the defaultQueryString when if querystring already exists', () => {
457 | const b = fixtureData.baseUrl;
458 | const u1 = 'image.png?v=1';
459 |
460 | loader.defaultQueryString = 'u=me&p=secret';
461 |
462 | expect(loader._prepareUrl(u1, loader.baseUrl))
463 | .to.equal(`${b}/${u1}&${loader.defaultQueryString}`);
464 | });
465 |
466 | it('should add the defaultQueryString when hash exists', () => {
467 | const b = fixtureData.baseUrl;
468 |
469 | loader.defaultQueryString = 'u=me&p=secret';
470 |
471 | expect(loader._prepareUrl('/image.png#me', loader.baseUrl))
472 | .to.equal(`${b}/image.png?${loader.defaultQueryString}#me`);
473 | });
474 |
475 | it('should add the defaultQueryString when querystring and hash exists', () => {
476 | const b = fixtureData.baseUrl;
477 |
478 | loader.defaultQueryString = 'u=me&p=secret';
479 |
480 | expect(loader._prepareUrl('/image.png?v=1#me', loader.baseUrl))
481 | .to.equal(`${b}/image.png?v=1&${loader.defaultQueryString}#me`);
482 | });
483 | });
484 |
485 | describe('#_loadResource', () => {
486 | it('should load a resource passed into it', () => {
487 | const res = new Loader.Resource('mock', { url: fixtureData.url });
488 |
489 | res.load = sinon.spy();
490 |
491 | loader._loadResource(res);
492 |
493 | expect(res.load).to.have.been.calledOnce;
494 | });
495 | });
496 |
497 | describe('#_onStart', () => {
498 | it('should emit the `start` event', (done) => {
499 | loader.onStart.add((_l) => {
500 | expect(_l).to.equal(loader);
501 |
502 | done();
503 | });
504 |
505 | loader._onStart();
506 | });
507 | });
508 |
509 | describe('#_onComplete', () => {
510 | it('should emit the `complete` event', (done) => {
511 | loader.onComplete.add((_l, resources) => {
512 | expect(_l).to.equal(loader);
513 | expect(resources).to.equal(loader.resources);
514 |
515 | done();
516 | });
517 |
518 | loader._onComplete();
519 | });
520 | });
521 |
522 | describe('#_onLoad', () => {
523 | it('should emit the `progress` event', () => {
524 | const res = new Loader.Resource('mock', { url: fixtureData.url });
525 | const spy = sinon.spy();
526 |
527 | res._dequeue = sinon.spy();
528 |
529 | loader.onProgress.once(spy);
530 |
531 | loader._onLoad(res);
532 |
533 | expect(spy).to.have.been.calledOnce;
534 | });
535 |
536 | it('should emit the `error` event when the resource has an error', () => {
537 | const res = new Loader.Resource('mock', { url: fixtureData.url });
538 | const spy = sinon.spy();
539 |
540 | res._dequeue = sinon.spy();
541 |
542 | res.error = new Error('mock error');
543 |
544 | loader.onError.once(spy);
545 |
546 | loader._onLoad(res);
547 |
548 | expect(spy).to.have.been.calledOnce;
549 | });
550 |
551 | it('should emit the `load` event when the resource loads successfully', () => {
552 | const res = new Loader.Resource('mock', { url: fixtureData.url });
553 | const spy = sinon.spy();
554 |
555 | res._dequeue = sinon.spy();
556 |
557 | loader.onLoad.once(spy);
558 |
559 | loader._onLoad(res);
560 |
561 | expect(spy).to.have.been.calledOnce;
562 | });
563 |
564 | it('should run middleware', (done) => {
565 | const spy = sinon.spy();
566 | const res = {};
567 |
568 | res._dequeue = sinon.spy();
569 |
570 | loader.use(spy);
571 |
572 | loader._onLoad(res);
573 |
574 | setTimeout(() => {
575 | expect(spy).to.have.been.calledOnce
576 | .and.calledOn(loader)
577 | .and.calledWith(res);
578 |
579 | done();
580 | }, 16);
581 | });
582 | });
583 |
584 | describe('events', () => {
585 | describe('with no additional subresources', () => {
586 | it('should call progress for each loaded asset', (done) => {
587 | loader.add([
588 | { name: 'hud', url: 'hud.png' },
589 | { name: 'hud2', url: 'hud2.png' },
590 | ]);
591 |
592 | const spy = sinon.spy();
593 |
594 | loader.onProgress.add(spy);
595 |
596 | loader.load(() => {
597 | expect(spy).to.have.been.calledTwice;
598 | done();
599 | });
600 | });
601 |
602 | it('should call progress for each loaded asset, even with low concurrency', (done) => {
603 | const loader = new Loader(fixtureData.baseUrl, 1);
604 |
605 | loader.add([
606 | { name: 'hud', url: 'hud.png' },
607 | { name: 'hud2', url: 'hud2.png' },
608 | ]);
609 |
610 | const spy = sinon.spy();
611 |
612 | loader.onProgress.add(spy);
613 |
614 | loader.load(() => {
615 | expect(spy).to.have.been.calledTwice;
616 | done();
617 | });
618 | });
619 |
620 | it('should never have an invalid progress value', (done) => {
621 | const total = 7;
622 | let i = 0;
623 |
624 | for (; i < total; i++) {
625 | loader.add([
626 | { name: `hud_${i}`, url: 'hud.png' },
627 | ]);
628 | }
629 | i = 0;
630 | loader.onProgress.add((loader) => {
631 | i++;
632 | expect(loader.progress).to.be.above(0);
633 | if (i === total) {
634 | expect(loader.progress).to.be.at.most(100);
635 | }
636 | else {
637 | expect(loader.progress).to.be.below(100);
638 | }
639 | });
640 |
641 | loader.load(() => {
642 | expect(loader).to.have.property('progress', 100);
643 | done();
644 | });
645 | });
646 |
647 | it('progress should be 100% on complete', (done) => {
648 | loader.add([
649 | { name: 'hud', url: 'hud.png' },
650 | { name: 'hud2', url: 'hud2.png' },
651 | ]);
652 |
653 | loader.load(() => {
654 | expect(loader).to.have.property('progress', 100);
655 | done();
656 | });
657 | });
658 | });
659 |
660 | describe('with one additional subresource', () => {
661 | it('should call progress for each loaded asset', (done) => {
662 | loader.add([
663 | { name: 'hud2', url: 'hud2.png' },
664 | { name: 'hud_atlas', url: 'hud.json' },
665 | ]);
666 |
667 | loader.use(spritesheetMiddleware());
668 |
669 | const spy = sinon.spy();
670 |
671 | loader.onProgress.add(spy);
672 |
673 | loader.load(() => {
674 | expect(spy).to.have.been.calledThrice;
675 | done();
676 | });
677 | });
678 |
679 | it('should call progress for each loaded asset, even with low concurrency', (done) => {
680 | const loader = new Loader(fixtureData.baseUrl, 1);
681 |
682 | loader.add([
683 | { name: 'hud2', url: 'hud2.png' },
684 | { name: 'hud_atlas', url: 'hud.json' },
685 | ]);
686 |
687 | loader.use(spritesheetMiddleware());
688 |
689 | const spy = sinon.spy();
690 |
691 | loader.onProgress.add(spy);
692 |
693 | loader.load(() => {
694 | expect(spy).to.have.been.calledThrice;
695 | done();
696 | });
697 | });
698 |
699 | it('should never have an invalid progress value', (done) => {
700 | loader.add([
701 | { name: 'hud2', url: 'hud2.png' },
702 | { name: 'hud_atlas', url: 'hud.json' },
703 | ]);
704 |
705 | loader.use(spritesheetMiddleware());
706 |
707 | const expectedProgressValues = [50, 75, 100];
708 | let i = 0;
709 |
710 | loader.onProgress.add((loader) => {
711 | expect(loader).to.have.property('progress', expectedProgressValues[i++]);
712 | });
713 |
714 | loader.load(() => {
715 | expect(loader).to.have.property('progress', 100);
716 | done();
717 | });
718 | });
719 |
720 | it('progress should be 100% on complete', (done) => {
721 | loader.add([
722 | { name: 'hud2', url: 'hud2.png' },
723 | { name: 'hud_atlas', url: 'hud.json' },
724 | ]);
725 |
726 | loader.use(spritesheetMiddleware());
727 |
728 | loader.load(() => {
729 | expect(loader).to.have.property('progress', 100);
730 | done();
731 | });
732 | });
733 | });
734 |
735 | describe('with multiple additional subresources', () => {
736 | it('should call progress for each loaded asset', (done) => {
737 | loader.add([
738 | { name: 'hud2', url: 'hud2.json' },
739 | { name: 'hud_atlas', url: 'hud.json' },
740 | ]);
741 |
742 | loader.use(spritesheetMiddleware());
743 |
744 | const spy = sinon.spy();
745 |
746 | loader.onProgress.add(spy);
747 |
748 | loader.load(() => {
749 | expect(spy).to.have.callCount(4);
750 | done();
751 | });
752 | });
753 |
754 | it('should never have an invalid progress value', (done) => {
755 | loader.add([
756 | { name: 'hud2', url: 'hud2.json' },
757 | { name: 'hud_atlas', url: 'hud.json' },
758 | ]);
759 |
760 | loader.use(spritesheetMiddleware());
761 |
762 | const expectedProgressValues = [25, 50, 75, 100];
763 | let i = 0;
764 |
765 | loader.onProgress.add((loader) => {
766 | expect(loader).to.have.property('progress', expectedProgressValues[i++]);
767 | });
768 |
769 | loader.load(() => {
770 | expect(loader).to.have.property('progress', 100);
771 | done();
772 | });
773 | });
774 |
775 | it('progress should be 100% on complete', (done) => {
776 | loader.add([
777 | { name: 'hud2', url: 'hud2.json' },
778 | { name: 'hud_atlas', url: 'hud.json' },
779 | ]);
780 |
781 | loader.use(spritesheetMiddleware());
782 |
783 | loader.load(() => {
784 | expect(loader).to.have.property('progress', 100);
785 | done();
786 | });
787 | });
788 | });
789 | });
790 | });
791 |
--------------------------------------------------------------------------------
/test/spec/async.test.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const AsyncQueue = Loader.async.AsyncQueue;
4 | const eachSeries = Loader.async.eachSeries;
5 |
6 | describe('async', () => {
7 | describe('queue', () => {
8 | it('basics', (done) => {
9 | const callOrder = [];
10 | const delays = [40, 20, 60, 20];
11 |
12 | // worker1: --1-4
13 | // worker2: -2---3
14 | // order of completion: 2,1,4,3
15 |
16 | const q = new AsyncQueue((task, callback) => {
17 | setTimeout(() => {
18 | callOrder.push(`process ${task}`);
19 | callback('error', 'arg');
20 | }, delays.shift());
21 | }, 2);
22 |
23 | q.push(1, (err, arg) => {
24 | expect(err).to.equal('error');
25 | expect(arg).to.equal('arg');
26 | expect(q.length()).to.equal(1);
27 | callOrder.push('callback 1');
28 | });
29 | q.push(2, (err, arg) => {
30 | expect(err).to.equal('error');
31 | expect(arg).to.equal('arg');
32 | expect(q.length()).to.equal(2);
33 | callOrder.push('callback 2');
34 | });
35 | q.push(3, (err, arg) => {
36 | expect(err).to.equal('error');
37 | expect(arg).to.equal('arg');
38 | expect(q.length()).to.equal(0);
39 | callOrder.push('callback 3');
40 | });
41 | q.push(4, (err, arg) => {
42 | expect(err).to.equal('error');
43 | expect(arg).to.equal('arg');
44 | expect(q.length()).to.equal(0);
45 | callOrder.push('callback 4');
46 | });
47 | expect(q.length()).to.equal(4);
48 | expect(q.concurrency).to.equal(2);
49 |
50 | q.onDrain.once(() => {
51 | expect(callOrder).to.eql([
52 | 'process 2', 'callback 2',
53 | 'process 1', 'callback 1',
54 | 'process 4', 'callback 4',
55 | 'process 3', 'callback 3',
56 | ]);
57 | expect(q.concurrency).to.equal(2);
58 | expect(q.length()).to.equal(0);
59 | done();
60 | });
61 | });
62 |
63 | it('default concurrency', (done) => {
64 | const callOrder = [];
65 | const delays = [40, 20, 60, 20];
66 |
67 | // order of completion: 1,2,3,4
68 |
69 | const q = new AsyncQueue((task, callback) => {
70 | setTimeout(() => {
71 | callOrder.push(`process ${task}`);
72 | callback('error', 'arg');
73 | }, delays.shift());
74 | });
75 |
76 | q.push(1, (err, arg) => {
77 | expect(err).to.equal('error');
78 | expect(arg).to.equal('arg');
79 | expect(q.length()).to.equal(3);
80 | callOrder.push('callback 1');
81 | });
82 | q.push(2, (err, arg) => {
83 | expect(err).to.equal('error');
84 | expect(arg).to.equal('arg');
85 | expect(q.length()).to.equal(2);
86 | callOrder.push('callback 2');
87 | });
88 | q.push(3, (err, arg) => {
89 | expect(err).to.equal('error');
90 | expect(arg).to.equal('arg');
91 | expect(q.length()).to.equal(1);
92 | callOrder.push('callback 3');
93 | });
94 | q.push(4, (err, arg) => {
95 | expect(err).to.equal('error');
96 | expect(arg).to.equal('arg');
97 | expect(q.length()).to.equal(0);
98 | callOrder.push('callback 4');
99 | });
100 | expect(q.length()).to.equal(4);
101 | expect(q.concurrency).to.equal(1);
102 |
103 | q.onDrain.once(() => {
104 | expect(callOrder).to.eql([
105 | 'process 1', 'callback 1',
106 | 'process 2', 'callback 2',
107 | 'process 3', 'callback 3',
108 | 'process 4', 'callback 4',
109 | ]);
110 | expect(q.concurrency).to.equal(1);
111 | expect(q.length()).to.equal(0);
112 | done();
113 | });
114 | });
115 |
116 | it('zero concurrency', (done) => {
117 | expect(() => {
118 | new AsyncQueue((task, callback) => {
119 | callback(null, task);
120 | }, 0);
121 | }).to.throw();
122 | done();
123 | });
124 |
125 | it('error propagation', (done) => {
126 | const results = [];
127 |
128 | const q = new AsyncQueue((task, callback) => {
129 | callback(task.name === 'foo' ? new Error('fooError') : null);
130 | }, 2);
131 |
132 | q.onDrain.once(() => {
133 | expect(results).to.eql(['bar', 'fooError']);
134 | done();
135 | });
136 |
137 | q.push({ name: 'bar' }, (err) => {
138 | if (err) {
139 | results.push('barError');
140 |
141 | return;
142 | }
143 |
144 | results.push('bar');
145 | });
146 |
147 | q.push({ name: 'foo' }, (err) => {
148 | if (err) {
149 | results.push('fooError');
150 |
151 | return;
152 | }
153 |
154 | results.push('foo');
155 | });
156 | });
157 |
158 | it('global error handler', (done) => {
159 | const results = [];
160 |
161 | const q = new AsyncQueue((task, callback) => {
162 | callback(task.name === 'foo' ? new Error('fooError') : null);
163 | }, 2);
164 |
165 | q.onError.add((error, task) => {
166 | expect(error).to.exist;
167 | expect(error.message).to.equal('fooError');
168 | expect(task.name).to.equal('foo');
169 | results.push('fooError');
170 | });
171 |
172 | q.onDrain.once(() => {
173 | expect(results).to.eql(['fooError', 'bar']);
174 | done();
175 | });
176 |
177 | q.push({ name: 'foo' });
178 |
179 | q.push({ name: 'bar' }, (err) => {
180 | expect(err).to.not.exist;
181 | results.push('bar');
182 | });
183 | });
184 |
185 | // The original queue implementation allowed the concurrency to be changed only
186 | // on the same event loop during which a task was added to the queue. This
187 | // test attempts to be a more robust test.
188 | // Start with a concurrency of 1. Wait until a leter event loop and change
189 | // the concurrency to 2. Wait again for a later loop then verify the concurrency
190 | // Repeat that one more time by chaning the concurrency to 5.
191 | it('changing concurrency', (done) => {
192 | const q = new AsyncQueue((task, callback) => {
193 | setTimeout(() => {
194 | callback();
195 | }, 10);
196 | }, 1);
197 |
198 | for (let i = 0; i < 50; ++i) {
199 | q.push('');
200 | }
201 |
202 | q.onDrain.once(() => {
203 | done();
204 | });
205 |
206 | setTimeout(() => {
207 | expect(q.concurrency).to.equal(1);
208 | q.concurrency = 2;
209 | setTimeout(() => {
210 | expect(q.running()).to.equal(2);
211 | q.concurrency = 5;
212 | setTimeout(() => {
213 | expect(q.running()).to.equal(5);
214 | }, 40);
215 | }, 40);
216 | }, 40);
217 | });
218 |
219 | it('push without callback', (done) => {
220 | const callOrder = [];
221 | const delays = [40, 20, 60, 20];
222 |
223 | // worker1: --1-4
224 | // worker2: -2---3
225 | // order of completion: 2,1,4,3
226 |
227 | const q = new AsyncQueue((task, callback) => {
228 | setTimeout(() => {
229 | callOrder.push(`process ${task}`);
230 | callback('error', 'arg');
231 | }, delays.shift());
232 | }, 2);
233 |
234 | q.push(1);
235 | q.push(2);
236 | q.push(3);
237 | q.push(4);
238 |
239 | q.onDrain.once(() => {
240 | expect(callOrder).to.eql([
241 | 'process 2',
242 | 'process 1',
243 | 'process 4',
244 | 'process 3',
245 | ]);
246 | done();
247 | });
248 | });
249 |
250 | it('push with non-function', (done) => {
251 | const q = new AsyncQueue(() => { /* empty */ }, 1);
252 |
253 | expect(() => {
254 | q.push({}, 1);
255 | }).to.throw();
256 | done();
257 | });
258 |
259 | it('unshift', (done) => {
260 | const queueOrder = [];
261 |
262 | const q = new AsyncQueue((task, callback) => {
263 | queueOrder.push(task);
264 | callback();
265 | }, 1);
266 |
267 | q.unshift(4);
268 | q.unshift(3);
269 | q.unshift(2);
270 | q.unshift(1);
271 |
272 | setTimeout(() => {
273 | expect(queueOrder).to.eql([1, 2, 3, 4]);
274 | done();
275 | }, 100);
276 | });
277 |
278 | it('too many callbacks', (done) => {
279 | const q = new AsyncQueue((task, callback) => {
280 | callback();
281 | expect(() => {
282 | callback();
283 | }).to.throw();
284 | done();
285 | }, 2);
286 |
287 | q.push(1);
288 | });
289 |
290 | it('idle', (done) => {
291 | const q = new AsyncQueue((task, callback) => {
292 | // Queue is busy when workers are running
293 | expect(q.idle()).to.equal(false);
294 | callback();
295 | }, 1);
296 |
297 | // Queue is idle before anything added
298 | expect(q.idle()).to.equal(true);
299 |
300 | q.unshift(4);
301 | q.unshift(3);
302 | q.unshift(2);
303 | q.unshift(1);
304 |
305 | // Queue is busy when tasks added
306 | expect(q.idle()).to.equal(false);
307 |
308 | q.onDrain.once(() => {
309 | // Queue is idle after drain
310 | expect(q.idle()).to.equal(true);
311 | done();
312 | });
313 | });
314 |
315 | it('pause', (done) => {
316 | const callOrder = [];
317 | const taskTimeout = 80;
318 | const pauseTimeout = taskTimeout * 2.5;
319 | const resumeTimeout = taskTimeout * 4.5;
320 | const tasks = [1, 2, 3, 4, 5, 6];
321 |
322 | const elapsed = (() => {
323 | const start = Date.now();
324 |
325 | return () => Math.round((Date.now() - start) / taskTimeout) * taskTimeout;
326 | })();
327 |
328 | const q = new AsyncQueue((task, callback) => {
329 | callOrder.push(`process ${task}`);
330 | callOrder.push(`timeout ${elapsed()}`);
331 | callback();
332 | });
333 |
334 | function pushTask() {
335 | const task = tasks.shift();
336 |
337 | if (!task) {
338 | return;
339 | }
340 |
341 | setTimeout(() => {
342 | q.push(task);
343 | pushTask();
344 | }, taskTimeout);
345 | }
346 | pushTask();
347 |
348 | setTimeout(() => {
349 | q.pause();
350 | expect(q.paused).to.equal(true);
351 | }, pauseTimeout);
352 |
353 | setTimeout(() => {
354 | q.resume();
355 | expect(q.paused).to.equal(false);
356 | }, resumeTimeout);
357 |
358 | setTimeout(() => {
359 | expect(callOrder).to.eql([
360 | 'process 1', `timeout ${taskTimeout}`,
361 | 'process 2', `timeout ${(taskTimeout * 2)}`,
362 | 'process 3', `timeout ${(taskTimeout * 5)}`,
363 | 'process 4', `timeout ${(taskTimeout * 5)}`,
364 | 'process 5', `timeout ${(taskTimeout * 5)}`,
365 | 'process 6', `timeout ${(taskTimeout * 6)}`,
366 | ]);
367 | done();
368 | }, (taskTimeout * tasks.length) + pauseTimeout + resumeTimeout);
369 | });
370 |
371 | it('pause in worker with concurrency', (done) => {
372 | const callOrder = [];
373 | const q = new AsyncQueue((task, callback) => {
374 | if (task.isLongRunning) {
375 | q.pause();
376 | setTimeout(() => {
377 | callOrder.push(task.id);
378 | q.resume();
379 | callback();
380 | }, 50);
381 | }
382 | else {
383 | callOrder.push(task.id);
384 | setTimeout(callback, 10);
385 | }
386 | }, 10);
387 |
388 | q.push({ id: 1, isLongRunning: true });
389 | q.push({ id: 2 });
390 | q.push({ id: 3 });
391 | q.push({ id: 4 });
392 | q.push({ id: 5 });
393 |
394 | q.onDrain.once(() => {
395 | expect(callOrder).to.eql([1, 2, 3, 4, 5]);
396 | done();
397 | });
398 | });
399 |
400 | it('pause with concurrency', (done) => {
401 | const callOrder = [];
402 | const taskTimeout = 40;
403 | const pauseTimeout = taskTimeout / 2;
404 | const resumeTimeout = taskTimeout * 2.75;
405 | const tasks = [1, 2, 3, 4, 5, 6];
406 |
407 | const elapsed = (() => {
408 | const start = Date.now();
409 |
410 | return () => Math.round((Date.now() - start) / taskTimeout) * taskTimeout;
411 | })();
412 |
413 | const q = new AsyncQueue((task, callback) => {
414 | setTimeout(() => {
415 | callOrder.push(`process ${task}`);
416 | callOrder.push(`timeout ${elapsed()}`);
417 | callback();
418 | }, taskTimeout);
419 | }, 2);
420 |
421 | for (let i = 0; i < tasks.length; ++i) {
422 | q.push(tasks[i]);
423 | }
424 |
425 | setTimeout(() => {
426 | q.pause();
427 | expect(q.paused).to.equal(true);
428 | }, pauseTimeout);
429 |
430 | setTimeout(() => {
431 | q.resume();
432 | expect(q.paused).to.equal(false);
433 | }, resumeTimeout);
434 |
435 | setTimeout(() => {
436 | expect(q.running()).to.equal(2);
437 | }, resumeTimeout + 10);
438 |
439 | setTimeout(() => {
440 | expect(callOrder).to.eql([
441 | 'process 1', `timeout ${taskTimeout}`,
442 | 'process 2', `timeout ${taskTimeout}`,
443 | 'process 3', `timeout ${(taskTimeout * 4)}`,
444 | 'process 4', `timeout ${(taskTimeout * 4)}`,
445 | 'process 5', `timeout ${(taskTimeout * 5)}`,
446 | 'process 6', `timeout ${(taskTimeout * 5)}`,
447 | ]);
448 | done();
449 | }, (taskTimeout * tasks.length) + pauseTimeout + resumeTimeout);
450 | });
451 |
452 | it('start paused', (done) => {
453 | const q = new AsyncQueue((task, callback) => {
454 | setTimeout(() => {
455 | callback();
456 | }, 40);
457 | }, 2);
458 |
459 | q.pause();
460 |
461 | q.push(1);
462 | q.push(2);
463 | q.push(3);
464 |
465 | setTimeout(() => {
466 | q.resume();
467 | }, 5);
468 |
469 | setTimeout(() => {
470 | expect(q._tasks.length).to.equal(1);
471 | expect(q.running()).to.equal(2);
472 | q.resume();
473 | }, 15);
474 |
475 | q.onDrain.once(() => {
476 | done();
477 | });
478 | });
479 |
480 | it('reset', (done) => {
481 | const q = new AsyncQueue((/* task, callback */) => {
482 | setTimeout(() => {
483 | throw new Error('Function should never be called');
484 | }, 20);
485 | }, 1);
486 |
487 | q.onDrain.once(() => {
488 | throw new Error('Function should never be called');
489 | });
490 |
491 | q.push(0);
492 |
493 | q.reset();
494 |
495 | setTimeout(() => {
496 | expect(q.length()).to.equal(0);
497 | done();
498 | }, 40);
499 | });
500 |
501 | it('events', (done) => {
502 | const calls = [];
503 | const q = new AsyncQueue((task, cb) => {
504 | // nop
505 | calls.push(`process ${task}`);
506 | setTimeout(cb, 10);
507 | }, 3);
508 |
509 | q.concurrency = 3;
510 |
511 | q.onSaturated.add(() => {
512 | expect(q.running()).to.equal(3, 'queue should be saturated now');
513 | calls.push('saturated');
514 | });
515 | q.onEmpty.add(() => {
516 | expect(q.length()).to.equal(0, 'queue should be empty now');
517 | calls.push('empty');
518 | });
519 | q.onDrain.once(() => {
520 | expect(q.length() === 0 && q.running() === 0)
521 | .to.equal(true, 'queue should be empty now and no more workers should be running');
522 | calls.push('drain');
523 | expect(calls).to.eql([
524 | 'process foo',
525 | 'process bar',
526 | 'saturated',
527 | 'process zoo',
528 | 'foo cb',
529 | 'saturated',
530 | 'process poo',
531 | 'bar cb',
532 | 'empty',
533 | 'saturated',
534 | 'process moo',
535 | 'zoo cb',
536 | 'poo cb',
537 | 'moo cb',
538 | 'drain',
539 | ]);
540 | done();
541 | });
542 | q.push('foo', () => calls.push('foo cb'));
543 | q.push('bar', () => calls.push('bar cb'));
544 | q.push('zoo', () => calls.push('zoo cb'));
545 | q.push('poo', () => calls.push('poo cb'));
546 | q.push('moo', () => calls.push('moo cb'));
547 | });
548 |
549 | it('empty', (done) => {
550 | const calls = [];
551 | const q = new AsyncQueue((task, cb) => {
552 | // nop
553 | calls.push(`process ${task}`);
554 | setTimeout(cb, 1);
555 | }, 3);
556 |
557 | q.onDrain.once(() => {
558 | expect(q.length() === 0 && q.running() === 0)
559 | .to.equal(true, 'queue should be empty now and no more workers should be running');
560 | calls.push('drain');
561 | expect(calls).to.eql([
562 | 'drain',
563 | ]);
564 | done();
565 | });
566 | q.push();
567 | });
568 |
569 | it('saturated', (done) => {
570 | let saturatedCalled = false;
571 | const q = new AsyncQueue((task, cb) => {
572 | setTimeout(cb, 1);
573 | }, 2);
574 |
575 | q.onSaturated.add(() => {
576 | saturatedCalled = true;
577 | });
578 | q.onDrain.once(() => {
579 | expect(saturatedCalled).to.equal(true, 'saturated not called');
580 | done();
581 | });
582 |
583 | q.push('foo');
584 | q.push('bar');
585 | q.push('baz');
586 | q.push('moo');
587 | });
588 |
589 | it('started', (done) => {
590 | const q = new AsyncQueue((task, cb) => {
591 | cb(null, task);
592 | });
593 |
594 | expect(q.started).to.equal(false);
595 | q.push();
596 | expect(q.started).to.equal(true);
597 | done();
598 | });
599 |
600 | context('q.saturated(): ', () => {
601 | it('should call the saturated callback if tasks length is concurrency', (done) => {
602 | const calls = [];
603 | const q = new AsyncQueue((task, cb) => {
604 | calls.push(`process ${task}`);
605 | setTimeout(cb, 1);
606 | }, 4);
607 |
608 | q.onSaturated.add(() => {
609 | calls.push('saturated');
610 | });
611 | q.onEmpty.add(() => {
612 | expect(calls.indexOf('saturated')).to.be.above(-1);
613 | setTimeout(() => {
614 | expect(calls).eql([
615 | 'process foo0',
616 | 'process foo1',
617 | 'process foo2',
618 | 'saturated',
619 | 'process foo3',
620 | 'foo0 cb',
621 | 'saturated',
622 | 'process foo4',
623 | 'foo1 cb',
624 | 'foo2 cb',
625 | 'foo3 cb',
626 | 'foo4 cb',
627 | ]);
628 | done();
629 | }, 50);
630 | });
631 | q.push('foo0', () => calls.push('foo0 cb'));
632 | q.push('foo1', () => calls.push('foo1 cb'));
633 | q.push('foo2', () => calls.push('foo2 cb'));
634 | q.push('foo3', () => calls.push('foo3 cb'));
635 | q.push('foo4', () => calls.push('foo4 cb'));
636 | });
637 | });
638 |
639 | context('q.unsaturated(): ', () => {
640 | it('should have a default buffer property that equals 25% of the concurrenct rate', (done) => {
641 | const calls = [];
642 | const q = new AsyncQueue((task, cb) => {
643 | // nop
644 | calls.push(`process ${task}`);
645 | setTimeout(cb, 1);
646 | }, 10);
647 |
648 | expect(q.buffer).to.equal(2.5);
649 | done();
650 | });
651 | it('should allow a user to change the buffer property', (done) => {
652 | const calls = [];
653 | const q = new AsyncQueue((task, cb) => {
654 | // nop
655 | calls.push(`process ${task}`);
656 | setTimeout(cb, 1);
657 | }, 10);
658 |
659 | q.buffer = 4;
660 | expect(q.buffer).to.not.equal(2.5);
661 | expect(q.buffer).to.equal(4);
662 | done();
663 | });
664 | it('should call the unsaturated callback if tasks length is less than concurrency minus buffer', (done) => { // eslint-disable-line max-len
665 | const calls = [];
666 | const q = new AsyncQueue((task, cb) => {
667 | calls.push(`process ${task}`);
668 | setTimeout(cb, 1);
669 | }, 4);
670 |
671 | q.onUnsaturated.add(() => {
672 | calls.push('unsaturated');
673 | });
674 | q.onEmpty.add(() => {
675 | expect(calls.indexOf('unsaturated')).to.be.above(-1);
676 | setTimeout(() => {
677 | expect(calls).eql([
678 | 'process foo0',
679 | 'process foo1',
680 | 'process foo2',
681 | 'process foo3',
682 | 'foo0 cb',
683 | 'unsaturated',
684 | 'process foo4',
685 | 'foo1 cb',
686 | 'unsaturated',
687 | 'foo2 cb',
688 | 'unsaturated',
689 | 'foo3 cb',
690 | 'unsaturated',
691 | 'foo4 cb',
692 | 'unsaturated',
693 | ]);
694 | done();
695 | }, 50);
696 | });
697 | q.push('foo0', () => calls.push('foo0 cb'));
698 | q.push('foo1', () => calls.push('foo1 cb'));
699 | q.push('foo2', () => calls.push('foo2 cb'));
700 | q.push('foo3', () => calls.push('foo3 cb'));
701 | q.push('foo4', () => calls.push('foo4 cb'));
702 | });
703 | });
704 | });
705 |
706 | describe('eachSeries', () => {
707 | function eachIteratee(args, x, callback) {
708 | setTimeout(() => {
709 | args.push(x);
710 | callback();
711 | }, x * 25);
712 | }
713 |
714 | function eachNoCallbackIteratee(done, x, callback) {
715 | expect(x).to.equal(1);
716 | callback();
717 | done();
718 | }
719 |
720 | it('eachSeries', (done) => {
721 | const args = [];
722 |
723 | eachSeries([1, 3, 2], eachIteratee.bind(this, args), (err) => {
724 | expect(err).to.equal(undefined, `${err} passed instead of 'null'`);
725 | expect(args).to.eql([1, 3, 2]);
726 | done();
727 | });
728 | });
729 |
730 | it('empty array', (done) => {
731 | eachSeries([], (x, callback) => {
732 | expect(false).to.equal(true, 'iteratee should not be called');
733 | callback();
734 | }, (err) => {
735 | if (err) {
736 | throw err;
737 | }
738 |
739 | expect(true).to.equal(true, 'should call callback');
740 | });
741 | setTimeout(done, 25);
742 | });
743 |
744 | it('array modification', (done) => {
745 | const arr = [1, 2, 3, 4];
746 |
747 | eachSeries(arr, (x, callback) => {
748 | setTimeout(callback, 1);
749 | }, () => {
750 | expect(true).to.equal(true, 'should call callback');
751 | });
752 |
753 | arr.pop();
754 | arr.splice(0, 1);
755 |
756 | setTimeout(done, 50);
757 | });
758 |
759 | // bug #782. Remove in next major release
760 | it('single item', (done) => {
761 | let sync = true;
762 |
763 | eachSeries(
764 | [1],
765 | (i, cb) => {
766 | cb(null);
767 | },
768 | () => {
769 | expect(sync).to.equal(true, 'callback not called on same tick');
770 | }
771 | );
772 | sync = false;
773 | done();
774 | });
775 |
776 | // bug #782. Remove in next major release
777 | it('single item', (done) => {
778 | let sync = true;
779 |
780 | eachSeries(
781 | [1],
782 | (i, cb) => {
783 | cb(null);
784 | },
785 | () => {
786 | expect(sync).to.equal(true, 'callback not called on same tick');
787 | }
788 | );
789 | sync = false;
790 | done();
791 | });
792 |
793 | it('error', (done) => {
794 | const callOrder = [];
795 |
796 | eachSeries(
797 | [1, 2, 3],
798 | (x, callback) => {
799 | callOrder.push(x);
800 | callback('error');
801 | },
802 | (err) => {
803 | expect(callOrder).to.eql([1]);
804 | expect(err).to.equal('error');
805 | }
806 | );
807 | setTimeout(done, 50);
808 | });
809 |
810 | it('no callback', (done) => {
811 | eachSeries([1], eachNoCallbackIteratee.bind(this, done));
812 | });
813 | });
814 | });
815 |
--------------------------------------------------------------------------------