@nkzw/fate-devtools 2.0.0 → 2.1.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nkzw/fate-devtools",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Devtools for fate, with a Vite DevTools panel.",
5
5
  "homepage": "https://github.com/nkzw-tech/fate",
6
6
  "license": "MIT",
@@ -31,12 +31,12 @@
31
31
  }
32
32
  },
33
33
  "devDependencies": {
34
- "@nkzw/fate": "2.0.0",
34
+ "@nkzw/fate": "2.1.0",
35
35
  "@types/react": "19.3.0",
36
36
  "@types/react-dom": "19.3.0",
37
37
  "react": "19.3.1",
38
38
  "react-dom": "19.3.1",
39
- "react-fate": "2.0.0",
39
+ "react-fate": "2.1.0",
40
40
  "vite": "npm:@voidzero-dev/vite-plus-core@1.6.0",
41
41
  "vite-plus": "1.6.0"
42
42
  },
@@ -1,556 +0,0 @@
1
- // src/protocol.ts
2
- /**
3
- * Messages exchanged between an app running fate and the devtools panel.
4
- *
5
- * Both sides talk over a `BroadcastChannel`, so the panel works inside the
6
- * Vite DevTools dock (served from the dev server's origin) and as a standalone
7
- * tab on the same origin.
8
- */
9
- const channelName = "fate-devtools";
10
- /** The app page exposes its page id here so an embedded panel can pick its own clients. */
11
- const pageIdGlobal = "__FATE_DEVTOOLS_PAGE_ID__";
12
- const isPanelMessage = (value) => typeof value === "object" && value !== null && value.source === "fate-devtools:panel";
13
- const listKeySeparator = " __fate__ ";
14
- const decodeListKeyPart = (part) => {
15
- try {
16
- return decodeURIComponent(part);
17
- } catch {
18
- return part;
19
- }
20
- };
21
- /**
22
- * Splits a fate list key (`owner __fate__ field __fate__ hash`) into its parts.
23
- * Root lists are owned by `__root__`.
24
- */
25
- const parseListKey = (key) => {
26
- const parts = key.split(listKeySeparator);
27
- return parts.length >= 3 ? {
28
- field: decodeListKeyPart(parts[1]),
29
- hash: decodeListKeyPart(parts[2]),
30
- owner: decodeListKeyPart(parts[0])
31
- } : {
32
- field: key,
33
- hash: "default",
34
- owner: ""
35
- };
36
- };
37
- // src/serialize.ts
38
- const maxDepth = 12;
39
- const maxArrayLength = 200;
40
- const maxStringLength = 2e3;
41
- /**
42
- * Converts arbitrary values into plain JSON that can cross a
43
- * `BroadcastChannel`. Cycles, functions, class instances, and very large
44
- * values are summarized instead of throwing.
45
- */
46
- function toJSONValue(value, depth = 0, seen = /* @__PURE__ */ new WeakSet()) {
47
- switch (typeof value) {
48
- case "string": return value.length > maxStringLength ? `${value.slice(0, maxStringLength)}… (${value.length} chars)` : value;
49
- case "number": return Number.isFinite(value) ? value : String(value);
50
- case "boolean": return value;
51
- case "bigint": return `${value}n`;
52
- case "undefined": return null;
53
- case "symbol": return value.toString();
54
- case "function": return `ƒ ${value.name || "anonymous"}()`;
55
- }
56
- if (value === null) return null;
57
- const object = value;
58
- const tag = Object.prototype.toString.call(object);
59
- if (tag === "[object Date]") {
60
- const date = object;
61
- return Number.isNaN(date.getTime()) ? "Invalid Date" : date.toISOString();
62
- }
63
- if (tag === "[object Error]") {
64
- const error = object;
65
- return {
66
- message: error.message,
67
- name: error.name
68
- };
69
- }
70
- if (seen.has(object)) return "[Circular]";
71
- if (depth >= maxDepth) return Array.isArray(object) ? `[Array(${object.length})]` : "[Object]";
72
- const ref = getNodeRef(object);
73
- if (ref !== void 0) return { $ref: ref };
74
- seen.add(object);
75
- try {
76
- if (tag === "[object Map]") return Object.fromEntries([...object].slice(0, maxArrayLength).map(([key, entry]) => [String(key), toJSONValue(entry, depth + 1, seen)]));
77
- if (tag === "[object Set]" || Array.isArray(object)) {
78
- const items = [...object];
79
- const result = items.slice(0, maxArrayLength).map((entry) => toJSONValue(entry, depth + 1, seen));
80
- return items.length > maxArrayLength ? [...result, `… ${items.length - maxArrayLength} more`] : result;
81
- }
82
- const result = {};
83
- for (const key of Object.keys(object)) {
84
- let entry;
85
- try {
86
- entry = object[key];
87
- } catch (error) {
88
- entry = error;
89
- }
90
- result[key] = toJSONValue(entry, depth + 1, seen);
91
- }
92
- return result;
93
- } finally {
94
- seen.delete(object);
95
- }
96
- }
97
- /**
98
- * fate stores relations as frozen objects keyed by a private symbol. The
99
- * devtools turn them into `{ $ref: 'Type:id' }` so the panel can link to them.
100
- */
101
- const getNodeRef = (object) => {
102
- for (const symbol of Object.getOwnPropertySymbols(object)) if (symbol.description === "__fate__node-ref") {
103
- const id = object[symbol];
104
- return typeof id === "string" ? id : void 0;
105
- }
106
- };
107
- const errorMessage = (error) => error instanceof Error ? error.message : typeof error === "string" ? error : JSON.stringify(toJSONValue(error));
108
- // src/index.ts
109
- const operationKinds = /* @__PURE__ */ new Set([
110
- "fetchById",
111
- "fetchList",
112
- "fetchQuery",
113
- "mutate",
114
- "mutateDurably",
115
- "subscribeById",
116
- "subscribeConnection"
117
- ]);
118
- const watcherTimeout = 5e3;
119
- const snapshotDelay = 120;
120
- const maxRecords = 5e3;
121
- const pageId = Math.random().toString(36).slice(2, 10);
122
- let nextClientId = 0;
123
- const parseEntityId = (id) => {
124
- const index = id.indexOf(":");
125
- return index < 0 ? {
126
- rawId: id,
127
- type: ""
128
- } : {
129
- rawId: id.slice(index + 1),
130
- type: id.slice(0, index)
131
- };
132
- };
133
- const nestedKeys = (value, prefix = "") => {
134
- if (!value || typeof value !== "object") return [];
135
- return Object.entries(value).flatMap(([key, entry]) => typeof entry === "function" ? [`${prefix}${key}`] : nestedKeys(entry, `${prefix}${key}.`));
136
- };
137
- const toPaths = (select) => {
138
- if (!select || typeof select !== "object" || !(Symbol.iterator in select)) return [];
139
- return [...select].map(String).sort();
140
- };
141
- /** Keeps a bounded log, dropping the oldest entries first. */
142
- var RingLog = class {
143
- constructor(size) {
144
- this.size = size;
145
- this.entries = [];
146
- }
147
- push(entry) {
148
- this.entries.push(entry);
149
- if (this.entries.length > this.size) this.entries.splice(0, this.entries.length - this.size);
150
- }
151
- clear() {
152
- this.entries = [];
153
- }
154
- values() {
155
- return this.entries;
156
- }
157
- };
158
- const connections = /* @__PURE__ */ new WeakMap();
159
- /**
160
- * Connects a fate client to the devtools panel. Returns a function that
161
- * disconnects it again and restores the client's original transport.
162
- *
163
- * Connecting the same client more than once is safe; the client stays
164
- * connected until every returned disposer has been called.
165
- *
166
- * The devtools are intended for development. Guard the call with
167
- * `import.meta.env.DEV` (or your bundler's equivalent) to keep them out of
168
- * production bundles.
169
- */
170
- function connectFateDevtools(client, options = {}) {
171
- if (typeof BroadcastChannel === "undefined") return () => {};
172
- const existing = connections.get(client);
173
- if (existing) {
174
- existing.refCount++;
175
- return once(() => release(client, existing));
176
- }
177
- const connection = {
178
- dispose: attach(client, options),
179
- refCount: 1
180
- };
181
- connections.set(client, connection);
182
- return once(() => release(client, connection));
183
- }
184
- const once = (fn) => {
185
- let called = false;
186
- return () => {
187
- if (!called) {
188
- called = true;
189
- fn();
190
- }
191
- };
192
- };
193
- const release = (client, connection) => {
194
- connection.refCount--;
195
- if (connection.refCount === 0) {
196
- connections.delete(client);
197
- connection.dispose();
198
- }
199
- };
200
- function attach(client, options) {
201
- const logSize = options.logSize ?? 300;
202
- globalThis[pageIdGlobal] = pageId;
203
- const summary = {
204
- id: `${pageId}:${nextClientId++}`,
205
- name: options.name ?? "fate",
206
- pageId,
207
- url: typeof location === "undefined" ? "" : location.href
208
- };
209
- const channel = new BroadcastChannel(channelName);
210
- const operations = new RingLog(logSize);
211
- const changes = new RingLog(logSize);
212
- const watchers = /* @__PURE__ */ new Map();
213
- let nextOperationId = 0;
214
- let nextChangeId = 0;
215
- let snapshotTimer;
216
- let disposed = false;
217
- const post = (message) => {
218
- if (!disposed) channel.postMessage(message);
219
- };
220
- const isWatched = () => {
221
- const now = Date.now();
222
- for (const [watcher, lastSeen] of watchers) if (now - lastSeen > watcherTimeout) watchers.delete(watcher);
223
- return watchers.size > 0;
224
- };
225
- const sendSnapshot = () => {
226
- snapshotTimer = void 0;
227
- if (!disposed && isWatched()) post({
228
- snapshot: takeSnapshot(),
229
- source: "fate-devtools:app",
230
- type: "snapshot"
231
- });
232
- };
233
- const scheduleSnapshot = () => {
234
- if (snapshotTimer === void 0 && watchers.size > 0) snapshotTimer = setTimeout(sendSnapshot, snapshotDelay);
235
- };
236
- const originalTransport = client.transport;
237
- if (originalTransport) {
238
- const wrapped = /* @__PURE__ */ new Map();
239
- client.transport = new Proxy(originalTransport, { get(target, property, receiver) {
240
- const value = Reflect.get(target, property, receiver);
241
- if (typeof value !== "function" || !operationKinds.has(property)) return value;
242
- let fn = wrapped.get(property);
243
- if (!fn) {
244
- fn = instrument(property, value.bind(target));
245
- wrapped.set(property, fn);
246
- }
247
- return fn;
248
- } });
249
- }
250
- function instrument(kind, fn) {
251
- return (...args) => {
252
- const entry = startOperation(kind, args);
253
- operations.push(entry);
254
- scheduleSnapshot();
255
- const settle = (status, value) => {
256
- entry.endedAt = Date.now();
257
- entry.status = status;
258
- if (status === "error") entry.error = errorMessage(value);
259
- else entry.result = toJSONValue(value);
260
- scheduleSnapshot();
261
- };
262
- if (kind === "subscribeById" || kind === "subscribeConnection") {
263
- const handlerIndex = kind === "subscribeById" ? 4 : 5;
264
- const handlers = args[handlerIndex];
265
- if (handlers) {
266
- const event = (name) => (...eventArgs) => {
267
- entry.events = (entry.events ?? 0) + 1;
268
- entry.result = toJSONValue(eventArgs[0]);
269
- scheduleSnapshot();
270
- return handlers[name]?.(...eventArgs);
271
- };
272
- args[handlerIndex] = {
273
- ...handlers,
274
- ...handlers.onData && { onData: event("onData") },
275
- ...handlers.onDelete && { onDelete: event("onDelete") },
276
- ...handlers.onEvent && { onEvent: event("onEvent") },
277
- onError: (error) => {
278
- entry.error = errorMessage(error);
279
- entry.status = "error";
280
- scheduleSnapshot();
281
- handlers.onError?.(error);
282
- }
283
- };
284
- }
285
- try {
286
- const unsubscribe = fn(...args);
287
- entry.status = "live";
288
- return () => {
289
- entry.endedAt = Date.now();
290
- if (entry.status === "live") entry.status = "closed";
291
- scheduleSnapshot();
292
- return unsubscribe();
293
- };
294
- } catch (error) {
295
- settle("error", error);
296
- throw error;
297
- }
298
- }
299
- try {
300
- const result = fn(...args);
301
- Promise.resolve(result).then((value) => settle("success", value), (error) => settle("error", error));
302
- return result;
303
- } catch (error) {
304
- settle("error", error);
305
- throw error;
306
- }
307
- };
308
- }
309
- function startOperation(kind, args) {
310
- const base = {
311
- id: nextOperationId++,
312
- kind,
313
- startedAt: Date.now(),
314
- status: "pending"
315
- };
316
- switch (kind) {
317
- case "fetchById": return {
318
- ...base,
319
- args: toJSONValue(args[3]),
320
- input: toJSONValue({ ids: args[1] }),
321
- name: String(args[0]),
322
- select: toPaths(args[2])
323
- };
324
- case "fetchList":
325
- case "fetchQuery": return {
326
- ...base,
327
- args: toJSONValue(args[2]),
328
- name: String(args[0]),
329
- select: toPaths(args[1])
330
- };
331
- case "mutate": return {
332
- ...base,
333
- args: toJSONValue(args[3]),
334
- input: toJSONValue(args[1]),
335
- name: String(args[0]),
336
- select: toPaths(args[2])
337
- };
338
- case "mutateDurably": return {
339
- ...base,
340
- args: toJSONValue(args[4]),
341
- input: toJSONValue({
342
- identity: args[3],
343
- input: args[1]
344
- }),
345
- name: String(args[0]),
346
- select: toPaths(args[2])
347
- };
348
- case "subscribeById": return {
349
- ...base,
350
- args: toJSONValue(args[3]),
351
- input: toJSONValue({ id: args[1] }),
352
- name: String(args[0]),
353
- select: toPaths(args[2])
354
- };
355
- case "subscribeConnection": return {
356
- ...base,
357
- args: toJSONValue({
358
- args: args[2],
359
- selectionArgs: args[4]
360
- }),
361
- input: toJSONValue({ type: args[1] }),
362
- name: String(args[0]),
363
- select: toPaths(args[3])
364
- };
365
- }
366
- }
367
- const store = client.store;
368
- const hadOwnOnChange = !!store && Object.hasOwn(store, "onChange");
369
- const originalOnChange = store?.onChange;
370
- if (store) store.onChange = (change, owner) => {
371
- try {
372
- changes.push({
373
- at: Date.now(),
374
- id: nextChangeId++,
375
- ...change.invalidated && { invalidated: true },
376
- key: String(change.key),
377
- kind: change.kind,
378
- optimistic: !!store.isRecordingOptimistic || !!store.hasOptimisticUpdates,
379
- ...change.paths && { paths: [...change.paths].map(String) }
380
- });
381
- scheduleSnapshot();
382
- } catch {}
383
- return originalOnChange?.call(store, change, owner);
384
- };
385
- const unsubscribePersistence = client.persistence?.subscribe?.(scheduleSnapshot);
386
- function takeSnapshot() {
387
- const state = store?.dehydrate?.() ?? {
388
- coverage: [],
389
- lists: [],
390
- records: []
391
- };
392
- const confirmed = store?.hasOptimisticUpdates ? store.dehydrateConfirmed?.() : void 0;
393
- const confirmedRecords = confirmed ? new Map(confirmed.records) : void 0;
394
- const confirmedLists = confirmed ? new Map(confirmed.lists) : void 0;
395
- const coverage = new Map(state.coverage);
396
- const stale = new Map(state.stale ?? []);
397
- const records = state.records.slice(0, maxRecords).map(([id, record]) => {
398
- const { rawId, type } = parseEntityId(String(id));
399
- return {
400
- coverage: coverage.get(id) ?? [],
401
- id,
402
- lists: store?.getListKeysForEntity?.(id) ?? [],
403
- optimistic: !!confirmedRecords && confirmedRecords.get(id) !== record,
404
- rawId,
405
- record: toJSONValue(record),
406
- stale: stale.get(id) ?? [],
407
- type
408
- };
409
- });
410
- const lists = state.lists.map(([key, list]) => {
411
- const { field, owner } = parseListKey(key);
412
- return {
413
- field,
414
- ids: list.ids.map(String),
415
- key,
416
- optimistic: !!confirmedLists && confirmedLists.get(key) !== list,
417
- owner,
418
- ...list.pagination !== void 0 && { pagination: toJSONValue(list.pagination) },
419
- state: toJSONValue(list)
420
- };
421
- });
422
- return {
423
- changes: [...changes.values()],
424
- ...typeof client.hydrationScope === "string" && { hydrationScope: client.hydrationScope },
425
- id: summary.id,
426
- lists,
427
- live: [...client.liveSubscriptions ?? []].map(([key, subscription]) => ({
428
- key,
429
- subscribers: subscription.count ?? 0
430
- })),
431
- mutations: nestedKeys(client.mutations).sort(),
432
- name: summary.name,
433
- operations: operations.values().map((entry) => ({ ...entry })),
434
- optimisticUpdates: !!store?.hasOptimisticUpdates,
435
- ...client.persistence?.getSnapshot && { persistence: toJSONValue(client.persistence.getSnapshot()) },
436
- records,
437
- requests: getRequests(client),
438
- roots: Object.keys(client.roots ?? {}).sort(),
439
- takenAt: Date.now(),
440
- types: [...client.types?.keys() ?? []].sort()
441
- };
442
- }
443
- function runAction(action) {
444
- switch (action.type) {
445
- case "gc":
446
- client.gc?.();
447
- break;
448
- case "clear-log":
449
- operations.clear();
450
- changes.clear();
451
- break;
452
- case "invalidate-record":
453
- store?.invalidateRecord?.(action.id);
454
- break;
455
- case "invalidate-all":
456
- for (const [id] of store?.dehydrate?.().records ?? []) store?.invalidateRecord?.(id);
457
- break;
458
- case "delete-record": {
459
- const { rawId, type } = parseEntityId(action.id);
460
- client.deleteRecord?.(type, rawId);
461
- break;
462
- }
463
- case "log-record": {
464
- const record = store?.dehydrate?.().records.find(([id]) => id === action.id)?.[1];
465
- globalThis.$fate = record;
466
- console.log(`fate-devtools: ${action.id} (available as $fate)`, record);
467
- break;
468
- }
469
- }
470
- scheduleSnapshot();
471
- }
472
- const onMessage = (event) => {
473
- const message = event.data;
474
- if (!isPanelMessage(message)) return;
475
- if (message.type === "discover") {
476
- post({
477
- client: summary,
478
- source: "fate-devtools:app",
479
- type: "connected"
480
- });
481
- return;
482
- }
483
- if (message.clientId !== summary.id) return;
484
- switch (message.type) {
485
- case "watch":
486
- watchers.set(message.panelId, Date.now());
487
- scheduleSnapshot();
488
- break;
489
- case "unwatch":
490
- watchers.delete(message.panelId);
491
- break;
492
- case "action": try {
493
- runAction(message.action);
494
- } catch (error) {
495
- console.error("fate-devtools: Action failed.", error);
496
- }
497
- }
498
- };
499
- channel.addEventListener("message", onMessage);
500
- post({
501
- client: summary,
502
- source: "fate-devtools:app",
503
- type: "connected"
504
- });
505
- const onPageHide = () => post({
506
- clientId: summary.id,
507
- source: "fate-devtools:app",
508
- type: "disconnected"
509
- });
510
- globalThis.addEventListener?.("pagehide", onPageHide);
511
- return () => {
512
- onPageHide();
513
- disposed = true;
514
- globalThis.removeEventListener?.("pagehide", onPageHide);
515
- if (snapshotTimer !== void 0) clearTimeout(snapshotTimer);
516
- unsubscribePersistence?.();
517
- channel.removeEventListener("message", onMessage);
518
- channel.close();
519
- if (originalTransport) client.transport = originalTransport;
520
- if (store) {
521
- if (hadOwnOnChange) store.onChange = originalOnChange;
522
- else delete store.onChange;
523
- }
524
- };
525
- }
526
- function getRequests(client) {
527
- const retained = client.operationLifetime?.operations;
528
- const entries = /* @__PURE__ */ new Map();
529
- const add = (descriptor, mode, status) => {
530
- const key = descriptor?.key;
531
- if (!key) return;
532
- const existing = entries.get(key);
533
- const modes = existing?.modes ?? [];
534
- entries.set(key, {
535
- items: existing?.items ?? (descriptor.items ?? []).map((item) => ({
536
- ...item.argsPayload !== void 0 && { args: toJSONValue(item.argsPayload) },
537
- ...item.ids && { ids: item.ids.map(String) },
538
- kind: String(item.kind ?? ""),
539
- name: String(item.name ?? ""),
540
- paths: toPaths(item.plan?.paths),
541
- ...item.procedure && { procedure: item.procedure },
542
- type: String(item.type ?? "")
543
- })),
544
- key,
545
- modes: mode ? [...modes, {
546
- mode,
547
- status: status ?? "unknown"
548
- }] : modes,
549
- retainers: retained?.get(key)?.refCount ?? 0
550
- });
551
- };
552
- for (const [, modes] of client.requests ?? []) for (const [mode, handle] of modes) add(handle.descriptor, mode, handle.status);
553
- for (const [, operation] of retained ?? []) add(operation.descriptor);
554
- return [...entries.values()];
555
- }
556
- export { connectFateDevtools as t };