mirror of
https://github.com/archtechx/airwire.git
synced 2025-12-12 02:34:04 +00:00
initial
This commit is contained in:
commit
d26fa93f1e
35 changed files with 2388 additions and 0 deletions
23
resources/js/AirwireWatcher.js
Normal file
23
resources/js/AirwireWatcher.js
Normal 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
13
resources/js/_types.d.ts
vendored
Normal 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
60
resources/js/airwired.ts
Normal 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
311
resources/js/index.ts
Normal 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;
|
||||
19
resources/js/plugins/alpine.ts
Normal file
19
resources/js/plugins/alpine.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
6
resources/js/plugins/vue.ts
Normal file
6
resources/js/plugins/vue.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default (reactive: any) => ({
|
||||
install(app: any) {
|
||||
window.Airwire.reactive = reactive;
|
||||
app.config.globalProperties.$airwire = window.Airwire
|
||||
}
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue