1
0
Fork 0
mirror of https://github.com/archtechx/airwire.git synced 2025-12-12 02:34:04 +00:00
This commit is contained in:
Samuel Štancl 2021-05-20 20:15:55 +02:00
commit d26fa93f1e
35 changed files with 2388 additions and 0 deletions

View file

@ -0,0 +1,23 @@
const chokidar = require('chokidar');
const exec = require('child_process').exec;
class AirwireWatcher {
constructor(files = 'app/**/*.php') {
this.files = files;
}
apply(compiler) {
compiler.hooks.afterEnvironment.tap('AirwireWatcher', () => {
chokidar
.watch(this.files, { usePolling: false, persistent: true })
.on('change', this.fire);
});
}
fire() {
exec('php artisan airwire:generate');
console.log('Rebuilding Airwire definitions');
}
}
module.exports = AirwireWatcher;

13
resources/js/_types.d.ts vendored Normal file
View file

@ -0,0 +1,13 @@
import './airwired'
declare module 'airwire' {
export interface TypeMap {
String: any;
}
}
declare global {
interface Window {
Airwire: Airwire
}
}

60
resources/js/airwired.ts Normal file
View file

@ -0,0 +1,60 @@
declare module 'airwire' {
export interface AirwireException {
message: string,
exception: string,
file: string,
line: number,
trace: Array<{
class: string,
file: string,
function: string,
line: number,
type: string,
}>,
}
export interface AirwirePromise<T> extends Promise<T> {
then<TResult1 = T, TResult2 = never>(onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | undefined | null, onrejected?: ((reason: AirwireException) => TResult2 | PromiseLike<TResult2>) | undefined | null): AirwirePromise<TResult1 | TResult2>;
catch<TResult = never>(onrejected?: ((reason: AirwireException) => TResult | PromiseLike<TResult>) | undefined | null): AirwirePromise<T | TResult>;
}
export type NonFunctionPropertyNames<T> = { [K in keyof T]: T[K] extends Function ? never : K }[keyof T];
export type NonFunctionProperties<T> = Pick<T, NonFunctionPropertyNames<T>>;
export type FunctionProperties<T> = Omit<T, NonFunctionPropertyNames<T>>;
export type StringKeys<T> = Pick<T, Extract<keyof T, string>>;
export type WiredMethods<T> = StringKeys<Partial<FunctionProperties<Omit<T, 'mount' | 'watch'>>>>;
export type WiredProperties<T> = StringKeys<Partial<NonFunctionProperties<Omit<T, 'errors' | '\$component'>>>>;
export type Magic<T> = T & { [key: string]: any };
export type ComponentResponse<Component> = {
data: WiredProperties<Component>;
metadata: {
calls?: {
[key in keyof WiredMethods<Component>]: any;
};
exceptions?: {
[key in keyof WiredMethods<Component>]: AirwireException;
};
errors?: {
[key in keyof WiredProperties<Component>]: string[];
};
readonly: Array<keyof WiredProperties<Component>>;
[key: string]: any;
}
}
type Watchers<T> = {
responses: Array<(response: ComponentResponse<T>) => void>,
errors: Array<(error: AirwireException) => void>,
};
export type TypeNames = keyof TypeMap
export type TypeName<T> = T extends TypeNames
? TypeMap[T]
: never
}

311
resources/js/index.ts Normal file
View file

@ -0,0 +1,311 @@
import { TypeMap, Watchers, WiredProperties, WiredMethods, AirwireException, AirwirePromise, ComponentResponse, Magic, TypeName, TypeNames } from 'airwire'
export class Component<AirwireComponent = TypeMap[keyof TypeMap]>
{
public proxy: any;
public loading: boolean = false;
public errors: Record<string, string[]> = {};
public _proxyTarget: Component<AirwireComponent>;
public watchers: Watchers<AirwireComponent> = { responses: [], errors: [] };
public pausedRequests: boolean = false;
public pendingChanges: Partial<{ [key in keyof WiredProperties<AirwireComponent>]: any }> = {};
public pendingCalls: Partial<{ [key in keyof WiredMethods<AirwireComponent>]: any }> = {};
public readonly: Partial<{ [key in keyof WiredProperties<AirwireComponent>]: 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<AirwireComponent> = 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<AirwireComponent>] = 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<AirwireComponent>];
}
if (typeof property === 'string' && !property.startsWith('__v') && property !== 'toJSON') {
return function (...args: any[]) {
return component.call.apply(component, [
property as keyof WiredMethods<AirwireComponent>,
...args
]);
}
}
},
set(target, property: string, value) {
component.update(property as keyof WiredProperties<AirwireComponent>, value);
return true
}
})
}
public update(property: keyof WiredProperties<AirwireComponent>, value: any): Promise<ComponentResponse<AirwireComponent>> | null {
this.state[property] = value;
if (this.pausedRequests) {
this.pendingChanges[property] = value;
return null;
}
return this.request(property, {
changes: { [property]: value }
}, (json: ComponentResponse<AirwireComponent>) => {
if (json?.metadata?.exceptions) {
return Promise.reject(json.metadata.exceptions);
}
return json
})
}
public call(method: keyof WiredMethods<AirwireComponent>, ...args: any[]): AirwirePromise<any> | null {
if (this.pausedRequests) {
this.pendingCalls[method] = args;
return null;
}
return this.request(method, {
calls: { [method]: args }
}, (json: ComponentResponse<AirwireComponent>) => {
if (json?.metadata?.exceptions) {
return Promise.reject(json.metadata.exceptions[method] ?? json.metadata.exceptions);
}
return json;
}).then((json: ComponentResponse<AirwireComponent>) => json?.metadata?.calls?.[method] ?? null);
}
public request(target: string, data: {
calls?: { [key in string]: any[] },
changes?: { [key in string]: any },
}, callback: (json: ComponentResponse<AirwireComponent>) => any = (json: ComponentResponse<AirwireComponent>) => json): AirwirePromise<ComponentResponse<AirwireComponent>> {
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<AirwireComponent>) => {
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<AirwireComponent>) => { // 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<AirwireComponent>] = 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<AirwireComponent>) => void, errors?: (error: AirwireException) => void): void {
this.watchers.responses.push(responses);
if (errors) {
this.watchers.errors.push(errors);
}
}
public defer<T>(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<TypeMap[keyof TypeMap]> = { responses: [], errors: [] };
public components: Partial<{ [T in keyof TypeMap]: Array<Component<TypeMap[T]>> }> = {};
public constructor(
public componentDefaults: any = {},
public reactive: CallableFunction = (component: Component) => component,
) { }
public watch(responses: (response: ComponentResponse<TypeMap[keyof TypeMap]>) => 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<keyof TypeMap> | null = null): void {
this.refresh(aliases, true)
}
public refresh(aliases: keyof TypeMap | Array<keyof TypeMap> | null = null, remount: boolean = false): void {
if (typeof aliases === 'string') {
aliases = [aliases];
}
if (! aliases) {
aliases = Object.keys(this.components) as Array<keyof TypeMap>;
}
for (const alias of aliases) {
this.components[alias]?.forEach((component: Component<any>) => {
if (remount) {
component.remount();
} else {
component.refresh();
}
});
}
}
public component<K extends TypeNames>(alias: K, state: WiredProperties<TypeName<K>>, reactive: CallableFunction | null = null): Magic<TypeName<K>> {
const component = new Component<TypeName<K>>(alias, {
...this.componentDefaults[alias] ?? {},
...state
}, reactive);
return component.proxy as TypeName<K>;
}
public plugin(name: string) {
return require('./plugins/' + name).default;
}
}
export default Airwire;

View file

@ -0,0 +1,19 @@
let state = window as any;
export default () => {
const deferrer = state.deferLoadingAlpine || function (callback: CallableFunction) { callback() }
state.deferLoadingAlpine = function (callback: CallableFunction) {
state.Alpine.addMagicProperty('$airwire', (el: any) => {
return function (...args: any) {
if (args) {
return window.Airwire.component(args[0], args[1], el.__x.$data.$reactive)
}
return window.Airwire;
}
})
deferrer(callback)
}
}

View file

@ -0,0 +1,6 @@
export default (reactive: any) => ({
install(app: any) {
window.Airwire.reactive = reactive;
app.config.globalProperties.$airwire = window.Airwire
}
})