Skip to content

Instantly share code, notes, and snippets.

@Vheissu
Created August 11, 2025 22:38
Show Gist options
  • Save Vheissu/67f353a112f68f1ca53b980f93afab96 to your computer and use it in GitHub Desktop.
Save Vheissu/67f353a112f68f1ca53b980f93afab96 to your computer and use it in GitHub Desktop.
Aurelia 1 async binding behavior without dependencies
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