@forge-go/client-devtools

中文文档
1.13.7发布于 2026/11/12MITTypeScriptES Module零依赖近 30 天下载 2090

Dev-only inspector for the Forge client runtime: cache and tag-graph inspection, a bounded causal event log, and the answer to why a query did or did not refetch

原始使用文档
文档目录16 节
  1. The questions it answers
  2. Why did this query NOT refetch?
  3. Why did this query refetch?
  4. What is in the cache for Order:7 , and at what version?
  5. Which queries depend on this entity?
  6. Which sockets are open, for which channels, with what ref c…
  7. And, without running anything
  8. Inspection does not mutate
  9. The log is bounded
  10. Frame capture, which is opt-in
  11. Across an identity change
  12. Zero production cost
  13. Budget
  14. The overlay
  15. The panel
  16. What was added to the core

@forge-go/client-devtools

Why the cache did what it did.

A normalized cache with a tag graph has a characteristic failure mode, and it is not "it doesn't work". It is it did something surprising and there is no lever to find out why — the tarpit Relay and Apollo are known for. The surprise is rarely a crash. A query refetched that should not have, or — far worse, because it is silent — one did not refetch that should have, and the screen is quietly wrong for as long as the session lasts.

The whole justification for declaring tags rather than guessing dependencies is that the graph becomes inspectable. This package is where that is cashed in.

if (process.env.NODE_ENV !== 'production') {
  void import('@forge-go/client-devtools').then((devtools) => {
    globalThis.forge = devtools.attach(client);
  });
}

Written that way, a production build contains neither this package nor the branch that would have loaded it. See Zero production cost below.

The questions it answers

Why did this query NOT refetch?

The one that matters. A missed invalidation produces no event, no error and no request.

forge.whyNotRefetched('GET /orders');
outcome:     missed
reason:      `GET /orders` did not refetch because none of the 1 tag(s)
             mutation POST /orders raised are tags it carries. The two sets are
             disjoint.
invalidated: Order:9
carried:     Customer:c1, Order:1, Order:2, Order[]
matched:     (none)
nearest:     Order:9 vs Order[]  (instance-vs-collection)
suggestion:  the mutation invalidated the instance `Order:9` but this query
             provides the collection `Order[]`, and the two never intersect. A
             query only carries `Order:9` once a response has actually put that
             entity in its result — which a create never has. Add `Order[]` to
             the operation's Invalidates.

Five outcomes, because they are five different bugs and conflating any two of them costs an afternoon:

outcomewhat it means
missedthe tag sets are disjoint. A declaration is wrong; read nearest.
stale-while-unmountedthey intersected, but nothing has the query mounted, so no request was made. Correct behaviour, and the most common false report of a broken cache. It refetches on the next mount.
placeda placement callback answered. If the screen is wrong, the callback is wrong.
refetchedit did refetch. The problem is downstream of the cache.
not-trackedthis key is not one the cache holds. A key includes its arguments.

nearest names how two tags that ought to have met failed to: instance-vs-collection, collection-vs-instance, different-instance, case, scoped — each with the declaration to change.

A sixth cause is reported separately because it is invisible by construction: a tag template that resolved to nothing and was skipped. Order:{res.id} against a response with no id invalidates nothing and says nothing. cause.unresolved is where that shows up.

Why did this query refetch?

forge.whyRefetched('GET /orders');
// `GET /orders` refetched because mutation PATCH /orders/{id} invalidated
// Order:1, Order[], of which it carries Order:1, Order[].

reason is invalidation, mount or manual; the cause is the mutation or the stream frame batch responsible, recovered from the log.

Not sure which question you have? forge.explain(key) picks.

What is in the cache for Order:7, and at what version?

forge.entity('Order:7');
// { key, type, id, version, frameAt, fields, refs, dependents }

version bumps only when the data actually moved, so a refetch that changed nothing leaves it alone. frameAt is non-zero when a stream frame wrote it. refs are the entities this record points at; references appear in fields as {__ref: 'Customer:c1'}, which is what the cache genuinely holds.

Which queries depend on this entity?

forge.dependents('Customer:c1'), or the dependents field of entity. This reaches through nested references: a list of orders rendering order.customer.name depends on Customer:c1 without ever declaring it.

Which sockets are open, for which channels, with what ref count?

forge.sockets();
// [{ endpoint: '/ws/orders', connected: true, refs: 2, opens: 1,
//    reconnecting: false, channels: [{ channel: '/ws/orders', handlers: 2 }] }]

Ten components on the same live query are one socket. opens > 1 means it dropped and came back — and a reconnect means frames were missed.

And, without running anything

forge.wouldInvalidate(ops.orderCreate, { body: {} }, { id: 9 });
// { tags: ['Order:9'], unresolved: [], missed: ['Order:9'], hits: [...] }

missed is tags no mounted query carries. Asking what a mutation would do must not be answered by doing it, so this resolves the templates through the same resolveTags the invalidator uses and reads the tag index — no request, no write.

Inspection does not mutate

Not one function on the read side calls getState, fetch, read or denormalize, and this is less obvious than it sounds. cache.getState(meta, args) — the natural way to read a query — calls open, which moves the record to the back of the LRU order and creates one if it is missing, then rehydrates the skeleton, building store memos, linking them into the reverse-dependency index and writing the result onto the registry entry's value. An inspector built on it would change which query is evicted next, change what a placement callback is handed as current, and populate memos for queries nobody rendered — all only while somebody has the panel open, which is the worst possible failure mode for a debugging tool.

The whole read surface is map lookups and counters, and every snapshot is a copy: a panel that writes to snapshot.fields cannot move the store. The test suite asserts record count, record versions, the private LRU order and the full registry state (including value by identity) are unchanged after a pass over every read the API offers — and includes the probe showing those assertions fail when getState is used instead.

There is a mutating half, and it is one file. forge.actions refetches, invalidates, evicts, drops and clears, which is what a panel with buttons needs. It lives in actions.ts and nowhere else, so the paragraph above stays literally true of inspect.ts rather than approximately true of the package, and so the entire set of calls that can move this cache fits on one screen. Every one of them records what it did, so an action you took is in the same log, in the same order, as the events it caused.

The log is bounded

A fixed-size ring, 500 entries by default, allocated once and never grown. When it fills, the oldest entry is overwritten and dropped counts how many are gone, so a timeline that begins mid-story says so.

What is in an entry is bounded too, which is the half that is easy to miss. Nothing is retained that could keep a page of data alive: no response body, no error object, no rehydrated value. Tags are resolved and copied when the event arrives, arguments are reduced to a truncated cache key, and an error becomes a short string. The per-query bookkeeping is pruned against the cache's own LRU cap rather than growing one entry per query key a search box ever produced.

Frame capture, which is opt-in

Every other entry here is bounded by construction: tags are copied strings, arguments are a truncated cache key, an error is a message. A stream frame's payload is none of those, so capturing one is the single place the retention rule is broken, and you have to ask for it.

attach(client, { frames: { limit: 200 } })

Off by default; frames: {} turns it on at 200. Presence is the switch, not the number. Turned on, forge.frames() gives you the last N decoded frames with their channel, message, intent and body, which is what you want when a frame arrives and the screen does not change. Payloads are copied at capture and capped in depth and width, so nothing the ring holds can keep a store record alive. It still costs memory, bounded by the limit you set.

Across an identity change

setPrincipal drops the whole cache, so nothing recorded before it describes the store that exists after. The inspector therefore does not pretend to span the boundary: it records a principal entry, increments a session counter, and stamps every entry with its session. Queries that re-mount and re-fetch under the new identity are logged as mounts, not as refetches of a query whose data no longer exists. The cache snapshot only ever shows the current principal's data, because that is the only data there is.

Zero production cost

The binding constraint, and it is checked against the built output rather than argued from the source.

The core's seam is one nullable field on QueryCache and five optional calls. An optional call does not evaluate its arguments when the callee is nullish, so with no inspector attached there is not even an allocation — one property load and one nullish check. The core keeps no history: every event is handed over or forgotten, and the ring buffer, the causal attribution and the analysis all live here.

__tests__/zero-cost.test.ts compiles the package with tsc, bundles three fixture applications with esbuild exactly as a real build would, and reads the bytes:

fixturegzipped
uses the runtime, never imports the devtools9325 B
the guarded dynamic import above, built for production9313 B
imports the devtools statically14573 B
imports the devtools and the panel statically18638 B

The production bundle contains none of this package's marker strings; the instrumented one contains all of them, which is what makes the absence meaningful. The guarded bundle contains no import( at all — the branch folded and the chunk was never emitted.

One thing that experiment established the hard way: write the guard as a bare process.env.NODE_ENV !== 'production'. Spelt defensively as typeof process === 'undefined' || process?.env?.NODE_ENV !== 'production', the second half still folds but the first cannot, the disjunction survives, and the whole package ships.

Budget

entrygzippedbudget
inspection API5675 B6 kB
overlay2788 B3.5 kB
panel4594 B12 kB
inspector and overlay together8495 B9 kB

The design gives this package no budget, because it has no end user to protect: it never reaches a production bundle. The numbers above exist to catch accidental bloat — a charting library pulled in for a table — and are set so that the inspector cannot quietly grow past the runtime it inspects.

inspection API was 5.5 kB and is now 6 kB. What grew it is the feature: an action layer, an opt-in frame ring, a query detail read and a bulk record read, 730 B between them. A budget raised because a dev-only package gained capability is a budget doing its job. One raised because a production package gained bytes would not be, and the two core budgets in the table below were not touched.

inspector and overlay together moved from 8.5 kB to 9 kB in the same change and for the same reason. It is the two files above summed, so it grew by what they grew by, and it landed 5 B under its old line. Five bytes is not headroom, it is a tripwire: the next doc comment added to index.js sets it off, and a budget that fails for a reason nobody can act on teaches people to raise limits without reading them, which is the one thing a budget must never teach.

The overlay

import { mountOverlay } from '@forge-go/client-devtools/overlay';

const unmount = mountOverlay(forge, { open: true });

This is the whole panel: the causal trace, the network view, the overlay stack, the control rail, the detail pane and the actions. /overlay and /panel are the same implementation under two names, so it does not matter which you reach for.

It used to be the other way round. /overlay was six read-only tables and /panel was everything, which meant that following your instincts got you the least capable UI in the package with nothing telling you a better one existed one subpath away. If you were on /overlay before 1.11 and it looked thin, that is why, and updating is the whole fix.

The lean view is still here, deprecated, and going away in the next major. It is /mini:

import { mountMini } from '@forge-go/client-devtools/mini';

const unmount = mountMini(forge);

Six read-only tables and a filter box in 3.5 kB gzipped. Prefer /overlay. The bytes were the whole case for a second UI and they do not survive contact with the fact that this package is dev-only: none of it reaches a production bundle, so what you save comes off a development build. Meanwhile the lean view drifted, and still has a log tab where the panel has a causal trace.

A DOM panel in a shadow root: document.createElement and nothing else. Deliberately not a component — a React panel forces React on a Vue application, and a Vue one forces Vue on an Angular application. It is also deliberately thinner than the API beneath it. A superb inspection API with a plain panel beats the reverse: the API is what you call from the console at three in the morning, what a test asserts on, and what somebody else's panel is built from.

The panel

import { mountPanel } from '@forge-go/client-devtools/panel';

const unmount = mountPanel(forge, { open: true });

The same thing /overlay gives you, under the name it had first. Both are the full panel; /mini is the lean one. What the panel has that /mini does not:

  • A detail pane. Click a query and get its status, its mounts, its provides, tags and deps, and its last settled response as a <details> tree. Click an entity and get its version, the frame clock reading that last wrote it, its fields, its references and the queries that reach it, each with whether anything currently has it mounted.
  • An action bar, which is the only part of either UI that writes. refetch, invalidate and drop hang off the selected query, evict off the selected entity, and clear cache sits in the global bar. Every one of them goes through devtools.actions, so a console session and a button press are the same call and land in the same log.
  • A streams tab, which is binderSnapshot rendered: the bindings, the live queries and their ref counts, the queue depth, and the recovering badge naming the endpoints inside a post-reconnect gap window. That badge is the one thing here you cannot see any other way, because a client that silently missed frames looks exactly like one that did not.
  • A frames tab, empty until you turn capture on, and honest about it: it tells you the call to make rather than showing nothing.

Neither entry point imports the other, so they have separate budgets and neither can bloat the other. Same construction as the overlay in every other respect: document.createElement in a shadow root, no framework, nothing an Angular or a Vue application has to take on.

What was added to the core

Seven things, all optional, all measured. The deltas are against core, REST only (9122 B gzipped) and were taken by removing each one from the built output and re-measuring:

changecost
QueryCache.observer + four emit sites+64 B gzipped
applyFrames emit site, carrying the frame batch+20 B gzipped (streams only)
QueryRegistry.all()+8 B gzipped
QueryCache.tracked()+11 B gzipped
QueryCache.drop(key)+66 B gzipped
socketSnapshot(manager)0 B unless imported (+135 B when it is)
binderSnapshot(binder)0 B unless imported (+126 B when it is)

The last two are free functions rather than methods precisely so they tree-shake. The five above them cannot: a class method lands in every import set that pulls the class in, which is why they are counted rather than waved at.

Core totals now: 9122 B gzipped REST-only against a 9.2 kB budget, and 11856 B with streams against 14.25 kB. The whole seam is 149 B of the first figure and 164 B of the second.

版本与发布记录

@forge-go/client-devtools 共发布 25 个版本,首次发布于 2026/08/07;过去 12 个月发布了 25 个版本。

版本发布日期距今
1.13.7latest2026/11/123 天前
1.13.62026/11/105 天前
1.13.52026/11/069 天前
1.13.42026/11/069 天前
1.13.32026/11/0610 天前
1.13.22026/10/2818 天前

关于 @forge-go/client-devtools 的常见问题

@forge-go/client-devtools 是什么?

Dev-only inspector for the Forge client runtime: cache and tag-graph inspection, a bounded causal event log, and the answer to why a query did or did not refetch

如何安装 @forge-go/client-devtools?

在项目目录运行 npm install @forge-go/client-devtools,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:@forge-go/client-core(^1.13.7)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@forge-go/client-devtools 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@forge-go/client-devtools 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@forge-go/client-devtools 可以商用吗?

@forge-go/client-devtools 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@forge-go/client-devtools 的下载量有多少?

根据 npm 官方下载统计,@forge-go/client-devtools 在 2026/10/16 – 2026/11/14 这 30 天内被下载 2,090 次,日均约 70 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@forge-go/client-devtools 还在维护吗?

最新版本 1.13.7 发布于 2026/11/12(3 天前);至今共发布 25 个版本,其中过去 12 个月发布了 25 个;首次发布于 2026/08/07。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。