Skip to content

Custom Dashboard SDK: promise client and React bindings for the postMessage bridge - #12

Open
gustavo-aguiar wants to merge 12 commits into
mainfrom
feat/custom-dashboard-sdk
Open

Custom Dashboard SDK: promise client and React bindings for the postMessage bridge#12
gustavo-aguiar wants to merge 12 commits into
mainfrom
feat/custom-dashboard-sdk

Conversation

@gustavo-aguiar

@gustavo-aguiar gustavo-aguiar commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds @tago-io/custom-dashboard and @tago-io/custom-dashboard-react: a promise client and React bindings for the Custom Dashboard postMessage bridge. Both packages ship private: true and no changeset, so nothing publishes until a deliberate release PR.

It also carries an unrelated fix to @tago-io/custom-widget-core, reported separately: the default merge realtime strategy compared four of the twelve fields a record declares, so an edit landing only in metadata, location, unit, group, device or created_at was judged identical and discarded. That is a widget SDK bug, not a Dashboard SDK one, and it is here because it was found while this branch was open. Say the word and it moves to its own PR off main.

Two repo-wide CI changes come along, both small and both load-bearing here. ci.yml gains a pnpm -r check:types step, because vp check covers lint and format only and tsup typechecks src/ on its way to the .d.ts, so nothing was typechecking tests/ and a type-level assertion in a test could not fail the build. Its pull_request trigger also moves from the non-existent master to main, which is what makes the PR check fire at all.

Why

Custom Dashboard has no SDK. The entire public contract is a snippet hardcoded in Admin's empty state, so every author hand-rolls the same plumbing. Since the host moved to a request/response bridge in tago-io/admin#5185, that plumbing grew teeth: the shell has to generate correlation ids, match responses to requests, and handle nine error codes. And the host drops replies without answering in several situations, so a naive promise wrapper hangs forever.

The SDK owns exactly that, and nothing else. It does not reshape the protocol: sql.run takes params in the wire shape, and the SDK reports the theme rather than writing to the author's DOM.

Test plan

  • pnpm build && pnpm check && pnpm -r check:types && pnpm vp test run — 326 tests, format and lint clean, typecheck clean across all 5 packages
  • 159 new tests, including the correlation, timeout, coalescing and inbound-filter paths
  • Mutation-tested the three review fixes: reverting the style copy, the coalescing key guard, or the column-name reader each kills exactly one test and no others
  • Built the IIFE and evaluated it in a bare VM: installs the global, reads ?theme= before any message, 8.4 KB min / 3.3 KB gzip
  • Typechecked and built a consumer project outside the workspace, which proves the generated .d.ts resolve through the exports map for an external consumer
  • End to end against a real Custom Dashboard on a local Admin carrying tago-io/admin#5209, live api.us-e1.tago.io, dashboard created from scratch and every artifact rebuilt from this branch first (see Screenshots)
  • A purpose-built harness on that dashboard: 13 of 13 assertions green, covering the three review fixes plus correlation, the four error codes the host can reach without abuse, the empty-id drop and id hygiene
  • Theme de-duplication counted live: 13 received theme messages against 0 subscriber calls, then exactly 1 for the real change
  • StrictMode request counting from a clean load: one GET /sql for the mount, one POST /execute per action
  • An inactive query still reports bad_params (it is a 400), and the transport backstop reports no_response rather than colliding with the host's timeout. The backstop was observed firing at its 60s deadline with the renamed message
  • An adversarial pass over the first two fix commits refuted five of six, and the eight resulting defects are fixed in ee45b57. Bundle hashes decided the re-test scope: the core IIFE came out byte-identical, so only the React scenario was re-shot
  • The core merge fix verified on a real custom widget (SDK metadata merge check) on a real dashboard, bound to Cold Chain Truck 04, editing through the widget's own editData so the API write, the socket echo and the merge are all the production path. A/B/A across the two bundles: with the fix onRealtime fires again and the cell repaints, with the shipped bundle the API confirms Device Data Updated and onRealtime never fires
  • The 402 / 408 / 429 mappings are covered by unit tests in that PR, not by live traffic. Manufacturing a real plan cap, execution timeout or rate limit against a live profile would mean abusing it

Screenshots

Re-captured after the review fixes, on a Custom Dashboard created from scratch (6a8306188b06e3000c78b82c) against a local Admin carrying tago-io/admin#5209 and the live api.us-e1.tago.io. All three artifacts were rebuilt from the current branch first, so the bundles under test contain the style copy, the coalescing guard and the column-name reader.

A purpose-built harness, 13 of 13 assertions green. The three review fixes cannot be shown by the ordinary examples: the host always sends style: {}, and it always flattens columns to strings. So this shell drives them directly. Style isolation uses two clients in one document, which is the case that actually aliased. The column branch uses the SDK's injectable targetWindow to answer with { name, type } columns, so the assertion runs against the really-built bundle rather than a unit-test double. The coalescing row reads requests 1 -> 3 -> 3: a third identical call after a stop, a restart and a stale settle adds no request.

Verification harness listing 13 passing assertions beside the protocol log

Theme de-duplication, counted rather than asserted. Five unrelated <html> class mutations plus the host's re-announces produced 13 received theme messages and 0 subscriber calls, because none of them changed the value.

Counters reading 13 theme messages received and 0 subscriber calls

Then one real change, the 14th message: exactly 1 subscriber call, and the shell repaints.

Counters reading 14 messages and 1 subscriber call, shell now dark

Vanilla shell. sql.list returned the profile's three saved queries; sql.run returned 5 device rows in 18 ms, with columns derived from the result.

Vanilla shell, light theme, five device rows

The core merge fix, before and after. Same custom widget, same dashboard, same record on Cold Chain Truck 04, same button. The widget calls its own editData carrying value and time through untouched, so the only real change is inside metadata. The only thing that differs between the two runs is which build of the widget SDK the page loaded.

With the SDK as shipped, the API confirms the write and the widget never hears about it: onRealtime stays at 1, and the table still reads occupied after the edit set free.

Pre-fix widget: API replied Device Data Updated, onRealtime calls 1, table still showing the stale label

With the fix, the same click produces onRealtime #2 and the cell repaints. value seen is 35.8 in both shots, which is the point: nothing but metadata moved.

Fixed widget: onRealtime #2 arrives and the label repaints to occupied

React shell. Built to one uploadable file from a consumer project outside the workspace, whose tsc --noEmit passes against the generated .d.ts. From a clean load under StrictMode, the network shows one GET /sql for the mount and one POST /execute per action, so nothing double-fires.

React shell, light theme, ten rows from the second saved query

Running the disabled saved query surfaces the server's own message through the hook's error state, and the table is gone rather than showing the previous query's rows. It is still bad_params after tago-io/admin#5209, correctly: an inactive query is a 400. That PR split out only 402, 408 and 429.

Taking this screenshot is what found the bug it now documents. On the first pass the five rows from the previous query were still on screen underneath this error, and the caption called that useSqlQuery keeping data across a refetch. It was not: the query id had changed, so the shell was rendering one query's rows under another's identity. The docblock only ever promised retention across a refetch of the same query. data now clears when the identity changes, and switching back to a working query recovers normally.

React shell showing SQL query is inactive with code bad_params and no table

Theme change with StrictMode on: same rows, same summary, so the theme subscription does not re-run the query.

React shell, dark theme, same rows and same summary

Handoff

One thing this branch cannot finish:

  • The packages stay private: true with no changeset. Writing one now would misfire: changesets still consumes a changeset for a private package, bumping the version and writing a CHANGELOG entry for a release that never publishes. The first changeset belongs in the PR that drops private.

Risk (CIA)

Likelihood: 🟢 Low | Impact: 🟢 Low | Exposure: 🟢 Low
New packages only. Nothing publishes, no existing package changes, and no Admin or server change is required.

Related

Reading the protocol surfaced host-side gaps that are fixed separately in tago-io/admin#5209: every 4xx that is not 404/403 collapsed into bad_params, so a query timeout, a rate limit and a genuine parameter error were indistinguishable by code; and the bridge accepted neither after_device nor test, so a custom dashboard could not paginate a fan-out query.

The bridge also discards the column types the API returns, which is left alone for now because changing the columns shape breaks any shell already reading it.

Closes tago-io/issues#989

The host moved to a request/response postMessage bridge, so a shell now has to
generate correlation ids, match responses, and survive the several situations
where the host drops a reply without answering. That plumbing belongs here
rather than in every uploaded HTML file.
Comment thread packages/dashboard/examples/dev-host.html Fixed
…frame src

The typed path flowed straight into iframe.src, which accepts javascript:.
CodeQL flagged it as js/xss-through-dom. Examples get copied, and the SDK
README tells authors not to do exactly this.
tago-io/admin#5209 gives the host its own `timeout` code for a query that ran
past its execution deadline. The SDK was already using that name for a reply
that never arrived, and the two need different remedies: narrow the query
versus the dashboard went away. Also picks up the host's new plan_limit and
rate_limited codes.
tago-io/admin#5209 split timeouts, rate limits and plan caps out of that code,
so the comments describing it as a catch-all no longer hold.
… codes

Three findings from reviewing this branch:

pending-pool kept an LRU of timed-out ids that nothing read, so the promised
diagnostic did not exist. settleResponse now consults it and logs one
console.debug naming the request, which separates a late reply from a query
that quietly did nothing. An id the client never issued stays silent.

style.get() handed every client the same module-level object, so a mutation in
one shell reached all of them. Now one per instance.

plan_limit, timeout and rate_limited were added to the host union without a
test. They are in the error-code table now, plus a case proving an unrecognized
code is forwarded rather than flattened.
@gustavo-aguiar

Copy link
Copy Markdown
Contributor Author

Block
The code is sound, but the error table documents the pre-rename meaning of timeout, the style isolation from f971e5d does not hold once a message arrives, and the Pages job publishes examples that cannot load the bundle.

Description alignment

Stale

  • PR description > Test plan: claims 314 tests and 147 new ones. Current head is 320 total and 153 in the two dashboard packages, after cb4a7d6 and f971e5d.
  • PR description > Why: says the shell has to handle six error codes. THostErrorCode now carries nine, which is what tago-io/admin#5209 in this body's own Test plan added.

Rewrite the PR description with /tagoio:pr-and-issue-descriptions.

Thread consolidation

Prior leads

  • Prior review (Hold, this branch): wasExpired was written and never read. Cleared. dashboard-client.ts:225 consults it and timeout.test.ts covers both the expired id and the never-issued id.
  • Prior review (Hold, this branch): plan_limit, timeout and rate_limited had no test. Cleared at request.test.ts:82, with request.test.ts:105 also pinning verbatim forwarding of an unrecognized code.
  • Prior review (Hold, this branch): EMPTY_STYLE was a shared module object. Only half cleared. See the second Blocker.

In this PR

Two of the three Blockers are docs-and-plumbing around a client that itself holds up well. The third is a fix from the last push that stopped one line short.

Blockers

  • packages/dashboard/README.md:136: the table still reads timeout as "The host never answered", which became no_response's meaning in 854ba72. The SDK never raises timeout; the host raises it for a query past its execution deadline, so an author following this table writes the branch backwards and gets the exact collision the rename existed to remove. The table also omits no_response, plan_limit, rate_limited and internal, all present in TDashboardErrorCode. The prose at :158 and the example comment at examples/error-handling.html:170 are both correct, which leaves the table as the only wrong surface and the one readers copy from.
  • packages/dashboard/src/client/dashboard-client.ts:260: setStyle stores the inbound payload by reference. One MessageEvent is delivered to every listener with the same event.data, so after a single dashboard:style message two clients in one document share the object that style.get() hands out. Verified: injecting a key through the first client's style.get() shows up on the second. The per-instance {} from f971e5d covers only the state before the first message, and tests/client/theme-style.test.ts:96 asserts just that half, so the live gap now reads as covered. Copy on assignment and dispatch a message inside that test.
  • .github/workflows/deploy-pages.yml:47: examples are copied to _site/examples/dashboard/ while the bundle goes to _site/dist/, so the <script src="../dist/custom-dashboard.min.js"> in each example resolves to _site/examples/dist/, which nothing populates. Every published dashboard example loads no SDK. The || true guards keep the job green while it does so. Either copy the bundle into _site/examples/dist/ as well, or place the examples at _site/examples/ next to the widget ones.

Improvements

  • packages/dashboard/src/client/dashboard-client.ts:302: the .finally deletes the coalescing key unconditionally, so a settle from a stopped run drops whatever holds that key now. Reproduced: stop(), start() and an identical call in one tick leave the key unregistered, and the next identical call posts a second request. That is the StrictMode remount path the comment at :285 says coalescing exists for. Consider guarding with if (this.inFlight.get(key) === promise).
  • packages/dashboard/src/protocol/messages.ts:65: an arbitrary string is cast into the closed TDashboardErrorCode, and tests/client/request.test.ts:105 proves the runtime value does escape the union. A consumer writing an exhaustive switch with a never default compiles and then reaches it. Widening to ... | (string & {}) keeps autocomplete without the type claiming more than it knows.
  • packages/dashboard/src/protocol/messages.ts:91: the comment above promises that a cosmetic columns change must not take down a deployed shell, but the filter drops every non-string, so an object-shaped columns yields [] and a headerless table while the rows still render. Reading .name off a record degrades to named columns instead of none.
  • packages/dashboard/tests/client/lifecycle.test.ts: no getTimerCount() assertion after stop(). The four ready-retry timers are the ones that fire after teardown if the clearing ever regresses, and the two existing assertions in timeout.test.ts cover only the request timer.

Praise

  • packages/dashboard/src/protocol/messages.ts:28: the allowlist is what makes the standalone self-echo a non-event. dashboard:ready is filtered before reaching the origin-learning branch, so a client posting to itself cannot pin its own origin.
  • packages/dashboard-react/src/hooks/use-sql-query.ts:64: the runID guard holds under the provider's StrictMode stop and start, because React runs cleanup and remount effects with no microtask flush between them, so the increment always beats the aborted rejection. Subtle and load-bearing.

Risk (CIA)

Likelihood: 🟢 Low | Impact: 🟢 Low | Exposure: 🟢 Low.
Confirmed. Both packages stay private: true with no changeset, no existing package changes, and no Admin or server change is required. The Pages job is the only surface that ships anything on merge, and it ships example pages.


Outside this PR

Pre-existing

  • packages/core/src/bridge/request-pool.ts:11: add is a bare Map.set, so a repeated key overwrites the first entry and orphans its promise forever. Same class as the coalescing Improvement above, and it predates this branch.
  • packages/core/src/bridge/message-bridge.ts:65: sendWithResponse adds to the pool before calling send on the next line, so a throwing send leaks the entry permanently.

Suggestions

  • packages/dashboard-react/src/hooks/use-sql-queries.ts: useSqlQuery accepts options.timeoutMs and useSqlQueries does not, so sql.list's TCallOptions is unreachable from React. Worth aligning whenever that file is next touched.

…error table

Three findings from re-reviewing this branch, plus two the pass turned up.

The error table still described `timeout` as "The host never answered", which is
what `no_response` means since 854ba72. The host owns `timeout` for a query past
its execution deadline, so an author following the table branched on the opposite
failure and got the exact collision the rename removed. The table now splits host
codes from SDK codes and carries all thirteen, including plan_limit, rate_limited,
no_response and internal.

setStyle adopted the inbound payload. One MessageEvent reaches every listener
carrying the same data object, so after a single style message two clients in a
document shared what style.get() hands out, and a mutation through one showed up
in the other. The per-instance {} from f971e5d only covered the state before the
first message, and the test asserted just that half. Copy on assignment, and the
test now dispatches a message first.

The in-flight release deleted its coalescing key unconditionally, so a settle from
a stopped run dropped whatever held that key by then. stop(), start() and an
identical call land in one tick under StrictMode, which is the case coalescing
exists for, and there it cost a second execution. Retract only our own entry.

Two more from the same pass. An unrecognized host code is forwarded verbatim, so
the runtime value sits outside the closed error union and a consumer's exhaustive
switch could claim a never default it does not have; TDashboardErrorCode now has
an open tail, with TKnownErrorCode kept closed for anyone who wants that
exhaustiveness. And normalizeSqlRunResult dropped every non-string column despite
a comment promising a cosmetic columns change would not take down a shell, which
would have meant an empty header row above populated rows; it reads .name off a
record now.

Reverting any of the three code fixes kills exactly one test and no others.
The Pages job copied the dashboard examples into _site/examples/dashboard/ while
the bundle went to _site/dist/, so each example's relative ../dist/ path resolved
to _site/examples/dist/, which nothing populates. Every published example loaded
no SDK, and the || true guards kept the job green while it happened. They go flat
alongside the widget examples, where ../dist/ resolves the same way it does in the
package. No filename collides.

Nothing was typechecking tests/. `vp check` covers lint and format, and tsup
typechecks src/ on its way to the .d.ts, so a type-level assertion in a test could
not fail the build. A repo-wide check:types step fixes that and passes clean on
all five packages today.

The pull_request trigger named master, which does not exist in this repo, so the
PR check never fired and only the push trigger was reporting.
Running the browser tooling against this repo drops console logs, page
snapshots and screenshots into .playwright-mcp/. None of it is source, and
leaving it untracked invites committing a session log by accident.
…he narrow code type

Findings from an adversarial pass over the previous two commits, which refuted
five of the six fixes they made.

useSqlQuery retained data across a change of query id, not just across a refetch
of the same query. Switching query and failing left the previous query's rows on
screen under the new query's identity, which the docblock never claimed and which
a shell has no way to detect. It now clears on an identity change, and the two
halves are pinned separately: cleared on switch, still kept across a refetch.

TKnownErrorCode was never exported from the package entry, so the README
instruction to narrow with it for an exhaustive switch could not be followed. The
type test now imports from the entry rather than the deep src/types path, which is
why it did not catch this; un-exporting the type is a typecheck failure today.

The error table's new Raised by column was wrong on two of thirteen rows. The SDK
raises api_error in four places for a malformed reply, and bad_request locally for
a query id that fails its format check, which is in fact the only way a shell
normally sees that code. Both are marked Both, and the claim that bad_params
covers only 400s is corrected: the host folds every unmapped 4xx, 422 included.

The coalescing regression test did not pin its fix. In isolation it passed against
an implementation with no cleanup at all, because "no third request" is equally
true of a key that leaked. It now settles the survivor and demands a fresh
request. A second test covers releasing a settled key while an unrelated request
is in flight, which nothing covered and which a plausible wrong guard passes.

The Pages job copied the examples unconditionally while guarding the bundle, so a
missing bundle still published pages whose relative script src 404s, green. The
example copy now rides on the bundle having landed, and warns when it has not.

check:types reached two of five packages: react and dashboard-react excluded
tests/ from tsconfig. rootDir went with it, which only constrains emit and tsc
here only ever runs --noEmit, exactly as core and dashboard already do. js keeps
both, since its one test is co-located in src and was covered all along.

Also drops a dead README link to an examples directory that was never committed.
recordsEqual compared four of the twelve fields TDataRecord declares, so any edit
landing only in the other eight was judged identical and the incoming record was
thrown away. metadata is the common way in: editDeviceData writing metadata leaves
value and time untouched, so a recoloured card kept its old colour, a swapped file
never loaded its new URL, and a tracker reporting a new position with the same
reading left its map pin frozen. Reloading showed the edit, because an empty store
adopts the first payload verbatim, which is why it reads as broken until refresh.

The comparison now covers every declared field, including the deprecated origin
and bucket, since a field left out is a field whose edits a widget never sees.
metadata and location are compared by value through one small recursive helper.
Reference and shallow both fail here and not as a matter of taste: realtime
payloads cross postMessage, which structured-clones them, so metadata is a fresh
object on every tick and coordinates is a fresh array. JSON.stringify is out
because it is key-order sensitive, and a reordered metadata object describes the
same record. An absent key and a key holding undefined count as equal, because the
platform sends JSON and the parent's own edit path emits old_value: undefined.

Structural sharing survives, which matters as much as the fix: an unchanged tick
still returns the same array and the same record objects, so memoized rows do not
repaint. Cost measured on 1000-record ticks: 0.26ms to 0.46ms for typical records,
and 0.34ms to 4.7ms for records carrying fat metadata.

Verified against a real custom widget on a real dashboard, bound to a real device,
editing through the widget's own editData so the write, the socket echo and the
merge are all the production path. Same widget A/B/A across the two bundles: with
the fix onRealtime fires again and the cell repaints, with the shipped bundle the
API confirms "Device Data Updated" and onRealtime never fires at all. The vanilla
SDK was the worst affected, since its callback is gated on array identity.

Tests cover the reported metadata case, nested metadata, metadata arrays, location,
one generated case per declared field from a Required<TDataRecord> fixture so the
coverage grows with the type, and the three no-op pins: an identical tick, a
key-order-only difference and an explicitly undefined key.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants