UNPKG

@schukai/monster

Version:

Monster is a simple library for creating fast, robust and lightweight websites.

1,192 lines (1,043 loc) 28.5 kB
/** * Copyright © Volker Schukai and all contributing authors, {{copyRightYear}}. All rights reserved. * Node module: @schukai/monster * * This source code is licensed under the GNU Affero General Public License version 3 (AGPLv3). * The full text of the license can be found at: https://www.gnu.org/licenses/agpl-3.0.en.html * * For those who do not wish to adhere to the AGPLv3, a commercial license is available. * Acquiring a commercial license allows you to use this software without complying with the AGPLv3 terms. * For more information about purchasing a commercial license, please contact Volker Schukai. * * SPDX-License-Identifier: AGPL-3.0 */ import { addAttributeToken } from "../../../dom/attributes.mjs"; import { ATTRIBUTE_ERRORMESSAGE } from "../../../dom/constants.mjs"; import { Datasource, dataSourceSymbol } from "../datasource.mjs"; import { DatasourceStyleSheet } from "../stylesheet/datasource.mjs"; import { instanceSymbol } from "../../../constants.mjs"; import { assembleMethodSymbol, registerCustomElement, } from "../../../dom/customelement.mjs"; import { RestAPI } from "../../../data/datasource/server/restapi.mjs"; import { DataFetchError } from "../../../data/datasource/server/restapi/data-fetch-error.mjs"; import { Formatter } from "../../../text/formatter.mjs"; import { clone } from "../../../util/clone.mjs"; import { validateBoolean } from "../../../types/validate.mjs"; import { findElementWithIdUpwards } from "../../../dom/util.mjs"; import { Observer } from "../../../types/observer.mjs"; import { Pathfinder } from "../../../data/pathfinder.mjs"; import { fireCustomEvent } from "../../../dom/events.mjs"; import { isArray, isFunction, isObject, isString } from "../../../types/is.mjs"; export { Rest }; /** * @private * @type {symbol} */ const intersectionObserverHandlerSymbol = Symbol("intersectionObserverHandler"); /** * @private * Original at source/components/datatable/datasource/rest.mjs * @type {symbol} */ const rawDataSymbol = Symbol.for( "@schukai/monster/data/datasource/server/restapi/rawdata", ); /** * @private * @type {symbol} */ const intersectionObserverObserverSymbol = Symbol( "intersectionObserverObserver", ); /** * @private * @type {symbol} */ const filterObserverSymbol = Symbol("filterObserver"); const readRequestIdSymbol = Symbol("readRequestId"); const writeRequestIdSymbol = Symbol("writeRequestId"); const lookupCacheSymbol = Symbol("lookupCache"); const lookupPendingSymbol = Symbol("lookupPending"); const lookupPendingFetchSymbol = Symbol("lookupPendingFetch"); /** * A rest api datasource * * @fragments /fragments/components/datatable/datasource/rest * * @example /examples/components/datatable/datasource-rest-simple Simple Rest datasource * @example /examples/components/datatable/datasource-rest-auto-init Auto init * @example /examples/components/datatable/datasource-rest-do-fetch Rest datasource with fetch * * @issue https://localhost.alvine.dev:8440/development/issues/closed/272.html * * @copyright Volker Schukai * @summary A rest api datasource for the datatable or other components */ class Rest extends Datasource { /** * the constructor of the class */ constructor() { super(); this[dataSourceSymbol] = new RestAPI(); } /** * This method is called by the `instanceof` operator. * @return {symbol} */ static get [instanceSymbol]() { return Symbol.for("@schukai/monster/components/datasource/rest@@instance"); } /** * To set the options via the HTML tag, the attribute `data-monster-options` must be used. * @see {@link https://monsterjs.org/en/doc/#configurate-a-monster-control} * * The individual configuration values can be found in the table. * * @property {Object} templates Template definitions * @property {string} templates.main Main template * @property {Object} features Feature definitions * @property {boolean} features.autoInit If true, the component is initialized automatically * @property {boolean} features.filter If true, the filter.id is used to attach a filter control * @property {Object} autoInit Auto init definitions * @property {boolean} autoInit.intersectionObserver If true, the intersection observer is initialized automatically * @property {boolean} autoInit.oneTime If true, the intersection observer is initialized only once * @property {Object} filter Filter definitions * @property {string} filter.id The id of the filter control * @property {Object} response Response definitions * @property {Object} response.path Path definitions (changed in 3.56.0) * @property {string} response.path.message Path to the message (changed in 3.56.0) * @property {Object} read Read configuration * @property {string} read.url The url of the rest api * @property {string} read.method The method of the rest api * @property {Object} read.parameters The parameters of the rest api * @property {Object} read.parameters.filter The filter of the rest api * @property {Object} read.parameters.order The order by of the rest api * @property {Object} read.parameters.page The page of the rest api * @property {string} read.mapping.currentPage The current page * @property {Object} write Write configuration * @property {string} write.url The url of the rest api * @property {string} write.method The method of the rest api * @property {Object} write Write configuration */ get defaults() { const restOptions = new RestAPI().defaults; restOptions.read.parameters = { filter: null, order: null, page: "1", }; restOptions.read.mapping.currentPage = "sys.pagination.currentPage"; return Object.assign({}, super.defaults, restOptions, { templates: { main: getTemplate(), }, features: { autoInit: false, filter: false, }, autoInit: { intersectionObserver: false, oneTime: true, }, filter: { id: null, }, /*datatable: { id: undefined, // not used? }, */ response: { path: { message: "sys.message", code: "sys.code", validationErrors: null, validationMessage: null, }, }, validation: { map: {}, }, lookups: { enabled: false, debug: false, sourcePath: "dataset", request: { idsParam: "ids", idsSeparator: ",", }, response: { path: "dataset", id: "id", }, format: { template: "${name}", marker: { open: ["${"], close: ["}"], }, }, columns: {}, }, }); } /** * With this method, you can set the parameters for the rest api. The parameters are * used for building the url. * * @param {string} page * @param {string} query * @param {string} order * @return {Rest} */ setParameters({ page, query, order }) { const parameters = this.getOption("read.parameters"); if (query !== undefined) { parameters.query = `${query}`; parameters.page = "1"; } // after a query the page is set to 1, so if the page is not set, it is set to 1 if (page !== undefined) parameters.page = `${page}`; if (order !== undefined) parameters.order = `${order}`; this.setOption("read.parameters", parameters); return this; } /** * @private * @return {void} */ [assembleMethodSymbol]() { super[assembleMethodSymbol](); initEventHandler.call(this); } /** * This method reloads the data from the rest api, this method is deprecated. * You should use the method `read` instead. * * @deprecated 2023-06-25 * @return {Promise<never>|*} */ reload() { return this.read(); } /** * Fetches the data from the rest api, this method is deprecated. * You should use the method `read` instead. * * @deprecated 2024-12-24 * @return {Promise<never>|*} */ fetch() { return this.read(); } /** * * @return {CSSStyleSheet[]} */ static getCSSStyleSheet() { return [DatasourceStyleSheet]; } /** * @private * @return {string} */ static getTag() { return "monster-datasource-rest"; } /** * This method activates the intersection observer manually. * For this purpose, the option `autoInit.intersectionObserver` must be set to `false`. * * @return {Rest} */ initIntersectionObserver() { initIntersectionObserver.call(this); return this; } /** * @private */ connectedCallback() { super.connectedCallback(); queueMicrotask(() => { if (this.getOption("features.filter", false) === true) { initFilter.call(this); if (!this[filterObserverSymbol]) { initAutoInit.call(this); } } else { initAutoInit.call(this); } }); } /** * @private */ disconnectedCallback() { super.disconnectedCallback(); removeFilter.call(this); } /** * This method reads the data from the rest api. * The data is stored in the internal dataset object. * * @return {Promise} * @fires monster-datasource-fetch * @fires monster-datasource-fetched * @fires monster-datasource-error */ read() { const opt = clone(this.getOption("read")); const requestId = (this[readRequestIdSymbol] || 0) + 1; this[readRequestIdSymbol] = requestId; const responseCallback = opt?.responseCallback; opt.responseCallback = (obj) => { if (this[readRequestIdSymbol] !== requestId) { return; } if (typeof responseCallback === "function") { responseCallback(obj); return; } const transformedPayload = this[ dataSourceSymbol ].transformServerPayload.call(this[dataSourceSymbol], obj); if (typeof transformedPayload === "undefined") { return; } this[dataSourceSymbol].set(transformedPayload); applyLookups.call(this, requestId).catch(() => {}); }; this[dataSourceSymbol].setOption("read", opt); let url = this.getOption("read.url"); if (!url) { return Promise.reject(new Error("No url defined")); } const param = this.getOption("read.parameters", {}); if (param.query === null || param.query === undefined) { param.query = ""; } if (param.page === null || param.page === undefined) { param.page = "1"; } if (param.order === null || param.order === undefined) { param.order = ""; } const formatter = new Formatter(param); url = formatter.format(url); this[dataSourceSymbol].setOption("read.url", url); return new Promise((resolve, reject) => { fireCustomEvent(this, "monster-datasource-fetch", { datasource: this, }); queueMicrotask(() => { this[dataSourceSymbol] .read() .then((response) => { if (this[readRequestIdSymbol] === requestId) { fireCustomEvent(this, "monster-datasource-fetched", { datasource: this, }); } resolve(response); }) .catch((error) => { handleValidationError.call(this, error); if (this[readRequestIdSymbol] === requestId) { fireCustomEvent(this, "monster-datasource-error", { error: error, }); addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, error.toString()); } reject(error); }); }); }); } /** * Writes the data to the rest api. * @return {Promise} */ write() { const opt = clone(this.getOption("write")); const requestId = (this[writeRequestIdSymbol] || 0) + 1; this[writeRequestIdSymbol] = requestId; const responseCallback = opt?.responseCallback; opt.responseCallback = (obj) => { if (this[writeRequestIdSymbol] !== requestId) { return; } if (typeof responseCallback === "function") { responseCallback(obj); } }; this[dataSourceSymbol].setOption("write", opt); let url = this.getOption("write.url"); const formatter = new Formatter(this.getOption("write.parameters")); if (!url) { return Promise.reject(new Error("No url defined")); } url = formatter.format(url); this[dataSourceSymbol].setOption("write.url", url); return new Promise((resolve, reject) => { fireCustomEvent(this, "monster-datasource-fetch", { datasource: this, }); queueMicrotask(() => { this[dataSourceSymbol] .write() .then((response) => { if (this[writeRequestIdSymbol] === requestId) { fireCustomEvent(this, "monster-datasource-fetched", { datasource: this, }); } resolve(response); }) .catch((error) => { handleValidationError.call(this, error); if (this[writeRequestIdSymbol] === requestId) { fireCustomEvent(this, "monster-datasource-error", { error: error, }); addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, error.toString()); } reject(error); }); }); }); } } /** * @private */ function removeFilter() { const filterID = this.getOption("filter.id", undefined); if (!filterID) return; const filterControl = findElementWithIdUpwards(this, filterID); if (filterControl && this[filterObserverSymbol]) { filterControl?.detachObserver(this[filterObserverSymbol]); } } /** * @private */ function initFilter() { const filterID = this.getOption("filter.id", undefined); if (!filterID) throw new Error("filter feature is enabled but no filter id is defined"); const filterControl = findElementWithIdUpwards(this, filterID); if (!filterControl) { addAttributeToken( this, ATTRIBUTE_ERRORMESSAGE, "filter feature is enabled but no filter control with id " + filterID + " is found", ); return; } this[filterObserverSymbol] = new Observer(() => { const query = filterControl.getOption("query"); if (query === undefined || query === null) { return; } this.setParameters({ query: query }); this.read() .then((response) => { if (!(response instanceof Response)) { return Promise.reject(new Error("response is not a Response object")); } if (response?.ok === true) { this.dispatchEvent(new CustomEvent("reload", { bubbles: true })); filterControl?.showSuccess(); } if (response.bodyUsed === true) { return handleIntersectionObserver.call( this, response[rawDataSymbol], response, filterControl, ); } response .text() .then((jsonAsText) => { let json; try { json = JSON.parse(jsonAsText); } catch (e) { const message = e instanceof Error ? e.message : `${e}`; filterControl?.showFailureMessage(message); return Promise.reject(e); } return handleIntersectionObserver.call( this, json, response, filterControl, ); }) .catch((e) => { filterControl?.showFailureMessage(e.message); }); }) .catch((e) => { this.dispatchEvent( new CustomEvent("error", { bubbles: true, detail: e }), ); if (!(e instanceof Error)) { e = new Error(e); } filterControl?.showFailureMessage(e.message); return Promise.reject(e); }); }); filterControl.attachObserver(this[filterObserverSymbol]); } /** * @private * @param json * @param response * @param filterControl * @returns {Promise<never>|Promise<Awaited<unknown>>} */ function handleIntersectionObserver(json, response, filterControl) { const path = new Pathfinder(json); const codePath = this.getOption("response.path.code"); if (path.exists(codePath)) { const code = `${path.getVia(codePath)}`; if (code && code === "200") { filterControl?.showSuccess(); return Promise.resolve(response); } const messagePath = this.getOption("response.path.message"); if (path.exists(messagePath)) { const message = path.getVia(messagePath); filterControl?.showFailureMessage(message); return Promise.reject(new Error(message)); } return Promise.reject(new Error("Response code is not 200")); } } /** * @private */ function initAutoInit() { const autoInit = this.getOption("features.autoInit"); validateBoolean(autoInit); if (autoInit !== true) return; if (this.getOption("autoInit.intersectionObserver") === true) { initIntersectionObserver.call(this); return; } queueMicrotask(() => { this.read().catch(() => {}); }); } /** * @private */ function initEventHandler() { this[intersectionObserverHandlerSymbol] = (entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { if (entry.intersectionRatio > 0) { this.read(); } // only load once if ( this.getOption("autoInit.oneTime") === true && this[intersectionObserverObserverSymbol] !== undefined ) { this[intersectionObserverObserverSymbol].unobserve(this); } } }); }; } /** * @private */ function initIntersectionObserver() { this.classList.add("intersection-observer"); const options = { root: null, rootMargin: "0px", threshold: 0.1, }; this[intersectionObserverObserverSymbol] = new IntersectionObserver( this[intersectionObserverHandlerSymbol], options, ); this[intersectionObserverObserverSymbol].observe(this); } /** * @private * @param {Error} error * @return {Promise<void>} */ function handleValidationError(error) { const path = this.getOption("response.path.validationErrors"); if (!path) { return Promise.resolve(); } if (!(error instanceof DataFetchError)) { return Promise.resolve(); } const messagePath = this.getOption("response.path.validationMessage"); return error .getBody() .then((body) => { let json = body; if (typeof body === "string") { try { json = JSON.parse(body); } catch { return; } } if (!isObject(json)) { return; } const pf = new Pathfinder(json); if (!pf.exists(path)) { return; } const rawErrors = pf.getVia(path); if (!isObject(rawErrors)) { return; } const normalizedErrors = {}; for (const [key, value] of Object.entries(rawErrors)) { if (isObject(value)) { normalizedErrors[key] = { code: value.code || null, message: value.message || `${value}`, }; } else { normalizedErrors[key] = { code: null, message: `${value}` }; } } let message = null; if (messagePath && pf.exists(messagePath)) { message = pf.getVia(messagePath); } this.dispatchEvent( new CustomEvent("monster-datasource-validation", { bubbles: true, detail: { errors: normalizedErrors, raw: rawErrors, message, payload: json, }, }), ); }) .catch(() => {}); } /** * @private * @return {boolean} */ function lookupDebugEnabled() { return this.getOption("lookups.debug", false) === true; } /** * @private * @param {number} requestId * @return {Promise<void>} */ async function applyLookups(requestId) { if (!this.getOption("lookups.enabled", false)) { return; } const columns = this.getOption("lookups.columns", {}); if (!isObject(columns)) { return; } const columnEntries = Object.entries(columns).filter(([, cfg]) => isObject(cfg), ); if (columnEntries.length === 0) { return; } const resolverColumns = []; const remoteColumns = []; for (const [name, cfg] of columnEntries) { if (isFunction(cfg.resolve)) { resolverColumns.push([name, cfg]); } else if (isString(cfg.url) && cfg.url !== "") { remoteColumns.push([name, cfg]); } } if (resolverColumns.length > 0) { updateLookupRows.call(this, requestId, (rows) => { for (const [, cfg] of resolverColumns) { const key = cfg.key; const target = cfg.target; if (!isString(key) || !isString(target)) { continue; } for (const row of rows) { const entry = cfg.resolve(row[key], row); if (!entry) { continue; } row[target] = formatLookupValue.call(this, cfg, entry, row); if (isString(cfg.loadingKey)) { row[cfg.loadingKey] = false; } } } }); } if (remoteColumns.length === 0) { return; } await Promise.all( remoteColumns.map(([name, cfg]) => resolveRemoteLookup.call(this, requestId, name, cfg), ), ); } /** * @private * @param {number} requestId * @param {string} name * @param {object} cfg * @return {Promise<void>} */ async function resolveRemoteLookup(requestId, name, cfg) { const key = cfg.key; const target = cfg.target; if (!isString(key) || !isString(target)) { return; } const cache = getLookupCache.call(this, name); const pending = getLookupPending.call(this, name); const pendingFetches = getLookupPendingFetches.call(this, name); const ids = collectLookupIds.call( this, key, this.getOption("lookups.sourcePath", "dataset"), ); const missingIds = ids.filter((id) => !cache.has(id) && !pending.has(id)); if (missingIds.length === 0) { if (pending.size > 0) { updateLookupRows.call(this, requestId, (rows) => { if (!isString(cfg.loadingKey)) return; for (const row of rows) { if (pending.has(String(row[key]))) { row[cfg.loadingKey] = true; } } }); if (pendingFetches.size > 0) { const fetches = Array.from(pendingFetches); await Promise.all(fetches.map((promise) => promise.catch(() => null))); } } applyLookupCache.call(this, requestId, cfg, cache); return; } updateLookupRows.call(this, requestId, (rows) => { if (!isString(cfg.loadingKey)) return; for (const row of rows) { if ( missingIds.includes(String(row[key])) || pending.has(String(row[key])) ) { row[cfg.loadingKey] = true; } } }); missingIds.forEach((id) => pending.add(id)); const fetchPromise = fetchLookupEntries.call(this, cfg, missingIds); pendingFetches.add(fetchPromise); let response; try { response = await fetchPromise; } finally { pendingFetches.delete(fetchPromise); } response.forEach((entry, id) => cache.set(id, entry)); missingIds.forEach((id) => pending.delete(id)); applyLookupCache.call(this, requestId, cfg, cache); } /** * @private * @param {number} requestId * @param {object} cfg * @param {Map} cache */ function applyLookupCache(requestId, cfg, cache) { const key = cfg.key; const target = cfg.target; updateLookupRows.call(this, requestId, (rows) => { for (const row of rows) { const entry = cache.get(String(row[key])); if (entry) { row[target] = formatLookupValue.call(this, cfg, entry, row); } if (isString(cfg.loadingKey)) { row[cfg.loadingKey] = false; } } }); } /** * @private * @param {string} key * @return {string[]} */ function collectLookupIds(key, sourcePath) { const rows = resolveLookupRows.call(this, undefined, sourcePath); if (!isArray(rows)) { return []; } const ids = new Set(); for (const row of rows) { const value = row?.[key]; if (value === undefined || value === null || value === "") { continue; } if (isArray(value)) { value.forEach((entry) => ids.add(String(entry))); } else { ids.add(String(value)); } } return Array.from(ids); } /** * @private * @param {object} cfg * @param {string[]} ids * @return {Promise<Map<string, object>>} */ async function fetchLookupEntries(cfg, ids) { const requestDefaults = this.getOption("lookups.request", {}); const request = { ...requestDefaults, ...(cfg.request || {}), }; const init = isObject(request.init) ? request.init : {}; const url = buildLookupUrl(cfg.url, ids, request); const debug = lookupDebugEnabled.call(this); if (debug) { console.debug("[monster-datasource-rest] lookup fetch", { url, ids, }); } const response = await fetch(url, init); if (!response.ok) { if (debug) { console.debug("[monster-datasource-rest] lookup failed", { url, status: response.status, }); } return new Map(); } let payload; try { payload = await response.json(); } catch (_error) { if (debug) { console.debug("[monster-datasource-rest] lookup invalid json", { url }); } return new Map(); } const responseDefaults = this.getOption("lookups.response", {}); const responseConfig = { ...responseDefaults, ...(cfg.response || {}), }; let entries = payload; if (isString(responseConfig.path) && responseConfig.path !== "") { entries = new Pathfinder(payload).getVia(responseConfig.path); } if (!isArray(entries)) { if (debug) { console.debug("[monster-datasource-rest] lookup no entries", { url, path: responseConfig.path, }); } return new Map(); } const idKey = responseConfig.id || "id"; const result = new Map(); for (const entry of entries) { if (!entry || entry[idKey] === undefined || entry[idKey] === null) { continue; } result.set(String(entry[idKey]), entry); } if (debug) { console.debug("[monster-datasource-rest] lookup resolved", { url, entries: result.size, }); } return result; } /** * @private * @param {string} url * @param {string[]} ids * @param {object} request * @return {string} */ function buildLookupUrl(url, ids, request) { const idsParam = request.idsParam || "ids"; const idsValue = buildLookupIdsValue(ids, request); if (url.includes("${")) { const formatter = new Formatter({ ids: idsValue }); return formatter.format(url); } const separator = url.includes("?") ? "&" : "?"; return `${url}${separator}${encodeURIComponent(idsParam)}=${encodeURIComponent( idsValue, )}`; } /** * @private * @param {string[]} ids * @param {object} request * @return {string} */ function buildLookupIdsValue(ids, request) { if (isFunction(request.queryBuilder)) { return request.queryBuilder(ids); } const idsTemplate = request.idsTemplate; const idsSeparator = request.idsSeparator || ","; const wrapOpen = request.wrapOpen || ""; const wrapClose = request.wrapClose || ""; let values = ids; if (isString(idsTemplate) && idsTemplate !== "") { values = ids.map((id) => { const formatter = new Formatter({ id }); return formatter.format(idsTemplate); }); } return `${wrapOpen}${values.join(idsSeparator)}${wrapClose}`; } /** * @private * @param {object} cfg * @param {object} entry * @param {object} row * @return {string} */ function formatLookupValue(cfg, entry, row) { if (isFunction(cfg.format)) { return cfg.format(entry, row); } const formatDefaults = this.getOption("lookups.format", {}); const format = isObject(cfg.format) ? cfg.format : {}; const template = format.template || formatDefaults.template || "${name}"; if (!isString(template)) { return ""; } const formatter = new Formatter({ ...entry, row }); const marker = format.marker || formatDefaults.marker; if (marker?.open) { formatter.setMarker(marker.open, marker.close); } return formatter.format(template); } /** * @private * @param {number} requestId * @param {Function} update */ function updateLookupRows(requestId, update) { if (this[readRequestIdSymbol] !== requestId) { return; } const data = this[dataSourceSymbol].get(); const sourcePath = this.getOption("lookups.sourcePath", "dataset"); const next = clone(data); const rows = resolveLookupRows.call(this, next, sourcePath); if (!isArray(rows)) { return; } update(rows); this[dataSourceSymbol].set(next); } /** * @private * @param {object} [data] * @param {string} [sourcePath] * @return {Array|undefined} */ function resolveLookupRows(data, sourcePath) { const source = data || this[dataSourceSymbol].get(); if (isString(sourcePath) && sourcePath !== "") { return new Pathfinder(source).getVia(sourcePath); } if (isArray(source)) { return source; } if (isObject(source) && isArray(source.dataset)) { return source.dataset; } return undefined; } /** * @private * @param {string} name * @return {Map<string, object>} */ function getLookupCache(name) { if (!this[lookupCacheSymbol]) { this[lookupCacheSymbol] = new Map(); } if (!this[lookupCacheSymbol].has(name)) { this[lookupCacheSymbol].set(name, new Map()); } return this[lookupCacheSymbol].get(name); } /** * @private * @param {string} name * @return {Set<string>} */ function getLookupPending(name) { if (!this[lookupPendingSymbol]) { this[lookupPendingSymbol] = new Map(); } if (!this[lookupPendingSymbol].has(name)) { this[lookupPendingSymbol].set(name, new Set()); } return this[lookupPendingSymbol].get(name); } /** * @private * @param {string} name * @return {Set<Promise<Map<string, object>>>} */ function getLookupPendingFetches(name) { if (!this[lookupPendingFetchSymbol]) { this[lookupPendingFetchSymbol] = new Map(); } if (!this[lookupPendingFetchSymbol].has(name)) { this[lookupPendingFetchSymbol].set(name, new Set()); } return this[lookupPendingFetchSymbol].get(name); } /** * @private * @return {string} */ function getTemplate() { // language=HTML return `<slot></slot>`; } registerCustomElement(Rest);