Created
August 11, 2025 22:38
-
-
Save Vheissu/67f353a112f68f1ca53b980f93afab96 to your computer and use it in GitHub Desktop.
Aurelia 1 async binding behavior without dependencies
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { bindingBehavior } from "aurelia-framework"; | |
import { Binding } from "aurelia-binding"; | |
export interface AsyncAureliaBinding extends Binding { | |
originalupdateTarget(value: any): void; | |
_subscription?: { unsubscribe: () => void } | null; | |
} | |
export interface AsyncBindingBehaviorOptions { | |
catch?: ((err: any) => void) | any; // Promise rejection handler or literal fallback | |
error?: ((err: any) => void) | any; // Stream error handler or literal fallback | |
completed?: () => void; // Stream completion callback | |
property?: string; // Optional deep pick path, e.g. "data.items.0.name" | |
event?: string; // Event name for EventTarget or EventEmitter sources | |
} | |
@bindingBehavior("async") | |
export class AsyncBindingBehavior { | |
private getPropByPath(obj: any, keyPath?: string): any { | |
if (!keyPath) return obj; | |
try { | |
return keyPath.split(".").reduce((prev, curr) => { | |
if (prev == null) return prev; | |
// Handle array indices | |
const arrayMatch = curr.match(/^(\w+)\[(\d+)\]$/); | |
if (arrayMatch) { | |
const [, prop, index] = arrayMatch; | |
return prev[prop]?.[parseInt(index, 10)]; | |
} | |
return prev[curr]; | |
}, obj); | |
} catch { | |
return undefined; | |
} | |
} | |
private isThenable(a: any): a is PromiseLike<any> { | |
return a != null && typeof a.then === "function"; | |
} | |
private isSubscribable(a: any): boolean { | |
return a != null && typeof a.subscribe === "function"; | |
} | |
private isAsyncIterable(a: any): a is AsyncIterable<any> { | |
return a != null && typeof a[Symbol.asyncIterator] === "function"; | |
} | |
private isEventTarget(a: any): a is EventTarget { | |
return a != null && | |
typeof a.addEventListener === "function" && | |
typeof a.removeEventListener === "function"; | |
} | |
private isEventEmitter(a: any): boolean { | |
if (a == null) return false; | |
// Node-style or similar: on/off or addListener/removeListener | |
return ( | |
(typeof a.on === "function" && (typeof a.off === "function" || typeof a.removeListener === "function")) || | |
(typeof a.addListener === "function" && typeof a.removeListener === "function") | |
); | |
} | |
private cleanup(binding: AsyncAureliaBinding): void { | |
const sub = binding._subscription; | |
if (sub && typeof sub.unsubscribe === "function") { | |
try { | |
sub.unsubscribe(); | |
} catch (error) { | |
// Silently ignore cleanup errors | |
console.warn("Error during async binding cleanup:", error); | |
} | |
} | |
binding._subscription = null; | |
} | |
private createSafeNext(binding: AsyncAureliaBinding, options?: AsyncBindingBehaviorOptions) { | |
return (val: any) => { | |
try { | |
const extractedValue = this.getPropByPath(val, options?.property); | |
binding.originalupdateTarget(extractedValue); | |
} catch (error) { | |
console.warn("Error updating binding target:", error); | |
} | |
}; | |
} | |
private createErrorHandler(binding: AsyncAureliaBinding, options?: AsyncBindingBehaviorOptions) { | |
return (err: any) => { | |
if (!options) return; | |
try { | |
if (typeof options.error === "function") { | |
options.error(err); | |
} else if (options.error !== undefined) { | |
// For streams only, mirror AU1 rx behavior: literal error values are plucked by property | |
const errorValue = this.getPropByPath(options.error, options.property); | |
binding.originalupdateTarget(errorValue); | |
} | |
} catch (handlerError) { | |
console.warn("Error in error handler:", handlerError); | |
} | |
}; | |
} | |
bind(binding: AsyncAureliaBinding, _source: unknown, options?: AsyncBindingBehaviorOptions): void { | |
// Store original updateTarget method | |
binding.originalupdateTarget = binding.updateTarget || (() => {}); | |
binding._subscription = null; | |
const next = this.createSafeNext(binding, options); | |
const handleError = this.createErrorHandler(binding, options); | |
binding.updateTarget = (a: any) => { | |
// Cancel any prior stream or listener | |
this.cleanup(binding); | |
// Handle null/undefined values | |
if (a == null) { | |
binding.originalupdateTarget(a); | |
return; | |
} | |
// 1) Promise or thenable | |
if (this.isThenable(a)) { | |
const promise = a.then((res: any) => next(res)); | |
// Always add catch handler to prevent unhandled rejections | |
if (options?.catch !== undefined) { | |
if (typeof options.catch === "function") { | |
promise.catch((err: any) => { | |
try { | |
options.catch!(err); | |
} catch (handlerError) { | |
console.warn("Error in promise catch handler:", handlerError); | |
} | |
}); | |
} else { | |
// Match original semantics: do NOT pluck for Promise literal | |
promise.catch(() => { | |
try { | |
binding.originalupdateTarget(options.catch); | |
} catch (error) { | |
console.warn("Error setting catch fallback value:", error); | |
} | |
}); | |
} | |
} else { | |
// Add default catch to prevent unhandled rejections | |
promise.catch((err: any) => { | |
console.warn("Unhandled promise rejection in async binding:", err); | |
}); | |
} | |
return; | |
} | |
// 2) Subscribable (duck-typed). Accepts subscribe(next, err, complete) and returns | |
// either a function disposer or an object with unsubscribe/dispose. | |
if (this.isSubscribable(a)) { | |
try { | |
const sub = a.subscribe( | |
(res: any) => next(res), | |
(err: any) => handleError(err), | |
() => { | |
try { | |
options?.completed?.(); | |
} catch (error) { | |
console.warn("Error in completion handler:", error); | |
} | |
} | |
); | |
binding._subscription = { | |
unsubscribe: () => { | |
try { | |
if (typeof sub === "function") { | |
sub(); | |
} else if (sub && typeof sub.unsubscribe === "function") { | |
sub.unsubscribe(); | |
} else if (sub && typeof sub.dispose === "function") { | |
sub.dispose(); | |
} | |
} catch (error) { | |
console.warn("Error unsubscribing from subscribable:", error); | |
} | |
} | |
}; | |
} catch (error) { | |
console.warn("Error subscribing to subscribable:", error); | |
handleError(error); | |
} | |
return; | |
} | |
// 3) AsyncIterable, e.g. web streams or custom producers | |
if (this.isAsyncIterable(a)) { | |
const iterator = a[Symbol.asyncIterator](); | |
let cancelled = false; | |
const runAsyncIteration = async () => { | |
try { | |
while (!cancelled) { | |
const result = await iterator.next(); | |
if (cancelled) break; // Check cancellation after await | |
if (result.done) { | |
if (options?.completed) { | |
try { | |
options.completed(); | |
} catch (error) { | |
console.warn("Error in async iterable completion handler:", error); | |
} | |
} | |
break; | |
} | |
next(result.value); | |
} | |
} catch (err) { | |
if (!cancelled) { | |
handleError(err); | |
} | |
} | |
}; | |
// Start the iteration | |
const iterationPromise = runAsyncIteration(); | |
binding._subscription = { | |
unsubscribe: () => { | |
cancelled = true; | |
// Try to clean up the iterator | |
if (iterator && typeof iterator.return === "function") { | |
try { | |
// iterator.return() might be async, but we'll fire and forget | |
const returnResult = iterator.return(); | |
if (returnResult && typeof returnResult.catch === "function") { | |
returnResult.catch(() => { | |
// Ignore cleanup errors | |
}); | |
} | |
} catch (error) { | |
console.warn("Error returning async iterator:", error); | |
} | |
} | |
// Ensure the iteration promise doesn't cause unhandled rejections | |
iterationPromise.catch(() => { | |
// Ignore - iteration was cancelled | |
}); | |
} | |
}; | |
return; | |
} | |
// 4) DOM EventTarget, requires options.event | |
if (this.isEventTarget(a) && options?.event) { | |
const handler = (ev: Event) => next(ev); | |
try { | |
a.addEventListener(options.event, handler); | |
binding._subscription = { | |
unsubscribe: () => { | |
try { | |
if (options?.event) { | |
a.removeEventListener(options.event, handler); | |
} | |
} catch (error) { | |
console.warn("Error removing event listener:", error); | |
} | |
} | |
}; | |
} catch (error) { | |
console.warn("Error adding event listener:", error); | |
} | |
return; | |
} | |
// 5) Node-style EventEmitter, requires options.event | |
if (this.isEventEmitter(a) && options?.event) { | |
const on = typeof a.on === "function" ? a.on.bind(a) : a.addListener?.bind(a); | |
const off = typeof a.off === "function" | |
? a.off.bind(a) | |
: typeof a.removeListener === "function" | |
? a.removeListener.bind(a) | |
: undefined; | |
if (on && off) { | |
const handler = (ev: any) => next(ev); | |
try { | |
on(options.event, handler); | |
binding._subscription = { | |
unsubscribe: () => { | |
try { | |
if (options?.event) { | |
off(options.event, handler); | |
} | |
} catch (error) { | |
console.warn("Error removing event emitter listener:", error); | |
} | |
} | |
}; | |
} catch (error) { | |
console.warn("Error adding event emitter listener:", error); | |
} | |
} | |
return; | |
} | |
// Fallback: pass through plain values | |
try { | |
binding.originalupdateTarget(a); | |
} catch (error) { | |
console.warn("Error setting fallback value:", error); | |
} | |
}; | |
} | |
unbind(binding: AsyncAureliaBinding): void { | |
this.cleanup(binding); | |
// Restore original updateTarget method | |
if (binding.originalupdateTarget) { | |
binding.updateTarget = binding.originalupdateTarget; | |
(binding as any).originalupdateTarget = undefined; | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment