scratch-storage
Version:
Load and store project and asset files for Scratch 3.0
247 lines (210 loc) • 9.16 kB
text/typescript
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'));
}
}