import { TypeMap, Watchers, WiredProperties, WiredMethods, AirwireException, AirwirePromise, ComponentResponse, Magic, TypeName, TypeNames } from 'airwire' export class Component { public proxy: any; public loading: boolean = false; public errors: Record = {}; public _proxyTarget: Component; public watchers: Watchers = { responses: [], errors: [] }; public pausedRequests: boolean = false; public pendingChanges: Partial<{ [key in keyof WiredProperties]: any }> = {}; public pendingCalls: Partial<{ [key in keyof WiredMethods]: any }> = {}; public readonly: Partial<{ [key in keyof WiredProperties]: any }> = {}; public reactive: CallableFunction; constructor( public alias: keyof TypeMap, public state: any, reactive: CallableFunction|null = null, ) { this.reactive = reactive ?? window.Airwire.reactive; this.readonly = state.readonly ?? {}; delete this.state.readonly; let component: Component = this._proxyTarget = this.reactive(this); window.Airwire.components[alias] ??= []; window.Airwire.components[alias]?.push(component as any); // We never use `this` in this class, because we always want to refer to the singleton reactive proxy component.watch(response => { let mount = (response.metadata.calls as any)?.mount; if (mount) { component.replaceState(mount, response.metadata.readonly); } }); this.proxy = new Proxy(component.state, { get(target, property: string | symbol) { if (property === 'deferred') { return new Proxy(component.state, { get(target, property) { return component.proxy[property] }, set(target, property, value) { component.pendingChanges[property as keyof WiredProperties] = value; return true } }); } if (property === 'readonly') { return component.readonly; } if (property === '$component') { return component; } // Methods are returned using wrapper methods bypass the Proxy let methods = ['watch', 'defer', 'refresh', 'remount']; if (typeof property === 'string' && methods.includes(property)) { return function (...args: any[]) { return component[property as keyof typeof component](...args); }; } // Whitelisted Component properties let properties = ['errors', 'loading']; if (typeof property === 'string' && properties.includes(property)) { return component[property as keyof typeof component]; } if (typeof property === 'string' && Object.keys(component.state).includes(property)) { return component.state[property]; } if (typeof property === 'string' && Object.keys(component.readonly).includes(property)) { return component.readonly[property as keyof WiredProperties]; } if (typeof property === 'string' && !property.startsWith('__v') && property !== 'toJSON') { return function (...args: any[]) { return component.call.apply(component, [ property as keyof WiredMethods, ...args ]); } } }, set(target, property: string, value) { component.update(property as keyof WiredProperties, value); return true } }) } public update(property: keyof WiredProperties, value: any): Promise> | null { this.state[property] = value; if (this.pausedRequests) { this.pendingChanges[property] = value; return null; } return this.request(property, { changes: { [property]: value } }, (json: ComponentResponse) => { if (json?.metadata?.exceptions) { return Promise.reject(json.metadata.exceptions); } return json }) } public call(method: keyof WiredMethods, ...args: any[]): AirwirePromise | null { if (this.pausedRequests) { this.pendingCalls[method] = args; return null; } return this.request(method, { calls: { [method]: args } }, (json: ComponentResponse) => { if (json?.metadata?.exceptions) { return Promise.reject(json.metadata.exceptions[method] ?? json.metadata.exceptions); } return json; }).then((json: ComponentResponse) => json?.metadata?.calls?.[method] ?? null); } public request(target: string, data: { calls?: { [key in string]: any[] }, changes?: { [key in string]: any }, }, callback: (json: ComponentResponse) => any = (json: ComponentResponse) => json): AirwirePromise> { this.loading = true; let pendingChanges = this.pendingChanges; this.pendingChanges = {}; let pendingCalls = this.pendingCalls; this.pendingCalls = {}; let path = window.Airwire.route; return fetch(`${path}/${this.alias}/${target}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', }, body: JSON.stringify({ state: this.state, calls: { ...pendingCalls, ...data?.calls ?? {} }, changes: { ...pendingChanges, ...data?.changes ?? {} }, }) }) .then(response => response.json()) .then((json: ComponentResponse) => { this.loading = false window.Airwire.watchers.responses.forEach((watcher: any) => watcher(json as any)) this.watchers.responses.forEach(watcher => watcher(json)) return callback(json) }) .catch((reason: AirwireException) => { this.loading = false window.Airwire.watchers.errors.forEach((watcher: any) => watcher(reason)) this.watchers.errors.forEach(watcher => watcher(reason)) return reason }) .then((json: ComponentResponse) => { // todo this then() shouldn't execute if previous catch() executes if (json?.metadata?.errors) { this.errors = json.metadata.errors } this.replaceState(json.data, json?.metadata?.readonly) return json }) } public replaceState(state: any, readonly: any[]) { Object.entries(state).forEach(([key, value]) => { if (readonly && readonly.includes && readonly.includes(key)) { this.readonly[key as keyof WiredProperties] = value; // Clean up state if the property wasn't readonly from the beginning if (this.state[key] !== undefined) { delete this.state[key]; } } else { this.state[key] = value; } }) } public watch(responses: (response: ComponentResponse) => void, errors?: (error: AirwireException) => void): void { this.watchers.responses.push(responses); if (errors) { this.watchers.errors.push(errors); } } public defer(callback: () => T): T | null { this.pausedRequests = true; let result = null; try { result = callback(); } catch (e) { } this.pausedRequests = false; return result; } public refresh() { return this.request('refresh', {}); } public remount(...args: any[]) { return this.request('mount', { calls: { mount: args, } }); } } export class Airwire { public route: string = '/airwire'; public watchers: Watchers = { responses: [], errors: [] }; public components: Partial<{ [T in keyof TypeMap]: Array> }> = {}; public constructor( public componentDefaults: any = {}, public reactive: CallableFunction = (component: Component) => component, ) { } public watch(responses: (response: ComponentResponse) => void, errors?: (error: AirwireException) => void): Airwire { this.watchers.responses.push(responses); if (errors) { this.watchers.errors.push(errors); } return this; } public remount(aliases: keyof TypeMap | Array | null = null): void { this.refresh(aliases, true) } public refresh(aliases: keyof TypeMap | Array | null = null, remount: boolean = false): void { if (typeof aliases === 'string') { aliases = [aliases]; } if (! aliases) { aliases = Object.keys(this.components) as Array; } for (const alias of aliases) { this.components[alias]?.forEach((component: Component) => { if (remount) { component.remount(); } else { component.refresh(); } }); } } public component(alias: K, state: WiredProperties>, reactive: CallableFunction | null = null): Magic> { const component = new Component>(alias, { ...this.componentDefaults[alias] ?? {}, ...state }, reactive); return component.proxy as TypeName; } public plugin(name: string) { return require('./plugins/' + name).default; } } export default Airwire;