UNPKG

@optionfactory/ful

Version:

- Import the lib via CDN:

1,474 lines (1,420 loc) 109 kB
import { registry, ParsedElement, Attributes, Fragments, Nodes, Rendering } from '@optionfactory/ftl'; class Base64 { static encode(arrayBuffer, dialect) { const d = dialect || Base64.URL_SAFE; const len = arrayBuffer.byteLength; const view = new Uint8Array(arrayBuffer); let res = ''; for (let i = 0; i < len; i += 3) { const v1 = d[view[i] >> 2]; const v2 = d[((view[i] & 3) << 4) | (view[i + 1] >> 4)]; const v3 = d[((view[i + 1] & 15) << 2) | (view[i + 2] >> 6)]; const v4 = d[view[i + 2] & 63]; res += v1 + v2 + v3 + v4; } if (len % 3 === 2) { res = res.substring(0, res.length - 1); } else if (len % 3 === 1) { res = res.substring(0, res.length - 2); } return res; } static decode(str, dialect) { const d = dialect || Base64.URL_SAFE; let nbytes = Math.floor(str.length * 0.75); for (let i = 0; i !== str.length; ++i) { if (str[str.length - i - 1] !== '=') { break; } --nbytes; } const view = new Uint8Array(nbytes); let vi = 0; let si = 0; while (vi < str.length * 0.75) { const v1 = d.indexOf(str.charAt(si++)); const v2 = d.indexOf(str.charAt(si++)); const v3 = d.indexOf(str.charAt(si++)); const v4 = d.indexOf(str.charAt(si++)); view[vi++] = (v1 << 2) | (v2 >> 4); view[vi++] = ((v2 & 15) << 4) | (v3 >> 2); view[vi++] = ((v3 & 3) << 6) | v4; } return view.buffer; } } Base64.STANDARD = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; Base64.URL_SAFE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; class Hex { static decode(hex) { if (hex.length % 2 !== 0) { throw new Error("invalid length"); } const lenInBytes = hex.length / 2; return new Uint8Array(lenInBytes).map((e, i) => { const offset = i * 2; const octet = hex.substring(offset, offset + 2); return parseInt(octet, 16); }); } static encode(bytes, upper) { return Array.from(bytes) .map(b => b.toString(16)) .map(b => upper ? b.toUpperCase() : b) .map(o => o.padStart(2, 0)) .join(''); } } /** * @typedef {{ type: string; context: string?; reason: string; details: any?; }} Problem */ class Failure extends Error { /** * * @param {string} message * @param {Problem[]} problems * @param {*} cause */ constructor(message, problems, cause) { super(message, { cause }); this.name = 'Failure'; this.problems = problems; } dropping(prefix){ return new Failure(this.message, Failure.dropProblemsContext(this.problems, prefix), this); } static dropProblemsContext(problems, prefix){ return problems.map(({type, context, reason, details}) => { const nctx = context?.startsWith(prefix) ? context.substring(prefix.length) : context; return {type, context: nctx, reason, details}; }) } } class MediaType { #type; #subtype; constructor(type, subtype) { this.#type = type; this.#subtype = subtype; } get normalized() { return `${this.#type}/${this.#subtype}`; } get type() { return this.#type; } get subtype() { return this.#subtype; } /** * * @param {string|null|undefined} v * @returns */ static parse(v) { if (!v) { return new MediaType("unknown", "unknown"); } const [prefix, _] = v.split(";"); const [ptype, psubtype] = prefix.trim().split("/"); return new MediaType(ptype.toLowerCase(), psubtype?.toLowerCase()); } } /** * @typedef {Int8Array| Uint8Array| Uint8ClampedArray| Int16Array| Uint16Array| Int32Array| Uint32Array| Float32Array| Float64Array| BigInt64Array| BigUint64Array} TypedArray */ /** * @typedef HttpInterceptor * @property {function(URL,RequestInit|undefined,HttpInterceptorChain):Promise<Response>} intercept */ class HttpClientError extends Failure { /** * @param {string} message * @param {number} status * @param {{ type: string; context: string?; reason: string; details: any?; }[]} problems * @param {Error|undefined} [cause] */ constructor(message, status, problems, cause) { super(message, problems, cause); this.name = 'HttpClientError'; this.status = status; } dropping(prefix){ return new HttpClientError(this.message, this.status, Failure.dropProblemsContext(this.problems, prefix), this); } /** * * @param {string} type * @param {any} cause * @returns */ static of(type, cause) { return new HttpClientError(cause.message, 0, [{ type, context: null, reason: cause.message, details: null }], cause); } /** * Creates an HttpClientError from a Response. * @param {Response} response * @returns an HttpClientError */ static async fromResponse(response) { switch (MediaType.parse(response.headers.get("Content-Type")).normalized) { case 'application/failures+json': { const data = await response.json(); const message = `${response.status} ${response.statusText}: ${data.length} failures`; return new HttpClientError(message, response.status, data); } case 'application/problem+json': { const data = await response.json(); const message = `${response.status} ${response.statusText}: ${data.title} ${data.detail}`; return new HttpClientError(message, response.status, data.problems || [{ type: "GENERIC_PROBLEM", context: null, reason: message, details: null }]); } default: { const text = await response.text(); const message = `${response.status} ${response.statusText}: ${text}`; return new HttpClientError(message, response.status, [{ type: "GENERIC_PROBLEM", context: null, reason: message, details: null }]); } } } } /** * @implements {HttpInterceptor} */ class CsrfTokenInterceptor { #k; #v; constructor() { this.#k = document.querySelector("meta[name='_csrf_header']")?.getAttribute("content"); this.#v = document.querySelector("meta[name='_csrf']")?.getAttribute("content"); } async intercept(url, request, chain) { if (this.#k && this.#v) { request.headers.set(this.#k, this.#v); } return await chain.proceed(url, request); } } /** * @implements {HttpInterceptor} */ class RedirectOnUnauthorizedInterceptor { #redirectUri; /** * @param {string} redirectUri */ constructor(redirectUri) { this.#redirectUri = redirectUri; } async intercept(url, request, chain) { const response = await chain.proceed(url, request); if (response.status === 401) { window.location.href = this.#redirectUri; } return response; } } class HttpClientBuilder { /** * @type {HttpInterceptor[]} */ #interceptors; constructor() { this.#interceptors = []; } withCsrfToken() { this.#interceptors.push(new CsrfTokenInterceptor()); return this; } withRedirectOnUnauthorized(redirectUri) { this.#interceptors.push(new RedirectOnUnauthorizedInterceptor(redirectUri)); return this; } /** * @param {...HttpInterceptor} interceptors */ withInterceptors(...interceptors) { this.#interceptors.push(...interceptors); return this; } build() { return new HttpClient(this.#interceptors); } } /** * @implements {HttpInterceptor} */ class HttpCall { async intercept(url, request, chain) { return await fetch(new Request(url, request)); } } class HttpInterceptorChain { #interceptors; #current; /** * * @param {HttpInterceptor[]} interceptors * @param {number} current */ constructor(interceptors, current) { this.#interceptors = interceptors; this.#current = current; } /** * * @param {URL} url * @param {RequestInit} request * @returns {Promise<Response>} the response */ async proceed(url, request) { const interceptor = this.#interceptors[this.#current]; return await interceptor.intercept(url, request, new HttpInterceptorChain(this.#interceptors, this.#current + 1)); } } class HttpClient { #interceptors; /** * Creates a builder for an HttpClient. * @returns {HttpClientBuilder} the client builder */ static builder() { return new HttpClientBuilder(); } /** * Creates an HttpClient. * @param {HttpInterceptor[]|undefined} interceptors - a list of interceptors to be registered for every request performed by the created client. */ constructor(interceptors) { this.#interceptors = interceptors || []; } /** * Performs an HTTP exchange. * @async * @param {string} uri - the (possibly relative) request url * @param {RequestInit|undefined} options - fetch options * @param {HttpInterceptor[]|undefined} interceptors - the HttpInterceptors to be registered for this exchange. * @returns {Promise<Response>} the response */ async exchange(uri, options, interceptors) { const is = [...this.#interceptors, ...interceptors || [], new HttpCall()]; const chain = new HttpInterceptorChain(is, 0); const url = new URL(new Request(uri).url); return await chain.proceed(url, options ?? {}); } /** * Creates a request builder. * @param {string} method - the HTTP method to be used * @param {string} uri - the (possibly relative) request url * @returns {HttpRequestBuilder} the request builder */ request(method, uri) { return HttpRequestBuilder.create(this, method, uri); } /** * Creates a request builder. * @param {string} uri - the (possibly relative) request url * @returns {HttpRequestBuilder} the request builder */ get(uri) { return HttpRequestBuilder.create(this, 'GET', uri); } /** * Creates a request builder. * @param {string} uri - the (possibly relative) request url * @returns {HttpRequestBuilder} the request builder */ head(uri) { return HttpRequestBuilder.create(this, 'HEAD', uri); } /** * Creates a request builder. * @param {string} uri - the (possibly relative) request url * @returns {HttpRequestBuilder} the request builder */ post(uri) { return HttpRequestBuilder.create(this, 'POST', uri); } /** * Creates a request builder. * @param {string} uri - the (possibly relative) request url * @returns {HttpRequestBuilder} the request builder */ put(uri) { return HttpRequestBuilder.create(this, 'PUT', uri); } /** * Creates a request builder. * @param {string} uri - the (possibly relative) request url * @returns {HttpRequestBuilder} the request builder */ patch(uri) { return HttpRequestBuilder.create(this, 'PATCH', uri); } /** * Creates a request builder. * @param {string} uri - the (possibly relative) request url * @returns {HttpRequestBuilder} the request builder */ delete(uri) { return HttpRequestBuilder.create(this, 'DELETE', uri); } } /** * * @param {Response} response * @param {'text'|'json'|'blob'|'arrayBuffer'} type * @returns */ const unmarshal = async (response, type) => { try { return await response[type](); } catch (ex) { throw HttpClientError.of("UNMARSHALING_PROBLEM", ex); } }; class HttpRequestBuilder { #client; #method; #uri; #params; #headers; #body; #options; #interceptors; /** * Creates an HttpRequestBuilder. * @param {HttpClient} client * @param {string} method - the HTTP method to be used * @param {string} uri - the (possibly relative) request url * @returns {HttpRequestBuilder} the builder */ static create(client, method, uri) { return new HttpRequestBuilder( client, method, uri, new URLSearchParams(), new Headers(), undefined, {}, [] ); } /** * Creates an HttpRequestBuilder. * @param {HttpClient} client * @param {string} method - the HTTP method to be used * @param {string} uri - the (possibly relative) request url * @param {URLSearchParams} params * @param {Headers} headers * @param {any} body * @param {Omit<RequestInit,"headers"|"method"|"body">} options * @param {HttpInterceptor[]} interceptors */ constructor(client, method, uri, params, headers, body, options, interceptors) { this.#client = client; this.#method = method; this.#uri = uri; this.#params = params; this.#body = body; this.#headers = headers; this.#options = options; this.#interceptors = interceptors; } /** * Add all passed headers to the request, overriding existing ones if that key already exists. Null and undefined values cause the key to be removed. * @param {HeadersInit} hs * @returns {HttpRequestBuilder} this builder */ headers(hs) { for (const [k, v] of new Headers(hs).entries()) { if (v === null || v === undefined) { this.#headers.delete(k); } else { this.#headers.set(k, v); } } return this; } /** * Adds an header to the request, overriding it if it already exists. Null and undefined values cause the key to be removed * @param {string} k * @param {string} v * @returns {HttpRequestBuilder} this builder */ header(k, v) { if (v === null || v === undefined) { this.#headers.delete(k); } else { this.#headers.set(k, v); } return this; } /** * Add all query parameters to the request, overriding existing ones if that key already exists. Null and undefined values cause the key to be removed * @param {URLSearchParams|Record<string,string>|string[][]|string} ps * @returns {HttpRequestBuilder} this builder */ params(ps) { for (const [k, v] of new URLSearchParams(ps).entries()) { if (v === null || v === undefined) { this.#params.delete(k); } else { this.#params.set(k, v); } } return this; } /** * Adds a query parameter to the request, overriding it if it already exists. Empty vs, or a single null or undefined value cause the key to be removed. * @param {string} k * @param {...string} vs * @returns {HttpRequestBuilder} this builder */ param(k, ...vs) { if (vs.length === 0 || vs[0] === null || vs[0] === undefined) { this.#params.delete(k); return this; } for (const v of vs) { this.#params.append(k, v); } return this; } /** * Sets the request body. * `Content-Type: multipart/form-data` header is automatically added by fetch when data is a FormData instance if not explicitly set. * `Content-Type: application/x-www-form-urlencoded` header is automatically added by fetch when data is an URLSearchParams instance if not explicitly set. * `Content-Type: text/plain` header is automatically added by fetch when data is a string instance if not explicitly set. * @param {string|ArrayBuffer|Blob|DataView|File|FormData|TypedArray|URLSearchParams|ReadableStream} data * @returns {HttpRequestBuilder} this builder */ body(data) { this.#body = data; return this; } /** * Sets the request body that will be serialized as json. Calling this method adds the `Content-Type application/json` header for the request. * @param {any} body - the body to be serialized as json * @returns {HttpRequestBuilder} this builder */ json(body) { this.#headers.set("Content-Type", "application/json"); this.#body = JSON.stringify(body); return this; } /** * Sets the request body as a FormData configured using the callback. * `Content-Type: multipart/form-data` header is automatically added by fetch if not explicitly set. * @param {function(HttpMultipartRequestCustomizer):void} callback */ multipart(callback) { const formData = new FormData(); const builder = new HttpMultipartRequestCustomizer(formData); callback(builder); this.#body = formData; return this; } /** * Sets a fetch options for the request. * @param {Omit<RequestInit,"headers"|"method"|"body">} kvs * @returns {HttpRequestBuilder} this builder */ options(kvs) { for (const [k, v] of Object.entries(kvs)) { this.#options[k] = v; } return this; } /** * Sets a fetch option for the request. * @param {keyof Omit<RequestInit,"headers"|"method"|"body">} k * @param {*} v * @returns {HttpRequestBuilder} this builder */ option(k, v) { this.#options[k] = v; return this; } /** * Adds interceptors to the request. * @param {[HttpInterceptor]} is - the interceptor to be regisered * @returns {HttpRequestBuilder} this builder */ interceptors(is) { for (const i of is) { this.#interceptors.push(i); } return this; } /** * Adds an interceptor to the request. * @param {HttpInterceptor} i - the interceptor to be regisered * @returns {HttpRequestBuilder} this builder */ interceptor(i) { this.#interceptors.push(i); return this; } /** * Performs an HTTP exchange using the configured client, request and interceptors. * @returns {Promise<Response>} the response */ async exchange() { const uri = this.#params.size ? `${this.#uri}?${this.#params}` : this.#uri; const opts = { ...this.#options, headers: this.#headers, method: this.#method, body: this.#body, }; return await this.#client.exchange(uri, opts, this.#interceptors); } /** * Performs an HTTP exchange using the configured client request, and interceptos throwing a failure when response status is not in the 200-299 range. * @returns {Promise<Response>} the response */ async fetch() { const uri = this.#params.size ? `${this.#uri}?${this.#params}` : this.#uri; const opts = { ...this.#options, headers: this.#headers, method: this.#method, body: this.#body, }; try { const response = await this.#client.exchange(uri, opts, this.#interceptors); if (!response.ok) { throw await HttpClientError.fromResponse(response); } return response; } catch (ex) { if (ex instanceof Failure) { throw ex; } throw HttpClientError.of("CONNECTION_PROBLEM", ex); } } /** * Performs an HTTP exchange using the configured client request, and interceptos throwing a failure when response status is not in the 200-299 range. * @returns {Promise<string>} the response body, as text */ async fetchText() { const response = await this.fetch(); return await unmarshal(response, 'text'); } /** * Performs an HTTP exchange using the configured client request, and interceptos throwing a failure when response status is not in the 200-299 range. * @returns {Promise<any>} the response body, deserialized as JSON */ async fetchJson() { const response = await this.fetch(); return await unmarshal(response, 'json'); } /** * Performs an HTTP exchange using the configured client request, and interceptos throwing a failure when response status is not in the 200-299 range. * @returns {Promise<Blob>} the response body, as a Blob */ async fetchBlob() { const response = await this.fetch(); return await unmarshal(response, 'blob'); } /** * Performs an HTTP exchange using the configured client request, and interceptos throwing a failure when response status is not in the 200-299 range. * @returns {Promise<ArrayBuffer>} the response body, as an ArrayBuffer */ async fetchArrayBuffer() { const response = await this.fetch(); return await unmarshal(response, 'arrayBuffer'); } } class HttpMultipartRequestCustomizer { #formData; /** * * @param {FormData} formData */ constructor(formData) { this.#formData = formData; } /** * Appends a value to the FormData. * @param {string} name * @param {*} value * @returns this builder */ field(name, value) { this.#formData.append(name, value); return this; } /** * Appends a Blob to the FormData. * If `filename` is omitted, FormData defaults are applied: * The default filename for Blob objects is "blob"; * The default filename for File objects is the file's filename. * @param {string} name * @param {Blob} value * @param {string|undefined} filename * @returns this builder */ blob(name, value, filename) { this.#formData.append(name, value, filename); return this; } /** * Appends multiple Blobs to the FormData with the same name. * The default filename for Blob objects is "blob"; * The default filename for File objects is the file's filename. * @param {string} name * @param {Blob[]} values * @returns this builder */ blobs(name, values) { for (let v of values) { this.#formData.append(name, v); } return this; } /** * Appends a JSON serialized blob to the FormData. * @param {string} name * @param {any} value * @param {string|undefined} filename * @returns this builder */ json(name, value, filename) { const blob = new Blob([JSON.stringify(value)], { type: 'application/json' }); this.#formData.append(name, blob, filename); return this; } } class LocalStorage extends Storage { static save(k, v) { localStorage.setItem(k, JSON.stringify(v)); } static load(k) { const got = localStorage.getItem(k); return got === null ? undefined : JSON.parse(got); } static remove(k) { localStorage.removeItem(k); } static pop(k) { const decoded = LocalStorage.load(k); LocalStorage.remove(k); return decoded; } } class SessionStorage extends Storage { static save(k, v) { sessionStorage.setItem(k, JSON.stringify(v)); } static load(k) { const got = sessionStorage.getItem(k); return got === null ? undefined : JSON.parse(got); } static remove(k) { sessionStorage.removeItem(k); } static pop(k) { const decoded = SessionStorage.load(k); SessionStorage.remove(k); return decoded; } } class VersionedLocalStorage { static save(key, revision, data){ LocalStorage.save(key, {revision, data}); } static load(key, revision){ const stored = LocalStorage.load(key); if(stored === undefined){ return undefined; } if(stored.revision !== revision){ localStorage.removeItem(key); return undefined; } return stored.data; } } class VersionedSessionStorage { static save(key, revision, data){ SessionStorage.save(key, {revision, data}); } static load(key, revision){ const stored = SessionStorage.load(key); if(stored === undefined){ return undefined; } if(stored.revision !== revision){ localStorage.removeItem(key); return undefined; } return stored.data; } } class AuthorizationCodeFlow { static forKeycloak(clientId, realmBaseUrl, redirectUri, maybeScope) { const scope = maybeScope ?? "openid profile"; return new AuthorizationCodeFlow(clientId, scope, { auth: new URL("protocol/openid-connect/auth", realmBaseUrl), token: new URL("protocol/openid-connect/token", realmBaseUrl), logout: new URL("protocol/openid-connect/logout", realmBaseUrl), registration: new URL("protocol/openid-connect/registrations", realmBaseUrl), redirect: redirectUri }); } constructor(clientId, scope, { auth, token, registration, logout, redirect }) { this.clientId = clientId; this.scope = scope; this.uri = { auth, token, registration, logout, redirect }; } async action(uri, additionalParams) { const pkceVerifier = Base64.encode(crypto.getRandomValues(new Uint8Array(32)).buffer); const pkceChallenge = Base64.encode(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(pkceVerifier))); const state = this.clientId + Base64.encode(crypto.getRandomValues(new Uint8Array(16)).buffer); SessionStorage.save(`${AuthorizationCodeFlow.PKCE_AND_STATE_KEY}-${this.clientId}`, { state: state, verifier: pkceVerifier }); const url = new URL(uri); url.searchParams.set("client_id", this.clientId); url.searchParams.set("redirect_uri", this.uri.redirect); url.searchParams.set("response_type", 'code'); url.searchParams.set("scope", this.scope); url.searchParams.set("state", state); url.searchParams.set("code_challenge", pkceChallenge); url.searchParams.set("code_challenge_method", 'S256'); Object.entries(additionalParams || {}).forEach(kv => { url.searchParams.set(kv[0], kv[1]); }); window.location.href = url.toString(); } async registration(additionalParams) { await this.action(this.uri.registration, additionalParams); } async applicationInitiatedAction(kcAction, additionalParams) { await this.action(this.uri.auth, { ...additionalParams, kc_action: kcAction, }); } async #tokenExchange(code, state) { window.history.replaceState('', "", this.uri.redirect); const stateAndVerifier = SessionStorage.pop(`${AuthorizationCodeFlow.PKCE_AND_STATE_KEY}-${this.clientId}`); if (stateAndVerifier.state !== state) { throw new Error("State mismatch"); } const response = await fetch(this.uri.token, { method: "POST", headers: { "Content-Type": 'application/x-www-form-urlencoded' }, body: new URLSearchParams([ ["client_id", this.clientId], ["code", code], ["grant_type", "authorization_code"], ["code_verifier", stateAndVerifier.verifier], ["state", stateAndVerifier.state], ["redirect_uri", this.uri.redirect] ]) }); if (!response.ok) { const text = await response.text(); throw new Error("Error:" + response.status + ": " + text); } const token = await response.json(); return new AuthorizationCodeFlowSession(this.clientId, token, this.uri); } async ensureLoggedIn() { const url = new URL(window.location.href); const code = url.searchParams.get("code"); if (code && SessionStorage.load(`${AuthorizationCodeFlow.PKCE_AND_STATE_KEY}-${this.clientId}`)) { //if callback from keycloak and we have our state still stored const state = url.searchParams.get("state"); return await this.#tokenExchange(code, state); } //if not authorized await this.action(this.uri.auth, {}); return null; } } AuthorizationCodeFlow.PKCE_AND_STATE_KEY = "state-and-verifier"; class AuthorizationCodeFlowSession { static parseToken(token) { const [rawHeader, rawPayload, signature] = token.split("."); const utf8decoder = new TextDecoder("utf-8"); return { header: JSON.parse(utf8decoder.decode(Base64.decode(rawHeader, Base64.STANDARD))), payload: JSON.parse(utf8decoder.decode(Base64.decode(rawPayload, Base64.STANDARD))), signature: signature }; } constructor(clientId, t, { token, logout, redirect }) { this.clientId = clientId; this.token = t; this.idToken = AuthorizationCodeFlowSession.parseToken(t.id_token); this.accessToken = AuthorizationCodeFlowSession.parseToken(t.access_token); this.refreshToken = AuthorizationCodeFlowSession.parseToken(t.refresh_token); this.uri = { token, logout, redirect }; this.refreshCallback = null; } onRefresh(callback) { this.refreshCallback = callback; } async refresh() { const response = await fetch(this.uri.token, { method: "POST", headers: { "Content-Type": 'application/x-www-form-urlencoded' }, body: new URLSearchParams([ ["client_id", this.clientId], ["grant_type", "refresh_token"], ["refresh_token", this.token.refresh_token] ]) }); if (!response.ok) { const text = await response.text(); throw new Error("Error:" + response.status + ": " + text); } const token = await response.json(); this.token = token; this.idToken = AuthorizationCodeFlowSession.parseToken(token.id_token); this.accessToken = AuthorizationCodeFlowSession.parseToken(token.access_token); this.refreshToken = AuthorizationCodeFlowSession.parseToken(token.refresh_token); if (this.refreshCallback) { this.refreshCallback(this.token, this.accessToken, this.refreshToken); } } shouldBeRefreshed(gracePeriod) { const now = new Date().getTime(); const refreshTokenExpiresAt = this.refreshToken.payload.exp * 1000; const expired = now > refreshTokenExpiresAt; const shouldRefresh = now - gracePeriod > refreshTokenExpiresAt; return !expired && shouldRefresh; } async refreshIf(gracePeriod) { if (!this.shouldBeRefreshed(gracePeriod)) { return; } await this.refresh(); } logout() { const url = new URL(this.uri.logout); url.searchParams.set("post_logout_redirect_uri", this.uri.redirect); url.searchParams.set("id_token_hint", this.token.id_token); window.location.href = url.toString(); } bearerToken() { return `Bearer ${this.token.access_token}`; } interceptor(gracePeriodBefore, gracePeriodAfter) { return new AuthorizationCodeFlowInterceptor(this, gracePeriodBefore, gracePeriodAfter); } } class AuthorizationCodeFlowInterceptor { #session; #gracePeriodBefore; #gracePeriodAfter; constructor(session, gracePeriodBefore, gracePeriodAfter) { this.#session = session; this.#gracePeriodBefore = gracePeriodBefore || 2000; this.#gracePeriodAfter = gracePeriodAfter || 30000; } async intercept(url, request, chain) { await this.#session.refreshIf(this.#gracePeriodBefore); request.headers.set("Authorization", this.#session.bearerToken()); const response = await chain.proceed(url, request); await this.#session.refreshIf(this.#gracePeriodAfter); return response; } } class AsyncEvents { static async fireAsync(el, evt) { el.dispatchEvent(evt); return await evt.async?.promise; } /** * * @param {*} el * @param {*} type * @param {*} fn returning the result * @param {*} options * @returns */ static asyncOn(el, type, fn, options) { const listener = async (event) => { let resolve, reject; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); event.async = { promise }; try { //@ts-ignore resolve(await fn(event)); } catch (e) { //@ts-ignore reject(e); } }; el.addEventListener(type, listener, options); return listener; } /** * * @param {*} el * @param {*} type * @param {*} listener the listener returned by asyncOn * @param {*} options */ static asyncOff(el, type, listener, options) { el.removeEventListener(type, listener, options); } static mixInto(...classes) { for (const k of classes) { Object.assign(k.prototype, { async fireAsync(evt) { return await AsyncEvents.fireAsync(this, evt); }, asyncOn(type, fn, options) { return AsyncEvents.asyncOn(this, type, fn, options); }, asyncOff(type, listener, options) { return AsyncEvents.asyncOff(this, type, listener, options); } }); } } } class Timing { static sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } static DEBOUNCE_DEFAULT = 0; static DEBOUNCE_IMMEDIATE = 1; /** * Executes only after a period of inactivity (pause in events). * Respond to the "end" of a series of events. * @param {*} timeoutMs * @param {*} func * @param {*} [options] * @returns {[function, function]} */ static debounce(timeoutMs, func, options) { const opts = options ?? Timing.DEBOUNCE_DEFAULT; let tid = null; let args = []; let previousTimestamp = 0; const later = () => { const elapsed = new Date().getTime() - previousTimestamp; if (timeoutMs > elapsed) { tid = setTimeout(later, timeoutMs - elapsed); return; } tid = null; if (opts !== Timing.DEBOUNCE_IMMEDIATE) { func(...args); } // This check is needed because `func` can recursively invoke `debounced`. if (tid === null) { args = []; } }; const debounced = function () { args = [...arguments]; previousTimestamp = new Date().getTime(); if (tid === null) { tid = setTimeout(later, timeoutMs); if (opts === Timing.DEBOUNCE_IMMEDIATE) { func(...args); } } }; const abort = () => clearTimeout(tid); return [debounced, abort]; } static THROTTLE_DEFAULT = 0; static THROTTLE_NO_LEADING = 1; static THROTTLE_NO_TRAILING = 2; /** * Executes at most once per specified time interval, regardless of ongoing events. * @param {*} timeoutMs * @param {*} func * @param {*} [options] * @returns {[function, function]} */ static throttle(timeoutMs, func, options) { const opts = options ?? Timing.THROTTLE_DEFAULT; let tid = null; let args = []; let previousTimestamp = 0; const later = () => { previousTimestamp = (opts & Timing.THROTTLE_NO_LEADING) ? 0 : new Date().getTime(); tid = null; func(...args); if (tid === null) { args = []; } }; const throttled = function () { const now = new Date().getTime(); if (!previousTimestamp && (opts & Timing.THROTTLE_NO_LEADING)) { previousTimestamp = now; } const remaining = timeoutMs - (now - previousTimestamp); args = [...arguments]; if (remaining <= 0 || remaining > timeoutMs) { if (tid !== null) { clearTimeout(tid); tid = null; } previousTimestamp = now; func(...args); if (tid === null) { args = []; } } else if (tid === null && !(opts & Timing.THROTTLE_NO_TRAILING)) { tid = setTimeout(later, remaining); } }; const abort = () => clearTimeout(tid); return [throttled, abort]; } } class Loaders { static fromAttributes(el, defaultLoader, options) { const http = registry.component("http-client"); const requestMapper = el.hasAttribute("request-mapper") ? registry.component(el.getAttribute("request-mapper")) : v => v; const responseMapper = el.hasAttribute("response-mapper") ? registry.component(el.getAttribute("response-mapper")) : v => v; const loaderClass = registry.component(el.getAttribute("loader") ?? defaultLoader); return loaderClass.create({ el, http, requestMapper, responseMapper, options: options ?? {} }); } } class Bindings { /** * @param {{ [x: string]: any; }} obj * @param {string} prefix * @param {Set<String>} stops * @return {{ [x: string]: any; }} */ static flatten(obj, prefix, stops) { return Object.keys(obj).reduce((acc, k) => { const pre = prefix.length ? prefix + '.' + k : k; if (!stops.has(pre) && typeof obj[k] === 'object' && obj[k] !== null) { Object.assign(acc, Bindings.flatten(obj[k], pre, stops)); } else { acc[pre] = obj[k]; } return acc; }, {}); } /** * @param {any} result * @param {string} path * @param {any} value */ static providePath(result, path, value) { const keys = path.split(".").map((k) => /^[0-9]+$/.test(k) ? +k : k); let current = result ?? {}; let previous = null; for (let i = 0; ; ++i) { const ckey = keys[i]; const pkey = keys[i - 1]; if (Number.isInteger(ckey) && !Array.isArray(current)) { if (previous !== null) { previous[pkey] = current = []; } else { result = current = []; } } if (i === keys.length - 1) { //when value is undefined we only want to define the property if it's not defined current[ckey] = value !== undefined ? value : (ckey in current ? current[ckey] : null); return result; } if (current[ckey] === undefined) { current[ckey] = {}; } previous = current; current = current[ckey]; } } /** * * @param {Element & {dataset?: any} & {checked?: boolean} & {value?: any}} el * @returns */ static extract(el) { if (el.getAttribute('type') === 'radio') { if (!el.checked) { return undefined; } return el.dataset['fulBindType'] === 'boolean' ? el.value === 'true' : el.value; } if (el.getAttribute('type') === 'checkbox') { return el.checked; } if (el.dataset['fulBindType'] === 'boolean') { return !el.value ? null : el.value === 'true'; } if (el.tagName === 'INPUT' || el.tagName === 'SELECT') { return el.value === '' || el.value === undefined ? null : el.value; } return el.value; } /** * * @param {HTMLFormElement} form * @returns */ static extractFrom(form){ let result = {}; for(const el of form.elements){ if(!el.hasAttribute("name") || el.matches(":disabled")){ continue; } result = Bindings.providePath(result, /** @type {string} */(el.getAttribute('name')), Bindings.extract(el)); } return result; } /** * * @param {Element & {checked?: boolean} & {value?: any}} el * @returns */ static mutate(el, raw) { if (el.getAttribute('type') === 'radio') { el.checked = el.getAttribute('value') === raw; return; } if (el.getAttribute('type') === 'checkbox') { el.checked = raw; return; } el.value = raw; } static mutateIn(form, values){ const names = Array.from(form.elements) .map(el => el.getAttribute("name")) .filter(n => n); for (const [flattenedKey, value] of Object.entries(Bindings.flatten(values, '', new Set(names)))) { for(const el of form.querySelectorAll(`[name='${CSS.escape(flattenedKey)}']`)){ Bindings.mutate(el, value); } } } static errors(form, es, scrollOnError){ const fieldErrors = es.filter(e => e.type === 'FIELD_ERROR' || e.type === 'INVALID_FORMAT'); const globalErrors = es.filter(e => e.type !== 'FIELD_ERROR' && e.type !== 'INVALID_FORMAT'); form.querySelectorAll(`[name]`).forEach(el => el.setCustomValidity?.("")); form.querySelectorAll("ful-errors").forEach(el => { el.replaceChildren(); el.setAttribute('hidden', ''); }); fieldErrors.forEach(e => { const name = e.context.replace("[", ".").replace("].", ".").replace("]", ""); const parts = name.split("."); for (let i = parts.length; i != 0; --i) { const prefix = parts.slice(0, i).join("."); const suffix = parts.slice(i, parts.length).join("."); form.querySelectorAll(`[name='${CSS.escape(prefix)}']`).forEach(input => input.setCustomValidity?.(e.reason, suffix)); } }); form.querySelectorAll("ful-errors").forEach(el => { const hel = /** @type HTMLElement} */ (el); hel.innerText = globalErrors.map(e => e.reason).join("\n"); if (globalErrors.length !== 0) { el.removeAttribute('hidden'); } }); if (es.length == 0 || !scrollOnError) { return; } Array.from(form.querySelectorAll(`:invalid`)).sort((a,b) => a.getBoundingClientRect().y - b.getBoundingClientRect().y)[0]?.focus(); } } class RemoteJsonFormLoader { #http; #url; #method; #requestMapper; #responseMapper; constructor(http, url, method, requestMapper, responseMapper) { this.#http = http; this.#url = url; this.#method = method; this.#requestMapper = requestMapper; this.#responseMapper = responseMapper; } prepare(values, form) { return this.#requestMapper(values, form); } async submit(values, form) { return await this.#http.request(this.#method, this.#url) .json(values) .fetch() } transform(response, form) { return this.#responseMapper(response, form); } } class LocalFormLoader { #requestMapper; #responseMapper; constructor(requestMapper, responseMapper) { this.#requestMapper = requestMapper; this.#responseMapper = responseMapper; } async prepare(values, form) { return await this.#requestMapper(values, form); } async submit(values, form, response) { return response; } async transform(response, form) { return await this.#responseMapper(response, form); } } class FormLoader { static create({ el, http, requestMapper, responseMapper }) { const url = el.getAttribute("action"); if (!url) { return new LocalFormLoader(requestMapper, responseMapper); } const method = el.getAttribute("method") ?? 'POST'; return new RemoteJsonFormLoader(http, url, method, requestMapper, responseMapper); } } class Form extends ParsedElement { form; render() { const form = this.form = document.createElement('form'); form.setAttribute("novalidate", ""); Attributes.forward('form-', this, form); form.replaceChildren(...this.childNodes); form.addEventListener('submit', async (e) => { e.preventDefault(); e.stopPropagation(); await this.submit(); }); if (this.hasAttribute("clear-invalid-on-change")) { this.addEventListener('change', (/** @type any */evt) => { evt.target.setCustomValidity?.(""); }); } this.replaceChildren(form); } async submit() { this.spinner(true); try { const loader = Loaders.fromAttributes(this, 'loaders:form'); const values = this.values; let request = await loader.prepare(values, this); try { const se = new CustomEvent('submit', { bubbles: true, cancelable: true, detail: { values, request } }); if (!this.dispatchEvent(se)) { return; } const sre = new CustomEvent('submit:requested', { bubbles: true, cancelable: false, detail: { values: se.detail.values, request: se.detail.request} }); let response = await AsyncEvents.fireAsync(this, sre); request = sre.detail.request; response = await loader.submit(request, this, response); const mapped = await loader.transform(response, this); this.dispatchEvent(new CustomEvent('submit:success', { bubbles: true, cancelable: false, detail: { values, request, response: mapped } })); } catch (e) { this.dispatchEvent(new CustomEvent('submit:failure', { bubbles: true, cancelable: false, detail: { values, request, exception: e } })); if (e instanceof Failure) { this.errors = e.problems; } console.warn("failed to submit form", this, "reason:", e); } } finally { this.spinner(false); } } reset(){ this.form.reset(); } spinner(spin) { this.querySelectorAll('ful-spinner').forEach(el => { const hel = /** @type HTMLElement */ (el); hel.hidden = !spin; }); this.querySelectorAll('[type=submit],[type=reset]').forEach(el => { const hel = /** @type HTMLButtonElement */ (el); hel.disabled = spin; }); } set values(vs) { Bindings.mutateIn(this.form, vs); } get values() { return Bindings.extractFrom(this.form); } set errors(es) { Bindings.errors(this.form, es, this.hasAttribute('scroll-on-error')); } } class Input extends ParsedElement { static observed = ['value', 'readonly:presence']; static slots = true; static template = ` <div class="form-label"> <label>{{{{ slots.default }}}}</label> {{{{ slots.info }}}} </div> <div class="input-group"> <span data-tpl-if="slots.ibefore" class="input-group-text">{{{{ slots.ibefore }}}}</span> {{{{ slots.before }}}} <input data-tpl-if="type != 'textarea'" class="form-control" data-tpl-type="type" placeholder=" " form=""> <textarea data-tpl-if="type == 'textarea'" class="form-control" placeholder=" " form=""></textarea> {{{{ slots.after }}}} <span data-tpl-if="slots.iafter" class="input-group-text">{{{{ slots.iafter }}}}</span> </div> <ful-field-error></ful-field-error> `; static formAssociated = true; _input; _fieldError; constructor() { super(); this.internals = this.attachInternals(); this.internals.role = 'presentation'; } _type() { return this.getAttribute("type") ?? 'text'; } _fragment(type, slots) { return this.template().withOverlay({ type, slots }).render(); } render({ slots, observed, disabled }) { const type = this._type(); const fragment = this._fragment(type, slots); this._input = fragment.querySelector("input,textarea"); Attributes.forward('input-', this, this._input); this.disabled = disabled; this.readonly = observed.readonly; this.value = observed.value; this._input.addEventListener('change', (evt) => {