UNPKG

scratch-storage

Version:

Load and store project and asset files for Scratch 3.0

247 lines (210 loc) 9.16 kB
import log from './log'; import Asset, {AssetData, AssetId} from './Asset'; import Helper from './Helper'; import ProxyTool from './ProxyTool'; import {ScratchGetRequest, ScratchSendRequest, Tool} from './Tool'; import {AssetType} from './AssetType'; import {DataFormat} from './DataFormat'; /** * The request configuration */ type RequestConfig = ScratchGetRequest | ScratchSendRequest; /** * The result of a UrlFunction, which can be a string URL or a full request configuration * object, or a promise for either of those. * * If set to null or undefined, the WebHelper will skip that store and move on to the * next one. This allows stores to be registered that only provide a subset of their * declared asset types at a given time. */ type RequestFnResult = null | undefined | string | ScratchGetRequest | ScratchSendRequest; /** * Ensure that the provided request configuration is in object form, converting from * string if necessary. */ const ensureRequestConfig = async ( reqConfig: RequestFnResult | Promise<RequestFnResult> ): Promise<RequestConfig | null | undefined> => { reqConfig = await reqConfig; if (typeof reqConfig === 'string') { return { url: reqConfig }; } return reqConfig; }; /** * A function which computes a URL from asset information. */ export type UrlFunction = (asset: Asset) => RequestFnResult | Promise<RequestFnResult>; interface StoreRecord { types: string[], get: UrlFunction, create?: UrlFunction, update?: UrlFunction } export default class WebHelper extends Helper { public stores: StoreRecord[]; public assetTool: Tool; public projectTool: Tool; constructor (parent) { super(parent); /** * @type {Array.<StoreRecord>} * @typedef {object} StoreRecord * @property {Array.<string>} types - The types of asset provided by this store, from AssetType's name field. * @property {UrlFunction} getFunction - A function which computes a URL from an Asset. * @property {UrlFunction} createFunction - A function which computes a URL from an Asset. * @property {UrlFunction} updateFunction - A function which computes a URL from an Asset. */ this.stores = []; /** * Set of tools to best load many assets in parallel. If one tool * cannot be used, it will use the next. * @type {ProxyTool} */ this.assetTool = new ProxyTool(); /** * Set of tools to best load project data in parallel with assets. This * tool set prefers tools that are immediately ready. Some tools have * to initialize before they can load files. * @type {ProxyTool} */ this.projectTool = new ProxyTool(ProxyTool.TOOL_FILTER.READY); } /** * Register a web-based source for assets. Sources will be checked in order of registration. * @deprecated Please use addStore * @param {Array.<AssetType>} types - The types of asset provided by this source. * @param {UrlFunction} urlFunction - A function which computes a URL from an Asset. */ addSource (types: AssetType[], urlFunction: UrlFunction): void { log.warn('Deprecation: WebHelper.addSource has been replaced with WebHelper.addStore.'); this.addStore(types, urlFunction); } /** * Register a web-based store for assets. Sources will be checked in order of registration. * @param {Array.<AssetType>} types - The types of asset provided by this store. * @param {UrlFunction} getFunction - A function which computes a GET URL for an Asset * @param {UrlFunction} createFunction - A function which computes a POST URL for an Asset * @param {UrlFunction} updateFunction - A function which computes a PUT URL for an Asset */ addStore ( types: AssetType[], getFunction: UrlFunction, createFunction?: UrlFunction, updateFunction?: UrlFunction ): void { this.stores.push({ types: types.map(assetType => assetType.name), get: getFunction, create: createFunction, update: updateFunction }); } /** * Fetch an asset but don't process dependencies. * @param {AssetType} assetType - The type of asset to fetch. * @param {string} assetId - The ID of the asset to fetch: a project ID, MD5, etc. * @param {DataFormat} dataFormat - The file format / file extension of the asset to fetch: PNG, JPG, etc. * @returns {Promise.<Asset>} A promise for the contents of the asset. */ async load (assetType: AssetType, assetId: AssetId, dataFormat: DataFormat): Promise<Asset | null> { /** @type {unknown[]} List of errors encountered while attempting to load the asset. */ const errors: unknown[] = []; const stores = this.stores.slice() .filter(store => store.types.indexOf(assetType.name) >= 0); // New empty asset but it doesn't have data yet const asset = new Asset(assetType, assetId, dataFormat); let tool = this.assetTool; if (assetType.name === 'Project') { tool = this.projectTool; } for (const store of stores) { const reqConfigFunction = store && store.get; if (reqConfigFunction) { try { const reqConfig = await ensureRequestConfig(reqConfigFunction(asset)); if (!reqConfig) { continue; } const body = await tool.get(reqConfig); if (body) { asset.setData(body, dataFormat); return asset; } } catch (err) { errors.push(err); } } } if (errors.length > 0) { return Promise.reject(errors); } // no stores matching asset return Promise.resolve(null); } /** * Create or update an asset with provided data. The create function is called if no asset id is provided * @param {AssetType} assetType - The type of asset to create or update. * @param {?DataFormat} dataFormat - DataFormat of the data for the stored asset. * @param {Buffer} data - The data for the cached asset. * @param {?string} assetId - The ID of the asset to fetch: a project ID, MD5, etc. * @returns {Promise.<object>} A promise for the response from the create or update request */ async store ( assetType: AssetType, dataFormat: DataFormat | undefined, data: AssetData, assetId?: AssetId ): Promise<string | {id: string}> { const asset = new Asset(assetType, assetId, dataFormat); // If we have an asset id, we should update, otherwise create to get an id const create = assetId === '' || assetId === null || typeof assetId === 'undefined'; const candidateStores = this.stores.filter(s => // Only use stores for the incoming asset type s.types.indexOf(assetType.name) !== -1 && ( // Only use stores that have a create function if this is a create request // or an update function if this is an update request (create && s.create) || s.update ) ); const method = create ? 'post' : 'put'; if (candidateStores.length === 0) { return Promise.reject(new Error('No appropriate stores')); } let tool = this.assetTool; if (assetType.name === 'Project') { tool = this.projectTool; } for (const store of candidateStores) { const reqConfig = await ensureRequestConfig( // The non-nullability of this gets checked above while looking up the store. // Making TS understand that is going to require code refactoring which we currently don't // feel safe to do. create ? store.create!(asset) : store.update!(asset) ); if (!reqConfig) { continue; } const reqBodyConfig = Object.assign({body: data, method}, reqConfig); let body = await tool.send(reqBodyConfig); // xhr makes it difficult to both send FormData and // automatically parse a JSON response. So try to parse // everything as JSON. if (typeof body === 'string') { try { body = JSON.parse(body); } catch (parseError) { // eslint-disable-line @typescript-eslint/no-unused-vars // If it's not parseable, then we can't add the id even // if we want to, so stop here return body; } } return Object.assign({ id: body['content-name'] || assetId }, body); } return Promise.reject(new Error('No store could handle the request')); } }