@nkzw/fate-devtools 0.0.0-stage → 2.0.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/LICENSE +21 -0
- package/lib/index.d.mts +19 -0
- package/lib/index.mjs +1 -0
- package/lib/panel/assets/index-B8BrSDrl.js +13 -0
- package/lib/panel/assets/index-NKFD_JQx.css +1 -0
- package/lib/panel/index.html +13 -0
- package/lib/react.d.mts +10 -0
- package/lib/react.mjs +25 -0
- package/lib/src-BAd3o4sS.mjs +563 -0
- package/lib/src-CBLiWt9-.mjs +556 -0
- package/lib/vite.d.mts +31 -0
- package/lib/vite.mjs +98 -0
- package/package.json +57 -3
- package/README.md +0 -3
|
@@ -0,0 +1,556 @@
|
|
|
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 };
|
package/lib/vite.d.mts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Plugin } from "vite";
|
|
2
|
+
export type FateDevtoolsPluginOptions = Readonly<{
|
|
3
|
+
/** Path the panel is served from. Defaults to `/__fate-devtools/`. */
|
|
4
|
+
base?: string;
|
|
5
|
+
}>;
|
|
6
|
+
/**
|
|
7
|
+
* The subset of the Vite DevTools Kit context this plugin uses. Declared here
|
|
8
|
+
* so `@vitejs/devtools` stays an optional peer dependency.
|
|
9
|
+
*/
|
|
10
|
+
type DevToolsSetupContext = {
|
|
11
|
+
docks: {
|
|
12
|
+
register: (entry: {
|
|
13
|
+
category?: string;
|
|
14
|
+
icon: string;
|
|
15
|
+
id: string;
|
|
16
|
+
title: string;
|
|
17
|
+
type: 'iframe';
|
|
18
|
+
url: string;
|
|
19
|
+
}) => unknown;
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
export type FateDevtoolsPlugin = Plugin & {
|
|
23
|
+
devtools: {
|
|
24
|
+
setup: (context: DevToolsSetupContext) => void;
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Registers the fate panel with Vite DevTools and serves it during development.
|
|
29
|
+
*/
|
|
30
|
+
export declare function fateDevtools({ base }?: FateDevtoolsPluginOptions): FateDevtoolsPlugin;
|
|
31
|
+
export {};
|
package/lib/vite.mjs
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { extname, join, normalize, sep } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
// src/vite.ts
|
|
6
|
+
/**
|
|
7
|
+
* Vite plugin for the fate devtools.
|
|
8
|
+
*
|
|
9
|
+
* Adds a "fate" panel to Vite DevTools and serves the same panel as a
|
|
10
|
+
* standalone page at `/__fate-devtools/`.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* import { fateDevtools } from '@nkzw/fate-devtools/vite';
|
|
14
|
+
*
|
|
15
|
+
* export default defineConfig({
|
|
16
|
+
* devtools: true,
|
|
17
|
+
* plugins: [fateDevtools()],
|
|
18
|
+
* });
|
|
19
|
+
*
|
|
20
|
+
* @module @nkzw/fate-devtools/vite
|
|
21
|
+
*/
|
|
22
|
+
const contentTypes = {
|
|
23
|
+
".css": "text/css; charset=utf-8",
|
|
24
|
+
".html": "text/html; charset=utf-8",
|
|
25
|
+
".js": "text/javascript; charset=utf-8",
|
|
26
|
+
".json": "application/json; charset=utf-8",
|
|
27
|
+
".svg": "image/svg+xml"
|
|
28
|
+
};
|
|
29
|
+
const icon = `data:image/svg+xml,${encodeURIComponent("<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 64 64\"><rect width=\"64\" height=\"64\" rx=\"18\" fill=\"#3d87f5\"/><text x=\"18\" y=\"47\" fill=\"white\" font-family=\"Georgia,serif\" font-style=\"italic\" font-size=\"52\">f</text></svg>")}`;
|
|
30
|
+
const findPanelDirectory = () => {
|
|
31
|
+
const candidates = [new URL("./panel/", import.meta.url), new URL("../lib/panel/", import.meta.url)];
|
|
32
|
+
for (const candidate of candidates) {
|
|
33
|
+
const directory = fileURLToPath(candidate);
|
|
34
|
+
if (existsSync(join(directory, "index.html"))) return directory;
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Registers the fate panel with Vite DevTools and serves it during development.
|
|
40
|
+
*/
|
|
41
|
+
function fateDevtools({ base = "/__fate-devtools/" } = {}) {
|
|
42
|
+
const mountPath = base.endsWith("/") ? base : `${base}/`;
|
|
43
|
+
return {
|
|
44
|
+
apply: "serve",
|
|
45
|
+
configureServer(server) {
|
|
46
|
+
const directory = findPanelDirectory();
|
|
47
|
+
server.middlewares.use(async (request, response, next) => {
|
|
48
|
+
const url = request.url?.split("?")[0];
|
|
49
|
+
if (!url || url !== mountPath.slice(0, -1) && !url.startsWith(mountPath)) return next();
|
|
50
|
+
if (!directory) {
|
|
51
|
+
response.statusCode = 500;
|
|
52
|
+
response.end("fate-devtools: The panel has not been built. Run `vp run build` in @nkzw/fate-devtools.");
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (url === mountPath.slice(0, -1)) {
|
|
56
|
+
response.statusCode = 302;
|
|
57
|
+
response.setHeader("Location", mountPath);
|
|
58
|
+
response.end();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
let path;
|
|
62
|
+
try {
|
|
63
|
+
path = decodeURIComponent(url.slice(mountPath.length));
|
|
64
|
+
} catch {
|
|
65
|
+
response.statusCode = 400;
|
|
66
|
+
response.end();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const file = normalize(join(directory, path || "index.html"));
|
|
70
|
+
if (!file.startsWith(directory.endsWith(sep) ? directory : directory + sep)) {
|
|
71
|
+
response.statusCode = 403;
|
|
72
|
+
response.end();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const body = await readFile(existsSync(file) ? file : join(directory, "index.html"));
|
|
77
|
+
response.setHeader("Content-Type", contentTypes[extname(file)] ?? "application/octet-stream");
|
|
78
|
+
response.setHeader("Cache-Control", "no-cache");
|
|
79
|
+
response.end(body);
|
|
80
|
+
} catch {
|
|
81
|
+
next();
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
},
|
|
85
|
+
devtools: { setup(context) {
|
|
86
|
+
context.docks.register({
|
|
87
|
+
category: "app",
|
|
88
|
+
icon,
|
|
89
|
+
id: "fate",
|
|
90
|
+
title: "fate",
|
|
91
|
+
type: "iframe",
|
|
92
|
+
url: mountPath
|
|
93
|
+
});
|
|
94
|
+
} },
|
|
95
|
+
name: "fate:devtools"
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
export { fateDevtools };
|