@autono/pinbox-toolbar 0.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/README.md ADDED
@@ -0,0 +1,44 @@
1
+ # @autono/pinbox-toolbar
2
+
3
+ The embeddable feedback toolbar: a vanilla web component (Shadow DOM isolation, zero runtime deps).
4
+
5
+ Two build artifacts from one `src/`, both emitted by **tsdown** — there is no Vite build here. `tsdown.config.ts` exports an **array of two configs**, not one config with two formats: with `fixedExtension: false` an iife output would also be named `.js` and collide with the ESM entries, and a multi-entry iife build cannot share chunks.
6
+
7
+ - `dist/toolbar.iife.js` — script-tag embed for any stack; served from a **versioned** CDN path (never latest-only). The entry point is `Pinbox.init({ endpoint })`, same as every other install path.
8
+ - ESM build for bundlers — one entry per `exports` subpath.
9
+
10
+ ```html
11
+ <script src="https://cdn.example.com/@autono/pinbox-toolbar@0.0.0/toolbar.iife.js"></script>
12
+ <script>
13
+ Pinbox.init({ endpoint: "http://127.0.0.1:4319" });
14
+ </script>
15
+ ```
16
+
17
+ **The iife entry is `src/iife.ts`, not `src/index.ts`, and that is load-bearing.** An iife bundle assigns the entry module's *namespace* to `globalName`, so an entry exporting a `Pinbox` object yields `Pinbox.Pinbox.init(…)` on the host page. `src/iife.ts` re-exports the intended surface flat and exists for this bundle alone — it is absent from the ESM entry list and from `exports`, whose shapes are separate published contracts. `src/build.test.ts` evaluates the built bundle in a happy-dom realm and asserts both that `Pinbox.init` is callable and that `Pinbox.Pinbox` does not exist, because after the first publish this is a breaking change to a public API.
18
+
19
+ Chunking is part of the published contract: because the wrappers share `src/index.ts`, rolldown hoists the custom-element registration into a shared chunk and `dist/index.js` becomes a re-export shim. `sideEffects` must therefore stay the glob `./dist/*.js` — naming only the shim would let bundlers tree-shake the dev plugins' bare `import "@autono/pinbox-toolbar"` away, silently. `src/build.test.ts` bundles that bare import with Vite and fails if the shape changes.
20
+
21
+ The `./vite` and `./next` subpath exports are dev-server *plugins for the consumer's* bundler. Vite is therefore an **optional peerDependency** with a deliberately wide range — it is their Vite, not ours, and pinning it would break installs. Pinbox itself has no Vite dependency.
22
+
23
+ Subpath exports (split into a separate package only if one ever needs independent versioning):
24
+
25
+ - `./react`, `./vue`, `./svelte` — thin wrappers; frameworks are **optional peerDependencies** and stay external, so nothing framework-shaped is bundled into `dist/index.js`.
26
+ - `./vite`, `./next` — dev-server plugins that auto-inject the toolbar in dev and ensure the hub is running. Dev-only; excluded from prod builds.
27
+
28
+ `./next` is a **separate plugin, not a re-export of `./vite`**, and Vite's own docs are the reason: `transformIndexHtml` "won't be called if you are using a framework that has custom handling of entry files (for example SvelteKit)." Next has no equivalent public hook for injecting a script tag into every dev page from `next.config`, so `withPinbox()` does only the half it can do honestly — keeping the hub daemon alive during `next dev`. The client-side mount is spelled out as a TODO in `src/plugins/next.ts` rather than faked.
29
+
30
+ Both plugin files are **guest-rule code** (`AGENTS.md`): they are evaluated inside the consumer's toolchain, and both the `vite` and `next` bins carry a `#!/usr/bin/env node` shebang, so they land on Node even when launched with Bun. They use the Node/Bun shared subset (`node:*`) deliberately — do not "fix" them.
31
+
32
+ ## Realtime, offline, attachments
33
+
34
+ - **Realtime** — `src/transport.ts` speaks the frozen WS protocol (`hello → catch-up → events`) against `GET /ws`, authenticating at upgrade only via the `pinbox.token.<token>` subprotocol, because a browser cannot set headers on an upgrade. It reconnects with jittered 1s→30s backoff and replays from its persisted cursor, so a dropped socket costs no events. Either side of an excluding protocol window closes `4400` with a clear upgrade message; a bad token closes `4401`.
35
+ - **Offline mirror** — `src/transport/mirror.ts` keeps the cursor, the last-known pin list, and an outbox of pins drawn while offline in `localStorage`, namespaced per endpoint. Reconnect reconciles on one rule: **the hub wins on status, the client wins on new pins**. Storage writes never throw upward — private mode or a full quota degrades the mirror, never the toolbar.
36
+ - **Attachments** — screenshots are cropped and webp-encoded **in the browser** (`createImageBitmap` → `OffscreenCanvas` → `convertToBlob`), POSTed to `/attachments`, and the pin then carries the returned **path — never bytes**. Capture is best-effort by design: with no `html2canvas`-class dependency allowed, `src/screenshot.ts` resolves `null` wherever the environment cannot capture, and the pin ships with structured capture alone.
37
+
38
+ The toolbar's copies of the `ws-protocol.ts` wire constants are mirrored, not imported (core is a **type-only** dependency here — that is what keeps the runtime zero-dependency, and fallow's `allowTypeOnly` enforces it). `e2e/toolbar.test.ts` drives this transport against a real `pinbox serve`, so drift between the two copies fails the build.
39
+
40
+ `demo/` is the manual harness: `bun run demo` starts a hub plus a fixture page and prints the URL. Its README carries the checklist the automated tests cannot cover (real canvas pixels, a real browser).
41
+
42
+ **This package deliberately declares no `engines` field, and must not gain one.** It is the sole exception to the repo-wide `"engines": { "bun": ">=1.3.0" }` rule. The toolbar is browser code: Bun builds it here, but consumers install it into their own toolchain — a Vite/React app on npm under Node, most often. Declaring a Bun engine would make those installs warn, and refuse outright under `engine-strict`, for a package that never executes on a server runtime at all.
43
+
44
+ Key behaviors (see spec): targeting adapters (`dom` hit-testing | `anchor` attributes for sandboxed-iframe hosts), move pins with before/after rects, live status badges over WS with the accept/reopen verification step, localStorage offline mirror with reconnect reconciliation, copy-as-markdown fallback, `Pinbox.init({ getToken })` auth passthrough — the toolbar never renders a login.
@@ -0,0 +1,206 @@
1
+ import { Attachment, Pin, PinInput, ThreadMessage } from "@autono/pinbox-core/schema";
2
+ //#region src/capture.d.ts
3
+ /**
4
+ * The full browser shapes. `Pin["target"]`/`Pin["env"]` are optional at v1 (core
5
+ * schema.ts §"widened in place") because a terminal `pinbox pin` has no browser to
6
+ * measure. A capture is the opposite case: it always has a window, so it always
7
+ * produces every field. Narrowing here is what keeps every capture-side consumer
8
+ * free of optional chaining — the widening only reaches code that reads pins back.
9
+ */
10
+ type MaybeTarget = NonNullable<Pin["target"]>;
11
+ type MaybeEnv = NonNullable<Pin["env"]>;
12
+ /** The named keys become present-and-defined; everything else keeps its optionality. */
13
+ type Concrete<T, K extends keyof T> = T & { [P in K]-?: NonNullable<T[P]>; };
14
+ type BrowserTarget = Concrete<MaybeTarget, "url" | "selector" | "tag" | "rect" | "fixed">;
15
+ type BrowserEnv = Concrete<MaybeEnv, "viewport" | "browser" | "os" | "colorScheme">;
16
+ /** What a targeting adapter produces for a chosen element: the pin schema shapes. */
17
+ interface CaptureResult {
18
+ target: BrowserTarget;
19
+ env: BrowserEnv;
20
+ }
21
+ //#endregion
22
+ //#region src/state.d.ts
23
+ type UiStatus = "open" | "waiting" | "replied" | "resolved" | "verify";
24
+ interface Draft {
25
+ target: CaptureResult;
26
+ placedAt: {
27
+ x: number;
28
+ y: number;
29
+ };
30
+ }
31
+ interface ToolbarState {
32
+ pins: Pin[];
33
+ threads: Map<string, ThreadMessage[]>;
34
+ draft: Draft | null;
35
+ mode: "idle" | "placing";
36
+ activePinId: string | null;
37
+ inboxOpen: boolean;
38
+ connection: "connecting" | "live" | "offline" | "incompatible";
39
+ /** Outbox-queued pin ids (offline creates) — flagged "queued" in the UI until the flush. */
40
+ queuedIds: ReadonlySet<string>;
41
+ }
42
+ interface Store {
43
+ get(): ToolbarState;
44
+ /** Render subscribes once; every update() notifies each subscriber exactly once. */
45
+ subscribe(fn: (state: ToolbarState) => void): () => void;
46
+ update(patch: Partial<ToolbarState>): void;
47
+ /** Placement click: sets the draft and leaves placing mode. Client-only — no hub call. */
48
+ place(draft: Draft): void;
49
+ /** Abandoned draft: cleared without touching pins; nothing ever reached the hub. */
50
+ discardDraft(): void;
51
+ /** First comment submitted: the hub-created Pin replaces the draft and becomes active. */
52
+ commitDraft(pin: Pin): void;
53
+ }
54
+ declare function createStore(): Store;
55
+ /** Replace-by-id upsert; new pins append. */
56
+ declare function upsertPin(store: Store, pin: Pin): void;
57
+ /** Append to the pin's thread, deduping by message id (REST echo vs WS event). */
58
+ declare function appendThreadMessage(store: Store, message: ThreadMessage): void;
59
+ /**
60
+ * Wire events mutate the store: pin.created upserts;
61
+ * pin.resolved / pin.verified / pin.linked replace the payload Pin;
62
+ * thread.message appends — payloads are the full post-mutation objects.
63
+ */
64
+ declare function applyHubEvent(store: Store, event: {
65
+ seq: number;
66
+ eventType: string;
67
+ at: string;
68
+ payload: unknown;
69
+ }): void;
70
+ /**
71
+ * Wire-status → UI-status mapping:
72
+ * resolved + no verification ⇒ "verify" (accept/reopen prompt);
73
+ * resolved + verification ⇒ "resolved";
74
+ * open + empty thread or last message human ⇒ "waiting";
75
+ * open + last message agent|mirror ⇒ "replied".
76
+ * The prototype's WORKING/APPLIED chips need an event vocabulary the hub does not emit — excluded here.
77
+ */
78
+ declare function deriveUiStatus(pin: Pin, thread: ThreadMessage[]): UiStatus;
79
+ //#endregion
80
+ //#region src/element.d.ts
81
+ declare const BaseElement: typeof HTMLElement;
82
+ declare class PinboxToolbarElement extends BaseElement {
83
+ #private;
84
+ static readonly tagName = "pinbox-toolbar";
85
+ readonly store: Store;
86
+ /** Card → transport seam (wired by #startTransport once a config exists). */
87
+ readonly actions: {
88
+ send?: (pinId: string | "draft", text: string) => void;
89
+ verify?: (pinId: string, outcome: "accepted" | "reopened") => void;
90
+ resolve?: (pinId: string) => void;
91
+ };
92
+ /** Programmatic path (Pinbox.init). The snippet path reads hub/token attributes. */
93
+ configure(config: PinboxConfig): void;
94
+ get config(): PinboxConfig | null;
95
+ connectedCallback(): void;
96
+ disconnectedCallback(): void;
97
+ }
98
+ //#endregion
99
+ //#region src/transport/mirror.d.ts
100
+ interface StorageLike {
101
+ getItem(key: string): string | null;
102
+ setItem(key: string, value: string): void;
103
+ removeItem(key: string): void;
104
+ }
105
+ //#endregion
106
+ //#region src/transport/rest.d.ts
107
+ type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
108
+ declare class HubError extends Error {
109
+ readonly code: string;
110
+ readonly status: number;
111
+ readonly hint: string | undefined;
112
+ constructor(code: string, message: string, status: number, hint?: string);
113
+ }
114
+ //#endregion
115
+ //#region src/transport.d.ts
116
+ /** The browser WebSocket surface the transport needs — injectable for tests. */
117
+ interface WebSocketLike {
118
+ send(data: string): void;
119
+ close(code?: number, reason?: string): void;
120
+ onopen: (() => void) | null;
121
+ onmessage: ((ev: {
122
+ data: string;
123
+ }) => void) | null;
124
+ onclose: ((ev: {
125
+ code: number;
126
+ }) => void) | null;
127
+ onerror: (() => void) | null;
128
+ }
129
+ interface SchedulerLike {
130
+ setTimeout(fn: () => void, ms: number): unknown;
131
+ clearTimeout(id: unknown): void;
132
+ }
133
+ interface HubEvent {
134
+ seq: number;
135
+ eventType: string;
136
+ at: string;
137
+ payload: unknown;
138
+ }
139
+ type ConnectionState = "connecting" | "live" | "offline" | "incompatible";
140
+ interface TransportOptions {
141
+ endpoint: string;
142
+ token: string;
143
+ /** Default localStorage; injectable for tests. */
144
+ storage?: StorageLike;
145
+ /** Injectable for tests. */
146
+ webSocket?: (url: string, protocols: string[]) => WebSocketLike;
147
+ onEvent(e: HubEvent): void;
148
+ onConnection(state: ConnectionState): void;
149
+ /** Reconciled pin lists: fresh `listPins()` after each reconnect (hub wins on status). */
150
+ onPins?(pins: Pin[]): void;
151
+ /** Queued outbox localIds whenever the set changes — the UI flags them as pending sync. */
152
+ onOutbox?(localIds: string[]): void;
153
+ /** Test seam; default global fetch. */
154
+ fetchFn?: FetchLike;
155
+ /** Test seam standing in for fake timers; default global setTimeout/clearTimeout. */
156
+ scheduler?: SchedulerLike;
157
+ }
158
+ declare class HubTransport {
159
+ #private;
160
+ /** Stable per install, persisted (`pinbox:<endpoint>:consumer`). */
161
+ readonly consumerId: string;
162
+ constructor(opts: TransportOptions);
163
+ /** Last-known pin list — the offline read-only render seed. */
164
+ mirrorPins(): Pin[];
165
+ /** Optimistic pins for the queued outbox — offline reloads render + flag them. */
166
+ outboxPins(): Pin[];
167
+ /** Last-known thread for a pin — the offline read-only thread render seed.
168
+ * Empty for a pin whose thread was never fetched while connected. */
169
+ mirrorThread(pinId: string): ThreadMessage[];
170
+ /** hello → buffer live frames → apply catch-up → drain buffer. */
171
+ connect(): void;
172
+ close(): void;
173
+ listPins(): Promise<Pin[]>;
174
+ /** Offline ⇒ queued in the outbox, optimistic local pin (client wins on new pins). */
175
+ createPin(input: PinInput): Promise<Pin>;
176
+ /** Mirrored on every success, served from the mirror when the hub is unreachable —
177
+ * an offline reload renders read-only threads instead of empty ones. Any other
178
+ * hub error (auth, not-found) surfaces: the mirror is a fallback, not a mask. */
179
+ getThread(pinId: string): Promise<ThreadMessage[]>;
180
+ reply(pinId: string, text: string, attachments?: Attachment[]): Promise<ThreadMessage>;
181
+ resolve(pinId: string, note?: string): Promise<Pin>;
182
+ verify(pinId: string, outcome: "accepted" | "reopened"): Promise<Pin>;
183
+ }
184
+ //#endregion
185
+ //#region src/index.d.ts
186
+ interface PinboxConfig {
187
+ /** Hub base URL. */
188
+ endpoint: string;
189
+ /** Local dev: injected by the dev plugin. */
190
+ token?: string;
191
+ /** Cloud: the host app supplies (spec: auth passthrough). */
192
+ getToken?: () => Promise<string>;
193
+ /** Default "dom"; "anchor" for sandboxed cross-origin iframe hosts (Task 7). */
194
+ targeting?: "dom" | "anchor";
195
+ /** Anchor mode attribute, default "data-pb-anchor". */
196
+ anchorAttribute?: string;
197
+ /** Reserved; the realtime topic is fixed server-side. */
198
+ project?: string;
199
+ }
200
+ /** Register <pinbox-toolbar>; no-op outside a browser or when already defined. */
201
+ declare function defineToolbarElement(): void;
202
+ declare const Pinbox: {
203
+ init(config: PinboxConfig): PinboxToolbarElement;
204
+ };
205
+ //#endregion
206
+ export { applyHubEvent as _, HubEvent as a, upsertPin as b, WebSocketLike as c, PinboxToolbarElement as d, Draft as f, appendThreadMessage as g, UiStatus as h, ConnectionState as i, HubError as l, ToolbarState as m, PinboxConfig as n, HubTransport as o, Store as p, defineToolbarElement as r, TransportOptions as s, Pinbox as t, StorageLike as u, createStore as v, CaptureResult as x, deriveUiStatus as y };
@@ -0,0 +1,2 @@
1
+ import { _ as applyHubEvent, a as HubEvent, b as upsertPin, c as WebSocketLike, d as PinboxToolbarElement, f as Draft, g as appendThreadMessage, h as UiStatus, i as ConnectionState, l as HubError, m as ToolbarState, n as PinboxConfig, o as HubTransport, p as Store, r as defineToolbarElement, s as TransportOptions, t as Pinbox, u as StorageLike, v as createStore, x as CaptureResult, y as deriveUiStatus } from "./index-ZR3JBQu-.js";
2
+ export { type CaptureResult, type ConnectionState, type Draft, HubError, type HubEvent, HubTransport, Pinbox, PinboxConfig, PinboxToolbarElement, type StorageLike, type Store, type ToolbarState, type TransportOptions, type UiStatus, type WebSocketLike, appendThreadMessage, applyHubEvent, createStore, defineToolbarElement, deriveUiStatus, upsertPin };
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ import { a as HubError, c as createStore, i as HubTransport, l as deriveUiStatus, n as defineToolbarElement, o as appendThreadMessage, r as PinboxToolbarElement, s as applyHubEvent, t as Pinbox, u as upsertPin } from "./src-BoLg81_j.js";
2
+ export { HubError, HubTransport, Pinbox, PinboxToolbarElement, appendThreadMessage, applyHubEvent, createStore, defineToolbarElement, deriveUiStatus, upsertPin };
@@ -0,0 +1,14 @@
1
+ //#region src/plugins/options.d.ts
2
+ /** User-facing plugin options. Every field is optional. */
3
+ interface PinboxPluginOptions {
4
+ /** Explicit hub base URL. When set, `.pinbox/server.json` discovery is skipped entirely. */
5
+ hub?: string;
6
+ /** Directory containing `.pinbox/server.json`. Defaults to `process.cwd()`. */
7
+ projectRoot?: string;
8
+ /** Spawn `pinbox serve` when no healthy hub is found. Defaults to true. */
9
+ spawnDaemon?: boolean;
10
+ /** Hard off switch: the plugin becomes fully inert. Defaults to false. */
11
+ disabled?: boolean;
12
+ }
13
+ //#endregion
14
+ export { PinboxPluginOptions as t };
@@ -0,0 +1,169 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { readFileSync, realpathSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ //#region src/plugins/daemon.ts
6
+ const DEFAULT_PROBE_TIMEOUT_MS = 750;
7
+ const SPAWN_WAIT_MS = 1e4;
8
+ const POLL_INTERVAL_MS = 250;
9
+ function warn(message) {
10
+ console.warn(`[pinbox] ${message}`);
11
+ }
12
+ function sleep(ms) {
13
+ return new Promise((resolve) => {
14
+ setTimeout(resolve, ms);
15
+ });
16
+ }
17
+ function hubUrlForPort(port) {
18
+ return `http://127.0.0.1:${port}`;
19
+ }
20
+ /**
21
+ * Read the port the hub daemon advertises in `<projectRoot>/.pinbox/server.json`.
22
+ *
23
+ * That file holds the PORT ONLY — `{ "port": number }`. The auth token and pid live in the XDG
24
+ * state dir at 0600 and are deliberately never written into the repo, so there is nothing secret
25
+ * to read here. Returns undefined when the file is missing, unreadable, or malformed.
26
+ */
27
+ function readServerPort(projectRoot) {
28
+ try {
29
+ const raw = readFileSync(join(projectRoot, ".pinbox", "server.json"), "utf8");
30
+ const parsed = JSON.parse(raw);
31
+ if (typeof parsed !== "object" || parsed === null) return void 0;
32
+ const port = parsed["port"];
33
+ if (typeof port !== "number" || !Number.isInteger(port) || port <= 0 || port > 65535) return;
34
+ return port;
35
+ } catch {
36
+ return;
37
+ }
38
+ }
39
+ /**
40
+ * Read the hub bearer token from the XDG state dir — the secret half `readServerPort` skips.
41
+ *
42
+ * XDG PARITY with packages/cli/src/paths.ts, which computes the state dir as
43
+ * `${XDG_STATE_HOME ?? ~/.local/state}/pinbox/<sha256(physical project path).slice(0, 12)>`.
44
+ * The CLI hashes with Bun.CryptoHasher over `pwd -P`; this guest-rule file must reach the same
45
+ * 12-hex id via the node: shared subset — `node:crypto` createHash over `realpathSync`.
46
+ * daemon.test.ts asserts the parity by building its fixture the CLI way.
47
+ *
48
+ * Returns undefined on ANY failure (no env, no file, garbled JSON, non-string token) — a missing
49
+ * token degrades the toolbar, it must never break the consumer's dev server.
50
+ */
51
+ function readHubToken(projectRoot) {
52
+ try {
53
+ let physical;
54
+ try {
55
+ physical = realpathSync(projectRoot);
56
+ } catch {
57
+ physical = projectRoot;
58
+ }
59
+ const id = createHash("sha256").update(physical).digest("hex").slice(0, 12);
60
+ const xdg = process.env["XDG_STATE_HOME"];
61
+ const home = process.env["HOME"];
62
+ const stateHome = xdg !== void 0 && xdg !== "" ? xdg : home !== void 0 && home !== "" ? join(home, ".local", "state") : void 0;
63
+ if (stateHome === void 0) return void 0;
64
+ const raw = readFileSync(join(stateHome, "pinbox", id, "hub.json"), "utf8");
65
+ const parsed = JSON.parse(raw);
66
+ if (typeof parsed !== "object" || parsed === null) return void 0;
67
+ const token = parsed["token"];
68
+ return typeof token === "string" && token.length > 0 ? token : void 0;
69
+ } catch {
70
+ return;
71
+ }
72
+ }
73
+ /** `GET <url>/health`. Resolves false on any error, non-2xx, or timeout. Never throws. */
74
+ async function probeHub(url, timeoutMs = DEFAULT_PROBE_TIMEOUT_MS) {
75
+ try {
76
+ return (await fetch(`${url}/health`, { signal: AbortSignal.timeout(timeoutMs) })).ok;
77
+ } catch {
78
+ return false;
79
+ }
80
+ }
81
+ /** Explicit hub wins; otherwise re-read the port file (the daemon may have just written it). */
82
+ function currentHubUrl(options) {
83
+ if (options.hub !== void 0) return options.hub;
84
+ const port = readServerPort(options.projectRoot);
85
+ return port === void 0 ? void 0 : hubUrlForPort(port);
86
+ }
87
+ /**
88
+ * Spawn `pinbox serve` DETACHED, unref'd, with stdio ignored, so it outlives this dev server.
89
+ *
90
+ * The daemon is ADOPTED, never OWNED: no teardown is registered anywhere in this module. Editing
91
+ * `vite.config.ts` restarts the dev server in-process, and the OLD server's close handlers fire
92
+ * AFTER the new `configureServer` — any teardown we registered would kill the hub we just
93
+ * started. Deduping is done purely by the out-of-process health probe; the daemon owns its own
94
+ * idle-exit lifecycle.
95
+ */
96
+ function spawnDaemon(projectRoot) {
97
+ try {
98
+ const child = spawn("pinbox", ["serve"], {
99
+ cwd: projectRoot,
100
+ detached: true,
101
+ stdio: "ignore"
102
+ });
103
+ child.on("error", () => {});
104
+ child.unref();
105
+ return true;
106
+ } catch {
107
+ return false;
108
+ }
109
+ }
110
+ /**
111
+ * Resolve a hub URL, adopting a running daemon when possible and spawning one otherwise.
112
+ *
113
+ * Never throws and never registers teardown: a dead hub degrades the toolbar, it must never break
114
+ * the consumer's dev server. On failure it logs one concise warning and returns undefined.
115
+ */
116
+ async function ensureHub(options) {
117
+ if (options.disabled) return void 0;
118
+ try {
119
+ const existing = currentHubUrl(options);
120
+ if (existing !== void 0 && await probeHub(existing)) return {
121
+ url: existing,
122
+ adopted: true
123
+ };
124
+ if (!options.spawnDaemon) {
125
+ warn("no running hub found and spawnDaemon is disabled — toolbar not injected.");
126
+ return;
127
+ }
128
+ if (!spawnDaemon(options.projectRoot)) {
129
+ warn("could not start `pinbox serve` — toolbar not injected.");
130
+ return;
131
+ }
132
+ const deadline = Date.now() + SPAWN_WAIT_MS;
133
+ while (Date.now() < deadline) {
134
+ await sleep(POLL_INTERVAL_MS);
135
+ const url = currentHubUrl(options);
136
+ if (url !== void 0 && await probeHub(url)) return {
137
+ url,
138
+ adopted: false
139
+ };
140
+ }
141
+ warn(`hub did not become healthy within ${SPAWN_WAIT_MS / 1e3}s — toolbar not injected.`);
142
+ return;
143
+ } catch {
144
+ warn("hub discovery failed — toolbar not injected.");
145
+ return;
146
+ }
147
+ }
148
+ //#endregion
149
+ //#region src/plugins/options.ts
150
+ /** Virtual module the injected inline script imports. */
151
+ const VIRTUAL_ID = "virtual:pinbox-toolbar";
152
+ /** Rollup/Vite convention: a leading NUL marks the id as owned by this plugin. */
153
+ const RESOLVED_VIRTUAL_ID = "\0virtual:pinbox-toolbar";
154
+ /** Strip trailing slashes so callers can append `/health` unconditionally. */
155
+ function normalizeHub(hub) {
156
+ const trimmed = hub.trim().replace(/\/+$/, "");
157
+ return trimmed.length > 0 ? trimmed : void 0;
158
+ }
159
+ /** Apply defaults. Never throws; unknown/empty values fall back to the documented default. */
160
+ function resolveOptions(options) {
161
+ return {
162
+ hub: options?.hub === void 0 ? void 0 : normalizeHub(options.hub),
163
+ projectRoot: options?.projectRoot ?? process.cwd(),
164
+ spawnDaemon: options?.spawnDaemon ?? true,
165
+ disabled: options?.disabled ?? false
166
+ };
167
+ }
168
+ //#endregion
169
+ export { readHubToken as a, ensureHub as i, VIRTUAL_ID as n, resolveOptions as r, RESOLVED_VIRTUAL_ID as t };
@@ -0,0 +1,31 @@
1
+ import { t as PinboxPluginOptions } from "../options-CNqXqkp9.js";
2
+ //#region src/plugins/next.d.ts
3
+ /**
4
+ * Structural stand-in for Next's `NextConfig`.
5
+ *
6
+ * We deliberately do NOT depend on `next` — it is an enormous dependency to pull in just for a
7
+ * type, and pinning it would constrain which Next versions consumers may use. An index signature
8
+ * is enough: we return the caller's object unchanged, so its own type `T` is what survives.
9
+ */
10
+ interface NextConfigLike {
11
+ [key: string]: unknown;
12
+ }
13
+ /**
14
+ * Wrap a `next.config` object so the pinbox hub is running during development.
15
+ *
16
+ * // next.config.ts
17
+ * import { withPinbox } from "@autono/pinbox-toolbar/next";
18
+ * export default withPinbox({ reactStrictMode: true });
19
+ *
20
+ * Returns the SAME config value it was given (never a clone), so it composes with other `withX`
21
+ * wrappers in any order. The only effect is the side effect below.
22
+ *
23
+ * The hub check is fire-and-forget: `next.config` is evaluated synchronously here and `ensureHub`
24
+ * can take up to ~10s when it has to spawn the daemon. Blocking on it would stall every `next dev`
25
+ * start, and `ensureHub` never throws or rejects, so nothing can escape into the consumer's build.
26
+ * A future phase may instead export an async config function so the resolved hub URL can be
27
+ * threaded into `env` at config time — see the TODO.
28
+ */
29
+ declare function withPinbox<T extends NextConfigLike>(config?: T, options?: PinboxPluginOptions): T;
30
+ //#endregion
31
+ export { NextConfigLike, withPinbox };
@@ -0,0 +1,31 @@
1
+ import { i as ensureHub, r as resolveOptions } from "../options-DSrJgbJh.js";
2
+ //#region src/plugins/next.ts
3
+ /** `next dev` / `next build` set NODE_ENV themselves; bracket access is required by house tsconfig. */
4
+ function isDevelopment() {
5
+ return process.env["NODE_ENV"] !== "production";
6
+ }
7
+ /**
8
+ * Wrap a `next.config` object so the pinbox hub is running during development.
9
+ *
10
+ * // next.config.ts
11
+ * import { withPinbox } from "@autono/pinbox-toolbar/next";
12
+ * export default withPinbox({ reactStrictMode: true });
13
+ *
14
+ * Returns the SAME config value it was given (never a clone), so it composes with other `withX`
15
+ * wrappers in any order. The only effect is the side effect below.
16
+ *
17
+ * The hub check is fire-and-forget: `next.config` is evaluated synchronously here and `ensureHub`
18
+ * can take up to ~10s when it has to spawn the daemon. Blocking on it would stall every `next dev`
19
+ * start, and `ensureHub` never throws or rejects, so nothing can escape into the consumer's build.
20
+ * A future phase may instead export an async config function so the resolved hub URL can be
21
+ * threaded into `env` at config time — see the TODO.
22
+ */
23
+ function withPinbox(config, options) {
24
+ const resolved = resolveOptions(options);
25
+ const nextConfig = config ?? {};
26
+ if (resolved.disabled || !isDevelopment()) return nextConfig;
27
+ ensureHub(resolved);
28
+ return nextConfig;
29
+ }
30
+ //#endregion
31
+ export { withPinbox };
@@ -0,0 +1,11 @@
1
+ import { t as PinboxPluginOptions } from "../options-CNqXqkp9.js";
2
+ import { Plugin } from "vite";
3
+ //#region src/plugins/vite.d.ts
4
+ /**
5
+ * Inject the pinbox toolbar into the dev page and make sure the hub daemon is running.
6
+ *
7
+ * Dev only: `apply: "serve"` is the sole production-exclusion mechanism (see below).
8
+ */
9
+ declare function pinbox(options?: PinboxPluginOptions): Plugin;
10
+ //#endregion
11
+ export { pinbox as default, pinbox };
@@ -0,0 +1,98 @@
1
+ import { a as readHubToken, i as ensureHub, n as VIRTUAL_ID, r as resolveOptions, t as RESOLVED_VIRTUAL_ID } from "../options-DSrJgbJh.js";
2
+ //#region src/plugins/snippet.ts
3
+ /**
4
+ * Build the module source injected into the dev page.
5
+ *
6
+ * The returned code imports the toolbar element for its side effect (custom element registration),
7
+ * then appends a single `<pinbox-toolbar>` to `document.body` pointed at `hubUrl`. Re-running it
8
+ * after an HMR update is a no-op: the existing element is reused and only its `hub`/`token`
9
+ * attributes are refreshed (a token that disappeared is removed, never left stale).
10
+ *
11
+ * The bare `import "@autono/pinbox-toolbar"` below is a SIDE-EFFECT import — it registers the
12
+ * custom element and binds no name. That is why package.json declares
13
+ * `"sideEffects": ["./dist/*.js"]` rather than `false`: under a blanket `false`, a bundler is
14
+ * entitled to drop this import entirely and `<pinbox-toolbar>` would never be defined, so the
15
+ * toolbar would silently never mount with nothing logged. Do not "simplify" that field back.
16
+ *
17
+ * The glob — not the single `./dist/index.js` it started as — is load-bearing too. Which emitted
18
+ * FILE carries the top-level `defineToolbarElement()` call is decided by tsdown's chunking: once
19
+ * the wrapper entries (react/vue/svelte) joined the ESM build they began sharing code with
20
+ * src/index.ts, so rolldown hoisted that module body into a hash-named chunk and left
21
+ * `dist/index.js` a re-export shim. An allowlist naming only index.js then covered nothing, and
22
+ * this import was dropped for real. `src/build.test.ts` bundles this exact import with Vite and
23
+ * asserts `customElements.define` survives, because statting dist/ cannot see that class of break.
24
+ */
25
+ function buildBootstrap(hubUrl, token) {
26
+ const hub = JSON.stringify(hubUrl);
27
+ return `// pinbox dev toolbar — injected by @autono/pinbox-toolbar (dev only)
28
+ import "@autono/pinbox-toolbar";
29
+
30
+ const TAG = ${JSON.stringify("pinbox-toolbar")};
31
+ const HUB = ${hub};
32
+ const TOKEN = ${token === void 0 ? "null" : JSON.stringify(token)};
33
+
34
+ function mount() {
35
+ let el = document.querySelector(TAG);
36
+ if (!el) {
37
+ el = document.createElement(TAG);
38
+ document.body.appendChild(el);
39
+ }
40
+ el.setAttribute("hub", HUB);
41
+ if (TOKEN === null) el.removeAttribute("token");
42
+ else el.setAttribute("token", TOKEN);
43
+ }
44
+
45
+ if (document.readyState === "loading") {
46
+ document.addEventListener("DOMContentLoaded", mount, { once: true });
47
+ } else {
48
+ mount();
49
+ }
50
+ `;
51
+ }
52
+ //#endregion
53
+ //#region src/plugins/vite.ts
54
+ const PLUGIN_NAME = "pinbox";
55
+ /**
56
+ * Inject the pinbox toolbar into the dev page and make sure the hub daemon is running.
57
+ *
58
+ * Dev only: `apply: "serve"` is the sole production-exclusion mechanism (see below).
59
+ */
60
+ function pinbox(options) {
61
+ const resolved = resolveOptions(options);
62
+ if (resolved.disabled) return { name: PLUGIN_NAME };
63
+ let hubPromise;
64
+ function hub() {
65
+ hubPromise ??= ensureHub(resolved);
66
+ return hubPromise;
67
+ }
68
+ return {
69
+ name: PLUGIN_NAME,
70
+ apply: "serve",
71
+ async configureServer() {
72
+ await hub();
73
+ },
74
+ resolveId(id) {
75
+ if (id === "virtual:pinbox-toolbar") return RESOLVED_VIRTUAL_ID;
76
+ return null;
77
+ },
78
+ async load(id) {
79
+ if (id !== "\0virtual:pinbox-toolbar") return null;
80
+ const status = await hub();
81
+ if (status === void 0) return "// pinbox: hub unavailable, toolbar not mounted.\nexport {};\n";
82
+ return buildBootstrap(status.url, readHubToken(resolved.projectRoot));
83
+ },
84
+ transformIndexHtml: {
85
+ order: "pre",
86
+ handler() {
87
+ return [{
88
+ tag: "script",
89
+ attrs: { type: "module" },
90
+ children: `import ${JSON.stringify(VIRTUAL_ID)};`,
91
+ injectTo: "body"
92
+ }];
93
+ }
94
+ }
95
+ };
96
+ }
97
+ //#endregion
98
+ export { pinbox as default, pinbox };
@@ -0,0 +1,11 @@
1
+ import { n as PinboxConfig } from "./index-ZR3JBQu-.js";
2
+ import { ReactElement } from "react";
3
+ //#region src/react.d.ts
4
+ /**
5
+ * `<PinboxToolbar endpoint="http://127.0.0.1:4242" />` — forwards the config ONCE on
6
+ * mount and removes the element on unmount. The element does not support live
7
+ * reconfiguration; remount with a `key` to change endpoints.
8
+ */
9
+ declare function PinboxToolbar(props: PinboxConfig): ReactElement;
10
+ //#endregion
11
+ export { PinboxToolbar };
package/dist/react.js ADDED
@@ -0,0 +1,28 @@
1
+ import { n as defineToolbarElement, r as PinboxToolbarElement } from "./src-BoLg81_j.js";
2
+ import { createElement, useEffect, useRef } from "react";
3
+ //#region src/react.ts
4
+ /**
5
+ * `<PinboxToolbar endpoint="http://127.0.0.1:4242" />` — forwards the config ONCE on
6
+ * mount and removes the element on unmount. The element does not support live
7
+ * reconfiguration; remount with a `key` to change endpoints.
8
+ */
9
+ function PinboxToolbar(props) {
10
+ const host = useRef(null);
11
+ const config = useRef(props);
12
+ config.current = props;
13
+ useEffect(() => {
14
+ defineToolbarElement();
15
+ const el = document.createElement(PinboxToolbarElement.tagName);
16
+ el.configure(config.current);
17
+ host.current?.appendChild(el);
18
+ return () => {
19
+ el.remove();
20
+ };
21
+ }, []);
22
+ return createElement("div", {
23
+ ref: host,
24
+ style: { display: "contents" }
25
+ });
26
+ }
27
+ //#endregion
28
+ export { PinboxToolbar };