diff --git a/src/asset/core/exporter.js b/src/asset/core/exporter.js new file mode 100644 index 00000000..5c275030 --- /dev/null +++ b/src/asset/core/exporter.js @@ -0,0 +1,41 @@ +/** @import { Constructor } from '../../type/index.js' */ +import { throws } from '../../logger/index.js' + +/** + * @abstract + * @template T + */ +export class Exporter { + + /** + * @readonly + * @type {Constructor} + */ + asset + + /** + * @param {Constructor} asset + */ + constructor(asset) { + this.asset = asset + } + + /** + * @param {T} _asset + * @returns {Promise} + */ + async serialize(_asset) { + throws(`Implement the method \`serialize\` on \`${this.constructor.name}\``) + + return undefined + } + + /** + * @returns {string[]} + */ + getExtensions() { + throws(`Implement the method \`getExtensions\` on \`${this.constructor.name}\``) + + return [] + } +} diff --git a/src/asset/core/index.js b/src/asset/core/index.js index 89c11167..6d2ebfac 100644 --- a/src/asset/core/index.js +++ b/src/asset/core/index.js @@ -1,2 +1,3 @@ export * from './asset.js' +export * from './exporter.js' export * from './parser.js' diff --git a/src/asset/events/fail.js b/src/asset/events/fail.js index f997ed7b..8111bd22 100644 --- a/src/asset/events/fail.js +++ b/src/asset/events/fail.js @@ -1,5 +1,15 @@ /** @import {TypeId} from '../../type/index.js' */ /** @import {AssetId} from '../types/index.js' */ + +/** + * @readonly + * @enum {number} + */ +export const AssetLoadOperation = { + Loading: 1, + Saving: 2 +} + export class AssetLoadFail { /** @@ -22,16 +32,23 @@ export class AssetLoadFail { */ reason + /** + * @type {number} + */ + operation + /** * @param {TypeId} typeId * @param {AssetId} assetId * @param {string} path * @param {string} reason + * @param {number} [operation=AssetLoadOperation.Loading] */ - constructor(typeId, assetId, path, reason) { + constructor(typeId, assetId, path, reason, operation = AssetLoadOperation.Loading) { this.typeId = typeId this.assetId = assetId this.path = path this.reason = reason + this.operation = operation } } diff --git a/src/asset/events/index.js b/src/asset/events/index.js index a57bc1d9..d4c025a2 100644 --- a/src/asset/events/index.js +++ b/src/asset/events/index.js @@ -1,3 +1,4 @@ export * from './fail.js' export * from './success.js' +export * from './save.js' export * from './assets.js' diff --git a/src/asset/events/save.js b/src/asset/events/save.js new file mode 100644 index 00000000..d57b2f3f --- /dev/null +++ b/src/asset/events/save.js @@ -0,0 +1,31 @@ +/** @import {TypeId} from '../../type/index.js' */ +/** @import {AssetId} from '../types/index.js' */ + +export class AssetSaveSuccess { + + /** + * @type {TypeId} + */ + typeId + + /** + * @type {AssetId} + */ + assetId + + /** + * @type {string} + */ + path + + /** + * @param {TypeId} typeId + * @param {AssetId} assetId + * @param {string} path + */ + constructor(typeId, assetId, path) { + this.path = path + this.typeId = typeId + this.assetId = assetId + } +} diff --git a/src/asset/plugins/assetServer.js b/src/asset/plugins/assetServer.js index d6e088d5..6e49a80b 100644 --- a/src/asset/plugins/assetServer.js +++ b/src/asset/plugins/assetServer.js @@ -1,8 +1,8 @@ import { App, Plugin } from '../../app/index.js' import { EventPlugin } from '../../event/index.js' import { AssetServer } from '../resources/index.js' -import { AssetLoadFail, AssetLoadSuccess } from '../events/index.js' -import { updateAssets, updateAssetLoadEvents, logFailedLoads, registerAssetServerTypes } from '../systems/index.js' +import { AssetLoadFail, AssetLoadSuccess, AssetSaveSuccess } from '../events/index.js' +import { updateAssets, updateAssetLoadEvents, updateAssetSaveEvents, logFailedLoads, registerAssetServerTypes } from '../systems/index.js' import { AppSchedule, CoreSystems } from '../../core/index.js' export class AssetServerPlugin extends Plugin { @@ -16,12 +16,16 @@ export class AssetServerPlugin extends Plugin { .registerPlugin(new EventPlugin({ event: AssetLoadSuccess })) + .registerPlugin(new EventPlugin({ + event: AssetSaveSuccess + })) .registerPlugin(new EventPlugin({ event: AssetLoadFail })) .registerSystem({ schedule: AppSchedule.Startup, systemGroup: CoreSystems.Start, system: registerAssetServerTypes }) .registerSystem({ schedule: AppSchedule.Update, systemGroup: CoreSystems.End, system: updateAssets }) .registerSystem({ schedule: AppSchedule.Update, systemGroup: CoreSystems.End, system: updateAssetLoadEvents }) + .registerSystem({ schedule: AppSchedule.Update, systemGroup: CoreSystems.End, system: updateAssetSaveEvents }) .registerSystem({ schedule: AppSchedule.Update, systemGroup: CoreSystems.End, system: logFailedLoads }) } } diff --git a/src/asset/plugins/exporter.js b/src/asset/plugins/exporter.js new file mode 100644 index 00000000..ca823395 --- /dev/null +++ b/src/asset/plugins/exporter.js @@ -0,0 +1,61 @@ +/** @import {Constructor} from '../../type/index.js' */ +import { App, Plugin } from '../../app/index.js' +import { AppSchedule, CoreSystems } from '../../core/index.js' +import { typeid, typeidGeneric } from '../../type/index.js' +import { Exporter } from '../core/index.js' +import { registerAssetExporterOnAssetServer } from '../systems/index.js' + +/** + * @template T + */ +export class AssetExporterPlugin extends Plugin { + + /** + * @readonly + * @type {Constructor} + */ + asset + + /** + * @readonly + * @type {Exporter} + */ + exporter + + /** + * @param {AssetExporterPluginOptions} options + */ + constructor(options) { + super() + const { asset, exporter } = options + + this.asset = asset + this.exporter = exporter + } + + /** + * @param {App} app + */ + register(app) { + const { asset, exporter } = this + + app + .registerSystem({ + label: `registerAssetExporterOnAssetServer<${typeid(asset)}>`, + schedule: AppSchedule.Startup, + systemGroup: CoreSystems.Start, + system: registerAssetExporterOnAssetServer(asset, exporter) + }) + } + + name() { + return typeidGeneric(AssetExporterPlugin, [this.asset]) + } +} + +/** + * @template T + * @typedef AssetExporterPluginOptions + * @property {Constructor} asset + * @property {Exporter} exporter + */ diff --git a/src/asset/plugins/index.js b/src/asset/plugins/index.js index 6d756bc7..268a09b6 100644 --- a/src/asset/plugins/index.js +++ b/src/asset/plugins/index.js @@ -1,3 +1,4 @@ export * from './asset.js' export * from './assetServer.js' +export * from './exporter.js' export * from './parser.js' diff --git a/src/asset/resources/assetserver.js b/src/asset/resources/assetserver.js index 86d841e3..9dba33b4 100644 --- a/src/asset/resources/assetserver.js +++ b/src/asset/resources/assetserver.js @@ -3,8 +3,8 @@ import { typeid } from '../../type/index.js' import { assert, warn } from '../../logger/index.js' import { getFileExtension, swapRemove } from '../../utils/index.js' -import { Assets, Handle, Parser } from '../core/index.js' -import { AssetLoadSuccess, AssetLoadFail } from '../events/index.js' +import { Assets, Handle, Parser, Exporter } from '../core/index.js' +import { AssetLoadSuccess, AssetSaveSuccess, AssetLoadFail, AssetLoadOperation } from '../events/index.js' /** * @typedef {number} ParserId @@ -77,6 +77,78 @@ export class Parsers { return /** @type {Parser} */(parser) } } + +/** + * @typedef {number} ExporterId + */ +export class Exporters { + + /** + * @private + * @type {Exporter[]} + */ + exporters = [] + + /** + * @private + * @type {Map>} + */ + extensions = new Map() + + /** + * @template T + * @param {Exporter} exporter + */ + add(exporter) { + const id = this.exporters.length + const typeId = typeid(exporter.asset) + const extensions = exporter.getExtensions() + + this.exporters.push(exporter) + + for (let i = 0; i < extensions.length; i++) { + const extension = extensions[i] + const extensionMap = this.extensions.get(extension) + + if (extensionMap) { + if (extensionMap.has(typeId)) { + warn(`Overriding an exporter already present with asset type \`${typeId}\` and with extension "${extension}"".`) + } + + extensionMap.set(typeId, id) + } else { + this.extensions.set(extension, new Map([[typeId, id]])) + } + } + } + + /** + * @template T + * @param {TypeId} type + * @param {string} extension + * @returns {Exporter} + * @throws {string} + */ + get(type, extension) { + const extensions = this.extensions.get(extension) + + if (!extensions) { + throw 'The given extension does not have an exporter registered' + } + + const exporterId = extensions.get(type) + + if (exporterId === undefined) { + throw 'The given asset type does not support the given extension' + } + + const exporter = this.exporters[exporterId] + + assert(exporter, 'Internal error: The givk&en exporter index is invalid.') + + return /** @type {Exporter} */(exporter) + } +} export class AssetServer { /** @@ -93,6 +165,13 @@ export class AssetServer { */ parsers = new Parsers() + /** + * @private + * @readonly + * @type {Exporters} + */ + exporters = new Exporters() + /** * @private * @readonly @@ -119,6 +198,12 @@ export class AssetServer { */ loaded = [] + /** + * @private + * @type {AssetSaveSuccess[]} + */ + saved = [] + /** * @type {AssetLoadFail[]} */ @@ -143,6 +228,15 @@ export class AssetServer { this.parsers.add(parser) } + /** + * @template T + * @param {Constructor} type + * @param {Exporter} exporter + */ + registerExporter(type, exporter) { + this.exporters.add(exporter) + } + /** * @template T * @param {Constructor} type @@ -176,6 +270,32 @@ export class AssetServer { return handle } + /** + * @template T + * @param {Handle} handle + * @param {string} [path] + */ + save(handle, path) { + const typeId = typeid(handle.type) + const assetId = handle.id() + const info = this.assetInfos.getByAssetId(assetId) + const targetPath = path ?? info?.path + + if (!targetPath) { + this.recordFailure( + typeId, + assetId, + path || '', + 'The given asset handle does not have a registered asset path.', + AssetLoadOperation.Saving + ) + + return + } + + return this.post(assetId, typeId, targetPath) + } + /** * @param {AssetId} assetId * @param {TypeId} typeId @@ -190,26 +310,83 @@ export class AssetServer { this.loadedAssets.push(asset) info.loadstate = LoadState.Loaded } catch(error) { - let message = '' + this.recordFailure(typeId, assetId, path, error) + info.loadstate = LoadState.Failed + } + } + + /** + * @private + * @param {AssetId} assetId + * @param {TypeId} typeId + * @param {string} path + */ + async post(assetId, typeId, path) { + try { + const response = await fetch(path, { + method: 'POST', + body: await this.serialize(assetId, typeId, path) + }) + + if (!response.ok) { + this.recordFailure(typeId, assetId, path, response.statusText, AssetLoadOperation.Saving) + } else { + this.saved.push(new AssetSaveSuccess(typeId, assetId, path)) + } + } catch(error) { + let message = 'Could not export the asset.' if (typeof error === 'string') { message = error } else if (error instanceof Error) { + const { message: errorMessage } = error - // eslint-disable-next-line prefer-destructuring - message = error.message - } else { - console.error('Unhandled Error: ', error) + message = errorMessage } - this.failed.push(new AssetLoadFail( - typeId, - assetId, - path, - message - )) - info.loadstate = LoadState.Failed + this.recordFailure(typeId, assetId, path, message, AssetLoadOperation.Saving) + } + } + + /** + * @private + * @param {AssetId} assetId + * @param {TypeId} typeId + * @param {string} path + * @returns {Promise} + */ + async serialize(assetId, typeId, path) { + const extension = getFileExtension(path) + const assets = this.assets.get(typeId) + const exporter = this.exporters.get(typeId, extension) + + assert(assets, `No assets registered for the asset type \`${typeId}\` on \`AssetServer\``) + + const asset = assets.getByAssetId(assetId) + + if (asset === undefined) { + throw 'Could not find the asset to export.' } + + return exporter.serialize(asset) + } + + /** + * @private + * @param {TypeId} typeId + * @param {AssetId} assetId + * @param {string} path + * @param {string} message + * @param {number} [operation=AssetLoadOperation.Loading] + */ + recordFailure(typeId, assetId, path, message, operation = AssetLoadOperation.Loading) { + this.failed.push(new AssetLoadFail( + typeId, + assetId, + path, + message, + operation + )) } /** @@ -287,6 +464,17 @@ export class AssetServer { return buffer } + /** + * @returns {readonly AssetSaveSuccess[]} + */ + flushSaveSuccess() { + const buffer = this.saved + + this.saved = [] + + return buffer + } + /** * @returns {readonly AssetLoadFail[]} */ diff --git a/src/asset/systems/events.js b/src/asset/systems/events.js index c565440c..03af7ccd 100644 --- a/src/asset/systems/events.js +++ b/src/asset/systems/events.js @@ -5,7 +5,7 @@ import { Assets } from '../core/index.js' import { Events } from '../../event/index.js' import { typeid, typeidGeneric } from '../../type/index.js' import { AssetServer } from '../resources/index.js' -import { AssetAdded, AssetDropped, AssetModified, AssetLoadSuccess, AssetLoadFail } from '../events/index.js' +import { AssetAdded, AssetDropped, AssetModified, AssetLoadSuccess, AssetSaveSuccess, AssetLoadFail } from '../events/index.js' import { warnOnce } from '../../logger/index.js' /** @@ -79,3 +79,20 @@ export function updateAssetLoadEvents(world) { failEvents.write(failed[i]) } } + +/** + * @param {World} world + * @returns {void} + */ +export function updateAssetSaveEvents(world) { + const server = world.getResource(AssetServer) + + /** @type {Events} */ + const saveEvents = world.getResourceByTypeId(typeidGeneric(Events, [AssetSaveSuccess])) + + const saved = server.flushSaveSuccess() + + for (let i = 0; i < saved.length; i++) { + saveEvents.write(saved[i]) + } +} diff --git a/src/asset/systems/server.js b/src/asset/systems/server.js index 574c7c5d..65eb4484 100644 --- a/src/asset/systems/server.js +++ b/src/asset/systems/server.js @@ -1,11 +1,11 @@ /** @import { SystemFunc, World } from '../../ecs/index.js' */ /** @import { Constructor } from '../../type/index.js' */ -/** @import { AssetDropped, AssetEvent, Parser } from '../index.js' */ +/** @import { AssetDropped, AssetEvent, Parser, Exporter } from '../index.js' */ import { Events } from '../../event/index.js' import { typeidGeneric } from '../../type/index.js' import { Assets } from '../core/index.js' import { AssetServer } from '../resources/index.js' -import { AssetLoadFail } from '../events/index.js' +import { AssetLoadFail, AssetLoadOperation } from '../events/index.js' import { error } from '../../logger/index.js' /** @@ -36,6 +36,20 @@ export function registerAssetParserOnAssetServer(type, parser) { } } +/** + * @template T + * @param {Constructor} type + * @param {Exporter} exporter + * @returns {SystemFunc} + */ +export function registerAssetExporterOnAssetServer(type, exporter) { + return function registerAssetExportedOnAssetServer(world) { + const server = world.getResource(AssetServer) + + server.registerExporter(type, exporter) + } +} + /** * @param {World} world */ @@ -63,8 +77,9 @@ export function logFailedLoads(world) { events.each((event) => { const { data } = event + const operation = data.operation === AssetLoadOperation.Saving ? 'saving' : 'loading' - error(`\`AssetServer\` error loading "${data.path}": ${data.reason}`) + error(`\`AssetServer\` error ${operation} "${data.path}": ${data.reason}`) }) } diff --git a/src/asset/systems/types.js b/src/asset/systems/types.js index 91f7aa18..2325a4b6 100644 --- a/src/asset/systems/types.js +++ b/src/asset/systems/types.js @@ -6,7 +6,7 @@ import { TypeRegistry } from '../../reflect/resources/index.js' import { Assets, Handle } from '../core/index.js' import { typeid, typeidGeneric } from '../../type/index.js' import { AssetServer } from '../resources/index.js' -import { AssetLoadFail } from '../events/index.js' +import { AssetLoadFail, AssetSaveSuccess } from '../events/index.js' /** * @template T @@ -38,10 +38,13 @@ export function registerAssetServerTypes(world) { const registry = world.getResource(TypeRegistry) const assetLoadFailArrayId = typeidGeneric(Array, [AssetLoadFail]) + const assetSaveSuccessArrayId = typeidGeneric(Array, [AssetSaveSuccess]) registry.registerTypeId(assetLoadFailArrayId, new ArrayInfo(typeid(AssetLoadFail))) + registry.registerTypeId(assetSaveSuccessArrayId, new ArrayInfo(typeid(AssetSaveSuccess))) registry.register(AssetServer, new StructInfo({ - failed: new Field(assetLoadFailArrayId) + failed: new Field(assetLoadFailArrayId), + saved: new Field(assetSaveSuccessArrayId) })) } diff --git a/src/asset/tests/assetserver.test.js b/src/asset/tests/assetserver.test.js index ffbf01fa..c7ac06be 100644 --- a/src/asset/tests/assetserver.test.js +++ b/src/asset/tests/assetserver.test.js @@ -1,6 +1,7 @@ import { deepStrictEqual, notDeepStrictEqual } from "assert"; import test, { describe,todo } from "node:test"; -import { Assets, AssetServer, Parser } from "../index.js"; +import { Assets, AssetServer, Exporter, Parser } from "../index.js"; +import { typeid } from "../../type/index.js"; class Text { inner = '' @@ -35,6 +36,29 @@ class TextParser extends Parser { } } +/** + * @extends {Exporter} + */ +class TextExporter extends Exporter { + constructor(){ + super(Text) + } + + /** + * @override + */ + getExtensions(){ + return ['txt'] + } + + /** + * @param {Text} asset + */ + async serialize(asset){ + return JSON.stringify(asset) + } +} + describe('Testing `AssetServer`', () => { test('Asset is cached by server.', () => { const server = createServer() @@ -61,6 +85,31 @@ describe('Testing `AssetServer`', () => { test('Asset load state cycle.', () => { todo() }) + + test('Asset save uses exporter.', async () => { + const server = createServer() + const assets = server.getAssets(typeid(Text)) + const handle = assets.add(new Text('hello')) + + const originalFetch = globalThis.fetch + let body + + globalThis.fetch = async (_path, init) => { + body = init.body + + return /**@type {Response}*/({ + ok: true, + statusText: 'OK' + }) + } + + try { + await server.save(handle, '/assets/text/sample.txt') + deepStrictEqual(body, JSON.stringify({ inner: 'hello' })) + } finally { + globalThis.fetch = originalFetch + } + }) }) function createServer() { @@ -69,6 +118,7 @@ function createServer() { server.registerAsset(assets) server.registerParser(Text,new TextParser()) + server.registerExporter(Text,new TextExporter()) return server -} \ No newline at end of file +}