@optionfactory/ful
Version:
- Import the lib via CDN:
1,474 lines (1,420 loc) • 109 kB
JavaScript
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) => {