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