@nuxodin/item 0.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +176 -0
- package/item.d.ts +116 -0
- package/item.js +361 -0
- package/package.json +24 -0
- package/src/AsyncDataPoint.d.ts +52 -0
- package/src/AsyncDataPoint.js +280 -0
- package/src/Collection.experiment.js +63 -0
- package/src/Emitter.js +103 -0
- package/src/proxy.js +77 -0
- package/src/signal.js +95 -0
- package/tools/AsyncChild.js +44 -0
- package/tools/access.js +36 -0
- package/tools/beta/collectChanges.js +88 -0
- package/tools/beta/syncWith.js +22 -0
- package/tools/condition.js +65 -0
- package/tools/csvDataItem.js +139 -0
- package/tools/db/connect.js +27 -0
- package/tools/db/dialect/mysql.js +12 -0
- package/tools/db/dialect/pg.js +15 -0
- package/tools/db/dialect/sqlite.js +12 -0
- package/tools/db/shared.js +7 -0
- package/tools/db/sql.d.ts +24 -0
- package/tools/db/sql.js +83 -0
- package/tools/httpRouter.js +66 -0
- package/tools/jsonDataItem.js +29 -0
- package/tools/schema/build/input.js +120 -0
- package/tools/schema/db/indexeddb/from-db.js +29 -0
- package/tools/schema/db/indexeddb/to-db.js +115 -0
- package/tools/schema/db/mysql/from-db.js +16 -0
- package/tools/schema/db/mysql/from-field.js +101 -0
- package/tools/schema/db/mysql/to-db.js +275 -0
- package/tools/schema/db/mysql/to-field.js +60 -0
- package/tools/schema/db/pg/from-db.js +48 -0
- package/tools/schema/db/pg/from-field.js +71 -0
- package/tools/schema/db/pg/to-db.js +223 -0
- package/tools/schema/db/pg/to-field.js +52 -0
- package/tools/schema/db/shared/from-db.js +29 -0
- package/tools/schema/db/shared/sql.js +26 -0
- package/tools/schema/db/sqlite/from-db.js +39 -0
- package/tools/schema/db/sqlite/from-field.js +49 -0
- package/tools/schema/db/sqlite/to-db.js +127 -0
- package/tools/schema/db/sqlite/to-field.js +34 -0
- package/tools/schema/diff.js +90 -0
- package/tools/schema/dom/toInput.js +6 -0
- package/tools/schema/render/dom.js +26 -0
- package/tools/schema/render/html.js +40 -0
- package/tools/schema/utils.js +131 -0
- package/tools/schema/validator.js +92 -0
- package/tools/view/dom.js +96 -0
- package/tools/view/uhtml.js +40 -0
- package/tools/wsDenoRouter.js +108 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export interface AsyncDataPointOptions {
|
|
2
|
+
optimistic?: boolean;
|
|
3
|
+
ttl?: number;
|
|
4
|
+
debounceMs?: number;
|
|
5
|
+
getRetry?: number;
|
|
6
|
+
getRetryDelay?: number;
|
|
7
|
+
setRetry?: number;
|
|
8
|
+
setRetryDelay?: number;
|
|
9
|
+
[key: string]: any;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface AsyncDataPointConfig<T = any> {
|
|
13
|
+
get: (signal?: AbortSignal) => Promise<T>;
|
|
14
|
+
set?: (value: T, signal?: AbortSignal) => Promise<T>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface AsyncDataPointChangeEvent<T = any> {
|
|
18
|
+
value?: T;
|
|
19
|
+
oldValue?: T;
|
|
20
|
+
error?: Error;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ResolvedAsyncDataPointOptions extends Required<AsyncDataPointOptions> {}
|
|
24
|
+
|
|
25
|
+
export class AsyncDataPoint<T = any> {
|
|
26
|
+
options: ResolvedAsyncDataPointOptions;
|
|
27
|
+
|
|
28
|
+
createGetter: (signal?: AbortSignal) => Promise<T>;
|
|
29
|
+
createSetter: ((value: T, signal?: AbortSignal) => Promise<T>) | undefined;
|
|
30
|
+
|
|
31
|
+
onpending: (() => void) | null;
|
|
32
|
+
onchange: ((event: AsyncDataPointChangeEvent<T>) => void) | null;
|
|
33
|
+
|
|
34
|
+
constructor(config: AsyncDataPointConfig<T> & AsyncDataPointOptions);
|
|
35
|
+
|
|
36
|
+
get isPending(): boolean;
|
|
37
|
+
/** True once a full value has arrived — via getter, setLocal, setFromPromise or a landed write. Expires with ttl. */
|
|
38
|
+
get isLoaded(): boolean;
|
|
39
|
+
get lastError(): Error | undefined;
|
|
40
|
+
get recentValue(): T | undefined;
|
|
41
|
+
|
|
42
|
+
get(): Promise<T>;
|
|
43
|
+
set(value: T): Promise<T> | undefined;
|
|
44
|
+
setLocal(value: T): void;
|
|
45
|
+
setFromPromise(promise: Promise<T>): void;
|
|
46
|
+
dispose(): void;
|
|
47
|
+
getDebugState(): {
|
|
48
|
+
getter: Promise<T> | null;
|
|
49
|
+
setter: Promise<T> | null;
|
|
50
|
+
expectedValue: T | null;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/*
|
|
2
|
+
// Usage:
|
|
3
|
+
const datapoint = new AsyncDataPoint({
|
|
4
|
+
get: () => fetch('https://example.com/todos/1').then(res => res.json()),
|
|
5
|
+
set: value => fetch('https://example.com/todos/1', {method: 'PUT', body: JSON.stringify(value)}).then(res => res.json())
|
|
6
|
+
});
|
|
7
|
+
datapoint.set({title: 'foo', completed: true}); // Returns promise - or undefined if value is already the same - do not rely on return value
|
|
8
|
+
datapoint.get().then(value => console.log(value));
|
|
9
|
+
|
|
10
|
+
// API
|
|
11
|
+
datapoint.onchange = ({value}) => console.log('value changed', value);
|
|
12
|
+
datapoint.ttl = 1000; // cache for 1 second
|
|
13
|
+
datapoint.optimistic = true; // trust sending value: until the sending is done, the value is the new value, despite the uncertainty that the server will fail
|
|
14
|
+
datapoint.debounceMs = 5; // debounce period for setter in ms, default 5
|
|
15
|
+
datapoint.setLocal({title: 'foo', completed: true}); // set value without saving it to the master (the value comes from the master through an other channel / trusted source)
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export class AsyncDataPoint {
|
|
19
|
+
|
|
20
|
+
createGetter;
|
|
21
|
+
createSetter;
|
|
22
|
+
#expectedValue = null; // value while saving
|
|
23
|
+
#setter = null; // current setter promise
|
|
24
|
+
#getter = null; // current/cached getter promise
|
|
25
|
+
#getterExpiry = Infinity; // ttl is checked lazily on access — no timer per cache entry
|
|
26
|
+
// expired getters count as gone (lazy ttl)
|
|
27
|
+
get #cachedGetter() { return Date.now() < this.#getterExpiry ? this.#getter : null; }
|
|
28
|
+
onpending = null;
|
|
29
|
+
onchange = null;
|
|
30
|
+
|
|
31
|
+
constructor({get, set, ...options}) {
|
|
32
|
+
this.options = {
|
|
33
|
+
optimistic: true,
|
|
34
|
+
ttl: 5000,
|
|
35
|
+
debounceMs: 5,
|
|
36
|
+
getRetry: 1,
|
|
37
|
+
getRetryDelay: 500,
|
|
38
|
+
setRetry: 0,
|
|
39
|
+
setRetryDelay: 500,
|
|
40
|
+
...options
|
|
41
|
+
};
|
|
42
|
+
this.createGetter = get;
|
|
43
|
+
this.createSetter = set;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
#createGetter() {
|
|
47
|
+
const {getRetry, getRetryDelay} = this.options;
|
|
48
|
+
const controller = new AbortController();
|
|
49
|
+
const promise = retryAsync(
|
|
50
|
+
() => this.createGetter(controller.signal),
|
|
51
|
+
getRetry,
|
|
52
|
+
getRetryDelay
|
|
53
|
+
);
|
|
54
|
+
promise.controller = controller;
|
|
55
|
+
makePromiseTransparent(promise);
|
|
56
|
+
return promise;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
#createSetter(value) {
|
|
60
|
+
const {setRetry, setRetryDelay, debounceMs} = this.options;
|
|
61
|
+
const promise = abortablePromise((resolve, reject) => {
|
|
62
|
+
retryAsync(
|
|
63
|
+
() => this.createSetter(value, promise.controller.signal),
|
|
64
|
+
setRetry,
|
|
65
|
+
setRetryDelay
|
|
66
|
+
).then(resolve, reject);
|
|
67
|
+
}, debounceMs);
|
|
68
|
+
|
|
69
|
+
makePromiseTransparent(promise);
|
|
70
|
+
return promise;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
#cacheGetter(promise){
|
|
74
|
+
// trigger onchange if the value changes
|
|
75
|
+
// note: triggers also if the getter is no more cached as we dont know if the value has changed
|
|
76
|
+
|
|
77
|
+
if (promise.state === 'pending') this.onpending?.();
|
|
78
|
+
|
|
79
|
+
const oldValue = this.#getter?.value;
|
|
80
|
+
promise.then(
|
|
81
|
+
value => {
|
|
82
|
+
if (this.#getter !== promise) return;
|
|
83
|
+
if (!isEqual(oldValue, value)) {
|
|
84
|
+
try {
|
|
85
|
+
this.onchange?.({value, oldValue});
|
|
86
|
+
} catch (err) {
|
|
87
|
+
console.error('[item.js] AsyncDataPoint onchange error:', err);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
error => {
|
|
92
|
+
if (this.#getter !== promise) return;
|
|
93
|
+
if (error?.name === 'AbortError') return;
|
|
94
|
+
this.onchange?.({error, oldValue});
|
|
95
|
+
}
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
this.#getter = promise;
|
|
99
|
+
const duration = this.options.ttl;
|
|
100
|
+
this.#getterExpiry = duration > 0 ? Date.now() + duration : Infinity;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
setLocal(value) { this.#cacheGetter(transparentPromiseResolve(value)); }
|
|
104
|
+
setFromPromise(promise) { this.#cacheGetter(makePromiseTransparent(promise)); }
|
|
105
|
+
get isPending() { return this.#cachedGetter?.state === 'pending' || this.#setter?.state === 'pending'; }
|
|
106
|
+
/** True once a full value has arrived — via getter, setLocal, setFromPromise or a landed write. */
|
|
107
|
+
get isLoaded() { return this.#cachedGetter?.state === 'fulfilled'; }
|
|
108
|
+
get lastError() { return this.#cachedGetter?.reason ?? this.#setter?.reason ?? undefined; }
|
|
109
|
+
get recentValue() { return this.#cachedGetter?.value; }
|
|
110
|
+
|
|
111
|
+
get() {
|
|
112
|
+
if (this.#setter?.state === 'pending') {
|
|
113
|
+
if (this.options.optimistic) {
|
|
114
|
+
return Promise.resolve(this.#expectedValue); // trust sending value
|
|
115
|
+
} else {
|
|
116
|
+
return this.#setter.then(() => this.get()); // wait for setter to be done
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (!this.#cachedGetter) this.#cacheGetter(this.#createGetter());
|
|
120
|
+
return this.#getter;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
set(value) {
|
|
124
|
+
// Already setting this value
|
|
125
|
+
if (this.#setter?.state === 'pending' && isEqual(this.#expectedValue, value)) return this.#setter;
|
|
126
|
+
|
|
127
|
+
this.#setter?.controller.abort(); // abort previous setter
|
|
128
|
+
|
|
129
|
+
// ignore if latest getter value is the same
|
|
130
|
+
// Value already set (no-op). Returns undefined to avoid falsy issues (value could be 0, false, etc.)
|
|
131
|
+
if (this.#cachedGetter?.state === 'fulfilled' && isEqual(this.#cachedGetter.value, value)) return;
|
|
132
|
+
|
|
133
|
+
this.#expectedValue = value;
|
|
134
|
+
const promise = this.#createSetter(value);
|
|
135
|
+
|
|
136
|
+
this.onpending?.();
|
|
137
|
+
|
|
138
|
+
promise.then(
|
|
139
|
+
data => {
|
|
140
|
+
if (this.#setter !== promise) return;
|
|
141
|
+
this.setLocal(value);
|
|
142
|
+
this.#setter = null;
|
|
143
|
+
this.#expectedValue = null;
|
|
144
|
+
},
|
|
145
|
+
error => {
|
|
146
|
+
if (this.#setter !== promise) return;
|
|
147
|
+
if (error?.name === 'AbortError') return;
|
|
148
|
+
this.#getter = null;
|
|
149
|
+
this.#setter = null;
|
|
150
|
+
this.#expectedValue = null;
|
|
151
|
+
this.onchange?.({ error }); }
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
return this.#setter = promise;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
dispose() {
|
|
158
|
+
this.#setter?.controller.abort();
|
|
159
|
+
this.#getter?.controller?.abort();
|
|
160
|
+
this.#setter = null;
|
|
161
|
+
this.#getter = null;
|
|
162
|
+
this.#expectedValue = null;
|
|
163
|
+
this.#getterExpiry = Infinity;
|
|
164
|
+
this.onchange = null;
|
|
165
|
+
}
|
|
166
|
+
getDebugState() {
|
|
167
|
+
return {
|
|
168
|
+
getter: this.#getter,
|
|
169
|
+
setter: this.#setter,
|
|
170
|
+
expectedValue: this.#expectedValue
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Adds state and value properties to a Promise, making it transparent.
|
|
180
|
+
* @param {Promise<any>|TransparentPromise} promise - The Promise to make transparent.
|
|
181
|
+
* @returns {TransparentPromise} - The transparent Promise.
|
|
182
|
+
* @example
|
|
183
|
+
* const promise = makePromiseTransparent(new Promise(resolve => resolve('foo')));
|
|
184
|
+
* promise.state; // 'pending'
|
|
185
|
+
* promise.value; // undefined
|
|
186
|
+
* promise.then(value => {
|
|
187
|
+
* console.log(promise.state); // 'fulfilled'
|
|
188
|
+
* console.log(promise.value); // 'foo'
|
|
189
|
+
* });
|
|
190
|
+
*/
|
|
191
|
+
function makePromiseTransparent(promise) {
|
|
192
|
+
if (promise.state) console.warn('[item.js] already transparent');
|
|
193
|
+
promise.state = 'pending'; // or 'unknown' ?
|
|
194
|
+
promise.value = undefined;
|
|
195
|
+
promise.then(
|
|
196
|
+
value => {
|
|
197
|
+
promise.state = 'fulfilled';
|
|
198
|
+
promise.value = value;
|
|
199
|
+
},
|
|
200
|
+
reason => {
|
|
201
|
+
promise.state = 'rejected';
|
|
202
|
+
promise.reason = reason;
|
|
203
|
+
}
|
|
204
|
+
);
|
|
205
|
+
return promise;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Returns a transparent Promise (see makePromiseTransparent) that is resolved with the specified value.
|
|
211
|
+
* @param {any} value - The value to resolve the Promise with.
|
|
212
|
+
* @returns {TransparentPromise} - A transparent Promise.
|
|
213
|
+
*/
|
|
214
|
+
function transparentPromiseResolve(value) {
|
|
215
|
+
const promise = makePromiseTransparent(Promise.resolve(value));
|
|
216
|
+
promise.state = 'fulfilled';
|
|
217
|
+
promise.value = value;
|
|
218
|
+
return promise;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Returns a delayed Promise that can be aborted within the specified time (ms).
|
|
223
|
+
* @param {Promise<any>} fn - A function that returns a Promise.
|
|
224
|
+
* @param {Number} [ms=1] - The time in milliseconds to delay before resolving the Promise.
|
|
225
|
+
* @returns {Promise<any>} - A delayed Promise that can be aborted.
|
|
226
|
+
*/
|
|
227
|
+
function abortablePromise(fn, ms=1) { // delayed and therfore abortable within ms
|
|
228
|
+
const controller = new AbortController();
|
|
229
|
+
const promise = new Promise((resolve, reject) => {
|
|
230
|
+
setTimeout(() => {
|
|
231
|
+
if (controller.signal.aborted) return reject(new DOMException('aborted', 'AbortError'));
|
|
232
|
+
fn(resolve, reject);
|
|
233
|
+
}, ms);
|
|
234
|
+
});
|
|
235
|
+
promise.controller = controller;
|
|
236
|
+
return promise;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Retries an async operation with exponential backoff.
|
|
241
|
+
* @param {Function} fn - The async function to retry.
|
|
242
|
+
* @param {Number} retries - Number of retries (0 = no retry, just one attempt).
|
|
243
|
+
* @param {Number} baseDelay - Base delay in milliseconds for exponential backoff.
|
|
244
|
+
* @returns {Promise<any>} - The result of the operation.
|
|
245
|
+
*/
|
|
246
|
+
async function retryAsync(fn, retries = 2, baseDelay = 1000) {
|
|
247
|
+
let lastError;
|
|
248
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
249
|
+
try {
|
|
250
|
+
return await fn();
|
|
251
|
+
} catch (error) {
|
|
252
|
+
if (error?.name === 'AbortError') return;
|
|
253
|
+
lastError = error;
|
|
254
|
+
if (attempt < retries) {
|
|
255
|
+
const delay = baseDelay * Math.pow(2, attempt);
|
|
256
|
+
console.warn(`[item.js] Attempt ${attempt + 1}/${retries + 1} failed, retrying in ${delay}ms...`, error);
|
|
257
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
throw lastError;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Compares two values for equality.
|
|
267
|
+
* @param {any} a - The first value to compare.
|
|
268
|
+
* @param {any} b - The second value to compare.
|
|
269
|
+
* @returns {boolean} - True if the values are equal, false otherwise.
|
|
270
|
+
*/
|
|
271
|
+
function isEqual(a, b) {
|
|
272
|
+
if (a === b) return true;
|
|
273
|
+
if (a == null || b == null || typeof a !== 'object' || typeof b !== 'object') return false;
|
|
274
|
+
if (a.constructor !== b.constructor) return false;
|
|
275
|
+
|
|
276
|
+
const keysA = Object.keys(a), keysB = Object.keys(b);
|
|
277
|
+
if (keysA.length !== keysB.length) return false;
|
|
278
|
+
|
|
279
|
+
return keysA.every(k => Object.hasOwn(b, k) && isEqual(a[k], b[k]));
|
|
280
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { Emitter, ItemEvent } from './Emitter.js';
|
|
2
|
+
import { evaluate } from '../tools/condition.js';
|
|
3
|
+
|
|
4
|
+
export class Collection extends Emitter {
|
|
5
|
+
#source;
|
|
6
|
+
#filter;
|
|
7
|
+
#items = new Map();
|
|
8
|
+
#onChange;
|
|
9
|
+
|
|
10
|
+
constructor(source, filter = {}) {
|
|
11
|
+
super();
|
|
12
|
+
this.#source = source;
|
|
13
|
+
this.#filter = filter;
|
|
14
|
+
for (const item of source.items()) this.#evaluate(item);
|
|
15
|
+
this.#onChange = ({ target, add, remove }) => {
|
|
16
|
+
if (target === this.#source) {
|
|
17
|
+
if (remove) {
|
|
18
|
+
if (this.#items.has(remove.key)) {
|
|
19
|
+
this.#items.delete(remove.key);
|
|
20
|
+
this.dispatchEvent(new ItemEvent('change', { remove }));
|
|
21
|
+
}
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (add) {
|
|
25
|
+
this.#evaluate(add);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const key = target.path[this.#source.path.length];
|
|
30
|
+
const direct = key ? this.#source.has(key) : target;
|
|
31
|
+
if (direct) this.#evaluate(direct);
|
|
32
|
+
};
|
|
33
|
+
source.addEventListener('changeIn', this.#onChange);
|
|
34
|
+
// source.addEventListener('destroy', () => this.destroy()); todo?
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async read() {
|
|
38
|
+
if (!this.#source.reader) throw new Error('[item.js] no reader on source');
|
|
39
|
+
await this.#source.reader(this.#filter);
|
|
40
|
+
return this;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
#evaluate(item) {
|
|
44
|
+
const has = this.#items.has(item.key);
|
|
45
|
+
const match = evaluate(this.#filter, item.get());
|
|
46
|
+
if (match && !has) {
|
|
47
|
+
this.#items.set(item.key, item);
|
|
48
|
+
this.dispatchEvent(new ItemEvent('change', { add: item }));
|
|
49
|
+
} else if (!match && has) {
|
|
50
|
+
this.#items.delete(item.key);
|
|
51
|
+
this.dispatchEvent(new ItemEvent('change', { remove: item }));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
get items() { return [...this.#items.values()]; }
|
|
56
|
+
get keys() { return [...this.#items.keys()]; }
|
|
57
|
+
[Symbol.iterator]() { return this.#items.values(); }
|
|
58
|
+
|
|
59
|
+
destroy() {
|
|
60
|
+
this.#source.removeEventListener('changeIn', this.#onChange);
|
|
61
|
+
this.#items.clear();
|
|
62
|
+
}
|
|
63
|
+
}
|
package/src/Emitter.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
|
|
2
|
+
export class Emitter {
|
|
3
|
+
|
|
4
|
+
#listeners = null;
|
|
5
|
+
|
|
6
|
+
addEventListener(type, fn, opts) {
|
|
7
|
+
if (opts?.signal?.aborted) return;
|
|
8
|
+
(this.#listeners ??= new Map()).has(type)
|
|
9
|
+
? this.#listeners.get(type).add(fn)
|
|
10
|
+
: this.#listeners.set(type, new Set([fn]));
|
|
11
|
+
if (opts?.signal) opts.signal.addEventListener('abort', () => this.removeEventListener(type, fn), { once: true });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
removeEventListener(type, fn) {
|
|
15
|
+
this.#listeners?.get(type)?.delete(fn);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
dispatchEvent(e) {
|
|
19
|
+
e.target ??= this;
|
|
20
|
+
const fns = this.#listeners?.get(e.type);
|
|
21
|
+
if (fns) {
|
|
22
|
+
e.currentTarget = this;
|
|
23
|
+
for (const fn of fns) fn(e);
|
|
24
|
+
}
|
|
25
|
+
return !e.defaultPrevented;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
removeAllListeners() {
|
|
29
|
+
this.#listeners?.clear();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
export class ItemEvent {
|
|
35
|
+
type;
|
|
36
|
+
target;
|
|
37
|
+
currentTarget;
|
|
38
|
+
defaultPrevented = false;
|
|
39
|
+
|
|
40
|
+
constructor(type, options) {
|
|
41
|
+
this.type = type;
|
|
42
|
+
Object.assign(this, options);
|
|
43
|
+
}
|
|
44
|
+
preventDefault() { this.defaultPrevented = true; }
|
|
45
|
+
toJSON() {
|
|
46
|
+
return {
|
|
47
|
+
path: this.target.path,
|
|
48
|
+
add: this.add?.key,
|
|
49
|
+
remove: this.remove?.key,
|
|
50
|
+
pending: this.pending,
|
|
51
|
+
error: this.error?.message,
|
|
52
|
+
value: this.value,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Creates a async iterator from an event target.
|
|
60
|
+
* @param {EventTarget|Emitter} eventTarget - The event target.
|
|
61
|
+
* @param {string} eventName - The name of the event.
|
|
62
|
+
* @param {Object} [options] - The event listener options.
|
|
63
|
+
* @return {AsyncGenerator} A generator that yields events.
|
|
64
|
+
* @example
|
|
65
|
+
* const abortCtrl = new AbortController();
|
|
66
|
+
* for await (const event of asyncIteratorFromEventTarget(document, 'click', {signal: abortCtrl.signal})) {
|
|
67
|
+
* console.log(event);
|
|
68
|
+
* }
|
|
69
|
+
* setTimeout(() => abortCtrl.abort(), 1000);
|
|
70
|
+
*/
|
|
71
|
+
export async function* asyncIteratorFromEventTarget(eventTarget, eventName, options) {
|
|
72
|
+
const queue = [];
|
|
73
|
+
let stopAfterQueue = false;
|
|
74
|
+
let resolve;
|
|
75
|
+
const eventHandler = event => {
|
|
76
|
+
queue.push(event);
|
|
77
|
+
if (resolve) {
|
|
78
|
+
resolve();
|
|
79
|
+
resolve = null;
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
eventTarget.addEventListener(eventName, eventHandler, options);
|
|
83
|
+
options?.signal?.addEventListener('abort', () => {
|
|
84
|
+
stopAfterQueue = true;
|
|
85
|
+
if (resolve) {
|
|
86
|
+
resolve();
|
|
87
|
+
resolve = null;
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
try {
|
|
91
|
+
while (true) {
|
|
92
|
+
if (queue.length) {
|
|
93
|
+
yield queue.shift();
|
|
94
|
+
} else if (stopAfterQueue) {
|
|
95
|
+
return;
|
|
96
|
+
} else {
|
|
97
|
+
await new Promise(res => resolve = res);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
} finally {
|
|
101
|
+
eventTarget.removeEventListener(eventName, eventHandler, options);
|
|
102
|
+
}
|
|
103
|
+
}
|
package/src/proxy.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
|
|
2
|
+
export const $item = Symbol('item.js [proxy target]');
|
|
3
|
+
|
|
4
|
+
const proxyHandler = {
|
|
5
|
+
|
|
6
|
+
get: function (target, property) {
|
|
7
|
+
const targetItem = target.item;
|
|
8
|
+
if (property === $item) return targetItem;
|
|
9
|
+
if (property === Symbol.iterator) {
|
|
10
|
+
!targetItem.filled && targetItem.read(); // read?
|
|
11
|
+
return function* () {
|
|
12
|
+
for (const childItem of targetItem) yield toProxy(childItem);
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
if (typeof property === 'symbol') {
|
|
16
|
+
if (typeof targetItem[property] === 'function') return targetItem[property].bind(targetItem);
|
|
17
|
+
return targetItem[property];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (property === 'then') {
|
|
21
|
+
return (resolve, reject) => {
|
|
22
|
+
targetItem.read()
|
|
23
|
+
.then(() => resolve(targetItem.get()))
|
|
24
|
+
.catch(reject);
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const childItem = targetItem.item(property);
|
|
29
|
+
|
|
30
|
+
// toJSON is allowed
|
|
31
|
+
if (property === 'toJSON') console.warn('[item.js] toJSON accessed on proxy. Use `JSON.stringify(proxy())` instead of `JSON.stringify(proxy)`', Error().stack);
|
|
32
|
+
|
|
33
|
+
return toProxy(childItem);
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
set: function (target, property, value) {
|
|
37
|
+
target.item.item(property).set(value);
|
|
38
|
+
return true;
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
apply: function (target, thisArg, args) {
|
|
42
|
+
const targetItem = target.item;
|
|
43
|
+
if (args.length === 0) return targetItem.get();
|
|
44
|
+
if (args.length === 1) return targetItem.set(args[0]);
|
|
45
|
+
throw new Error('[item.js] apply called with too many arguments');
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
has: (target, property) => typeof property === 'string' && !!target.item.has(property),
|
|
49
|
+
|
|
50
|
+
ownKeys: (target) => {
|
|
51
|
+
if (!target.item.filled) target.item.read();
|
|
52
|
+
return target.item.keys;
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
getOwnPropertyDescriptor(target, property) {
|
|
56
|
+
if (typeof property === 'symbol') return Reflect.getOwnPropertyDescriptor(target.item, property);
|
|
57
|
+
if (target.item.has(property)) return { configurable: true, enumerable: true, writable: false };
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
deleteProperty: function (target, property) {
|
|
61
|
+
target.item.has(property)?.remove();
|
|
62
|
+
return true;
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const cachedProxies = new WeakMap();
|
|
67
|
+
|
|
68
|
+
export const toProxy = (itm) => {
|
|
69
|
+
let p = cachedProxies.get(itm);
|
|
70
|
+
if (!p) {
|
|
71
|
+
const fn = ()=>{};
|
|
72
|
+
fn.item = itm;
|
|
73
|
+
p = new Proxy(fn, proxyHandler);
|
|
74
|
+
cachedProxies.set(itm, p);
|
|
75
|
+
}
|
|
76
|
+
return p;
|
|
77
|
+
};
|
package/src/signal.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
|
|
2
|
+
// signal / effect
|
|
3
|
+
const relatedEffects = new WeakMap();
|
|
4
|
+
|
|
5
|
+
let activeEffect = null;
|
|
6
|
+
let queue = null;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Execute the provided function and re-execute it when dependencies change.
|
|
10
|
+
* @param {EffectFn} fn - A function that executes immediately and collects the containing items.
|
|
11
|
+
* @return {function} A function to dispose the effect.
|
|
12
|
+
*/
|
|
13
|
+
export function effect(fn) { // async?
|
|
14
|
+
const parent = activeEffect;
|
|
15
|
+
if (parent) {
|
|
16
|
+
(parent.nested ??= new Set()).add(fn);
|
|
17
|
+
if (fn.parent && fn.parent !== parent) throw new Error("[item.js] effect(cb) callbacks should not be reused for other effects");
|
|
18
|
+
fn.parent = parent;
|
|
19
|
+
}
|
|
20
|
+
activeEffect = fn;
|
|
21
|
+
cleanup(fn);
|
|
22
|
+
try { fn({ self: fn }); }
|
|
23
|
+
finally { activeEffect = parent; }
|
|
24
|
+
return () => dispose(fn);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Notify all effects that depend on the given signal.
|
|
29
|
+
* @param {Item} signal - The signal to notify.
|
|
30
|
+
*/
|
|
31
|
+
export function notify(signal) {
|
|
32
|
+
const effects = relatedEffects.get(signal);
|
|
33
|
+
if (!effects) return;
|
|
34
|
+
for (const fn of effects) {
|
|
35
|
+
fn.nested?.forEach(dispose);
|
|
36
|
+
if (fn.disposed) effects.delete(fn);
|
|
37
|
+
else batch(fn);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Register the active effect as a dependency of the given signal.
|
|
43
|
+
* @param {Item} signal - The signal to track.
|
|
44
|
+
*/
|
|
45
|
+
export function track(signal) {
|
|
46
|
+
if (!activeEffect || activeEffect.disposed) return;
|
|
47
|
+
let effects = relatedEffects.get(signal);
|
|
48
|
+
if (!effects) {
|
|
49
|
+
effects = new Set();
|
|
50
|
+
relatedEffects.set(signal, effects);
|
|
51
|
+
}
|
|
52
|
+
effects.add(activeEffect);
|
|
53
|
+
|
|
54
|
+
(activeEffect.deps ??= new Set()).add(signal);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function batch(effect) {
|
|
58
|
+
if (queue) return queue.add(effect); // currently collecting
|
|
59
|
+
queue = new Set([effect]);
|
|
60
|
+
const schedule = globalThis.requestAnimationFrame ?? queueMicrotask;
|
|
61
|
+
schedule(() => {
|
|
62
|
+
const effects = queue;
|
|
63
|
+
queue = null;
|
|
64
|
+
for (const fn of effects) {
|
|
65
|
+
if (fn.disposed) continue;
|
|
66
|
+
if (effects.has(fn?.parent)) continue; // skip if parent already runs. needed as nested effects are disposed anyway
|
|
67
|
+
|
|
68
|
+
cleanup(fn);
|
|
69
|
+
|
|
70
|
+
activeEffect = fn; // effect() called inside fn(callback) has to know his parent effect
|
|
71
|
+
try { fn({ self: fn }); }
|
|
72
|
+
catch (err) { console.error('[item.js]', err); }
|
|
73
|
+
}
|
|
74
|
+
activeEffect = null;
|
|
75
|
+
// queue = null; // restart collecting, todo? we could also keep collecting while running, but it can cause infinite loops if not careful
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function cleanup(effect) {
|
|
80
|
+
if (!effect.deps) return;
|
|
81
|
+
for (const signal of effect.deps) {
|
|
82
|
+
relatedEffects.get(signal)?.delete(effect);
|
|
83
|
+
}
|
|
84
|
+
effect.deps.clear();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function dispose(effect) {
|
|
88
|
+
if (effect.disposed) return;
|
|
89
|
+
effect.disposed = true;
|
|
90
|
+
effect.nested?.forEach(dispose);
|
|
91
|
+
effect.nested?.clear();
|
|
92
|
+
effect.parent?.nested?.delete(effect); // remove from parent's nested set
|
|
93
|
+
effect.parent = undefined;
|
|
94
|
+
}
|
|
95
|
+
|