devframe 0.9.5 → 0.9.6

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.
Files changed (66) hide show
  1. package/README.md +1 -1
  2. package/dist/{_shared-CsBsnhuO.d.mts → _shared-BJ6AmwTu.d.mts} +1 -1
  3. package/dist/adapters/build.d.mts +1 -1
  4. package/dist/adapters/build.mjs +1 -1
  5. package/dist/adapters/cac.d.mts +1 -1
  6. package/dist/adapters/cac.mjs +3 -2
  7. package/dist/adapters/dev.d.mts +4 -4
  8. package/dist/adapters/dev.mjs +1 -1
  9. package/dist/adapters/embedded.d.mts +1 -1
  10. package/dist/adapters/initiate.d.mts +3 -3
  11. package/dist/adapters/initiate.mjs +1 -1
  12. package/dist/adapters/mcp.d.mts +30 -26
  13. package/dist/adapters/mcp.mjs +559 -1
  14. package/dist/cli/main.mjs +1 -1
  15. package/dist/client/index.d.mts +5 -5
  16. package/dist/client/index.mjs +1 -64
  17. package/dist/constants.d.mts +13 -1
  18. package/dist/constants.mjs +1 -1
  19. package/dist/{context-c0OlSc12.d.mts → context-BhBzOXVa.d.mts} +3 -3
  20. package/dist/{context-DO0Qu1O8.d.mts → context-HOnG-WvG.d.mts} +1 -1
  21. package/dist/{context-Cx9V9Grq.mjs → context-KUqAfawQ.mjs} +1 -1
  22. package/dist/{context-Bc5S40Qt.mjs → context-omlma_1n.mjs} +3 -150
  23. package/dist/{dev-BRQA42Lh.mjs → dev-Czit5IP4.mjs} +3 -3
  24. package/dist/{events-DzolaBEs.mjs → events-4daw0uK9.mjs} +16 -2
  25. package/dist/in-page-channel/index.d.mts +306 -0
  26. package/dist/in-page-channel/index.mjs +816 -0
  27. package/dist/{index-DOg4S5wk.d.mts → index-BzyopqxV.d.mts} +1 -1
  28. package/dist/{index-B_DPNIT4.d.mts → index-DcKxIMoI.d.mts} +2 -2
  29. package/dist/index.d.mts +4 -4
  30. package/dist/{instance-shell-CPuYY8pw.d.mts → instance-shell-Bh4l9Vz_.d.mts} +3 -3
  31. package/dist/{instance-shell-fB5PyVtR.mjs → instance-shell-CpVW4zG0.mjs} +15 -2
  32. package/dist/internal/index.d.mts +16 -6
  33. package/dist/internal/index.mjs +3 -3
  34. package/dist/node/auth.d.mts +2 -2
  35. package/dist/node/auth.mjs +1 -1
  36. package/dist/node/hub-internals.d.mts +2 -2
  37. package/dist/node/hub-internals.mjs +1 -1
  38. package/dist/node/index.d.mts +3 -3
  39. package/dist/node/index.mjs +1 -1
  40. package/dist/recipes/common-rpc-functions.d.mts +3 -3
  41. package/dist/recipes/interactive-auth.d.mts +13 -2
  42. package/dist/recipes/interactive-auth.mjs +5 -1
  43. package/dist/{revoke-PihtvVil.mjs → revoke-COLW0cOi.mjs} +1 -1
  44. package/dist/rpc/dump.d.mts +1 -1
  45. package/dist/rpc/index.d.mts +2 -2
  46. package/dist/rpc/transports/sse-client.d.mts +1 -1
  47. package/dist/rpc/transports/sse-server.d.mts +2 -2
  48. package/dist/rpc/transports/ws-bun.d.mts +2 -2
  49. package/dist/rpc/transports/ws-client.d.mts +1 -1
  50. package/dist/rpc/transports/ws-deno.d.mts +2 -2
  51. package/dist/rpc/transports/ws-server.d.mts +1 -1
  52. package/dist/{rpc-core-Oyz-cJBC.d.mts → rpc-core-BHUsDl7s.d.mts} +3 -3
  53. package/dist/{host-agent-DbPWo0Bl.mjs → services-install-CqqQVls5.mjs} +150 -2
  54. package/dist/{sse-client-CdGD_B8_.d.mts → sse-client-DwX021ST.d.mts} +1 -1
  55. package/dist/types/index.d.mts +3 -3
  56. package/dist/{types-BBLeGfpt.d.mts → types-CMdPYhOx.d.mts} +1 -1
  57. package/dist/utils/events.d.mts +1 -1
  58. package/dist/utils/serve-static.mjs +1 -1
  59. package/dist/utils/shared-state.d.mts +1 -1
  60. package/dist/utils/streaming-channel.d.mts +1 -1
  61. package/dist/{ws-client-Bo2t6hES.d.mts → ws-client-7_lZi-B9.d.mts} +1 -1
  62. package/dist/{ws-server-DLtEoLdR.d.mts → ws-server-Di2oqr4q.d.mts} +1 -1
  63. package/package.json +2 -1
  64. package/skills/devframe/SKILL.md +86 -82
  65. package/dist/http-D7wWuy7-.mjs +0 -569
  66. package/dist/transports-vhizgqXM.mjs +0 -15
@@ -0,0 +1,816 @@
1
+ import { t as DEVFRAME_EVENTS } from "../events-4daw0uK9.mjs";
2
+ import { createEventEmitter } from "../utils/events.mjs";
3
+ import { t as createSharedState } from "../shared-state-DoXutg_U.mjs";
4
+ import { nanoid } from "../utils/nanoid.mjs";
5
+ import { createBirpc } from "birpc";
6
+ //#region src/in-page-channel/protocol.ts
7
+ /**
8
+ * Wire protocol of the in-page channel handshake and its port-level control
9
+ * frames. The envelope is transport-neutral by design — it identifies the
10
+ * protocol (`channel` tag + `v`), the user channel (`name`), and the peers
11
+ * (`panelId`, `instanceId`) — so a future cross-tab transport (e.g. a
12
+ * `BroadcastChannel`) can reuse it unchanged.
13
+ */
14
+ /** `postMessage` tag every handshake message carries. */
15
+ const IN_PAGE_CHANNEL_TAG = DEVFRAME_EVENTS.postMessage.inPageChannel;
16
+ function isControlFrame(data) {
17
+ return !!data && typeof data === "object" && "__dfIpc" in data;
18
+ }
19
+ function isHandshakeMessage(data) {
20
+ if (!data || typeof data !== "object") return false;
21
+ const message = data;
22
+ return message.channel === IN_PAGE_CHANNEL_TAG && typeof message.name === "string" && typeof message.panelId === "string" && (message.kind === "hello" || message.kind === "grant");
23
+ }
24
+ const INSTANCE_STORAGE_KEY = "devframe:in-page-channel:instance";
25
+ let memoryInstanceId;
26
+ /**
27
+ * The page context's instance id: one nanoid per browser tab, persisted in
28
+ * `sessionStorage` so it survives page-script reloads. It scopes handshakes
29
+ * when the same app is open in several tabs — a panel pinned to an instance
30
+ * id ignores grants from every other tab's page script.
31
+ */
32
+ function resolveInstanceId(win) {
33
+ try {
34
+ const storage = win?.sessionStorage;
35
+ if (storage) {
36
+ let id = storage.getItem(INSTANCE_STORAGE_KEY);
37
+ if (!id) {
38
+ id = nanoid();
39
+ storage.setItem(INSTANCE_STORAGE_KEY, id);
40
+ }
41
+ return id;
42
+ }
43
+ } catch {}
44
+ memoryInstanceId ??= nanoid();
45
+ return memoryInstanceId;
46
+ }
47
+ /** Resolve the origins accepted during the handshake (default: same-origin). */
48
+ function resolveAllowedOrigins(allowedOrigins, win) {
49
+ if (allowedOrigins?.length) return allowedOrigins;
50
+ const origin = win?.location?.origin;
51
+ return origin && origin !== "null" ? [origin] : ["*"];
52
+ }
53
+ /**
54
+ * Default handshake targets of a panel: its ancestor chain plus its
55
+ * `opener` — every same-tab window a page script can live in. `WindowProxy`
56
+ * references stay valid across navigations, so hellos posted to these reach
57
+ * a page script even after the host page reloads.
58
+ */
59
+ function defaultHandshakeTargets(win) {
60
+ const targets = [];
61
+ try {
62
+ let current = win;
63
+ while (current.parent && current.parent !== current) {
64
+ targets.push(current.parent);
65
+ current = current.parent;
66
+ }
67
+ } catch {}
68
+ try {
69
+ const opener = win.opener;
70
+ if (opener && opener !== win) targets.push(opener);
71
+ } catch {}
72
+ return targets;
73
+ }
74
+ const DEFAULT_HEARTBEAT = {
75
+ intervalMs: 5e3,
76
+ timeoutMs: 12e3
77
+ };
78
+ /** Resolve the heartbeat option against its defaults. */
79
+ function resolveHeartbeat(option) {
80
+ return option === false ? void 0 : {
81
+ ...DEFAULT_HEARTBEAT,
82
+ ...option
83
+ };
84
+ }
85
+ /** A coded in-page channel error; every failure mode carries a stable `code`. */
86
+ var InPageChannelError = class extends Error {
87
+ code;
88
+ name = "InPageChannelError";
89
+ constructor(code, message, options) {
90
+ super(message, options);
91
+ this.code = code;
92
+ }
93
+ };
94
+ const warned = /* @__PURE__ */ new Set();
95
+ /**
96
+ * `console.warn` once per distinct message — handshake noise (foreign
97
+ * origins, version mismatches, missing targets) repeats on every retry tick.
98
+ */
99
+ function warnOnce(message) {
100
+ if (!warned.has(message)) {
101
+ warned.add(message);
102
+ console.warn(`[devframe] ${message}`);
103
+ }
104
+ }
105
+ function jsonViolation(value, path, seen) {
106
+ if (value === null || typeof value === "boolean" || typeof value === "string") return void 0;
107
+ if (typeof value === "number") return Number.isFinite(value) ? void 0 : `${path} is ${value}`;
108
+ if (typeof value !== "object") return `${path} is ${value === void 0 ? "`undefined`" : `a ${typeof value}`}`;
109
+ if (seen.has(value)) return `${path} is circular`;
110
+ seen.add(value);
111
+ if (Array.isArray(value)) {
112
+ for (let i = 0; i < value.length; i++) {
113
+ const violation = jsonViolation(value[i], `${path}[${i}]`, seen);
114
+ if (violation) return violation;
115
+ }
116
+ return;
117
+ }
118
+ const proto = Object.getPrototypeOf(value);
119
+ if (proto !== Object.prototype && proto !== null) return `${path} is an instance of ${value.constructor?.name ?? "an exotic class"}`;
120
+ for (const [key, entry] of Object.entries(value)) {
121
+ const violation = jsonViolation(entry, `${path}.${key}`, seen);
122
+ if (violation) return violation;
123
+ }
124
+ }
125
+ /**
126
+ * Enforce a `jsonSerializable: true` contract: throws code
127
+ * `not-serializable` naming the offending path when the value contains
128
+ * anything strict JSON can't represent — surfacing the bug at the offending
129
+ * call instead of a silent coercion later.
130
+ */
131
+ function assertJsonSerializable(value, what, functionName) {
132
+ const violation = jsonViolation(value, what, /* @__PURE__ */ new Set());
133
+ if (violation) throw new InPageChannelError("not-serializable", `in-page function "${functionName}" is declared jsonSerializable, but ${violation}`);
134
+ }
135
+ function formatIssues(issues) {
136
+ return issues.map((issue) => {
137
+ const path = issue.path?.map((s) => typeof s === "object" ? s.key : s).join(".");
138
+ return path ? `${path}: ${issue.message}` : issue.message;
139
+ }).join("; ");
140
+ }
141
+ /**
142
+ * Validate positional arguments against their Standard Schemas (mirrors the
143
+ * RPC layer's `validateRpcArgs`): only indices with a schema are checked,
144
+ * values pass through unchanged, the first failure rejects with code
145
+ * `invalid-args`.
146
+ */
147
+ async function validateArgs(name, schemas, args) {
148
+ for (let index = 0; index < schemas.length; index++) {
149
+ const schema = schemas[index];
150
+ if (!schema) continue;
151
+ const result = await schema["~standard"].validate(args[index]);
152
+ if (result.issues) throw new InPageChannelError("invalid-args", `in-page function "${name}" rejected argument ${index}: ${formatIssues(result.issues)}`);
153
+ }
154
+ }
155
+ function serializeArgs(codec, args) {
156
+ return codec.serialize ? args.map(codec.serialize) : args;
157
+ }
158
+ function deserializeResult(codec, result) {
159
+ return codec.deserialize && result !== void 0 ? codec.deserialize(result) : result;
160
+ }
161
+ /**
162
+ * An endpoint's local function table, resolved by name when the remote side
163
+ * calls in. Each handler is wrapped with the receive pipeline: deserialize
164
+ * hook, `jsonSerializable` enforcement, Standard-Schema argument validation,
165
+ * then serialize hook + `jsonSerializable` enforcement on the result.
166
+ */
167
+ function createLocalFunctionRegistry(codec) {
168
+ const wrapped = /* @__PURE__ */ new Map();
169
+ return {
170
+ register(definition) {
171
+ wrapped.set(definition.name, async (...rawArgs) => {
172
+ const args = codec.deserialize ? rawArgs.map(codec.deserialize) : rawArgs;
173
+ if (definition.jsonSerializable) assertJsonSerializable(args, "its arguments", definition.name);
174
+ if (definition.args?.length) await validateArgs(definition.name, definition.args, args);
175
+ const result = await definition.handler(...args);
176
+ if (definition.jsonSerializable) assertJsonSerializable(result, "its return value", definition.name);
177
+ return codec.serialize && result !== void 0 ? codec.serialize(result) : result;
178
+ });
179
+ },
180
+ resolve: (name) => wrapped.get(name)
181
+ };
182
+ }
183
+ function wrapPostError(error) {
184
+ if (error instanceof DOMException && error.name === "DataCloneError") return new InPageChannelError("not-cloneable", `a payload could not be structured-cloned across the in-page channel: ${error.message}. Strip non-cloneable values (functions, DOM nodes, framework reactivity proxies) before sending — declare the function \`jsonSerializable: true\` for a precise error, or provide a \`serialize\` hook.`, { cause: error });
185
+ return error;
186
+ }
187
+ function attachChannelPort(port, options) {
188
+ let birpcHandler;
189
+ let disposed = false;
190
+ const attached = {
191
+ rpc: void 0,
192
+ lastActivity: Date.now(),
193
+ postControl(kind) {
194
+ try {
195
+ port.postMessage({ __dfIpc: kind });
196
+ } catch {}
197
+ },
198
+ dispose(disposeOptions) {
199
+ if (disposed) return;
200
+ disposed = true;
201
+ if (disposeOptions?.bye) attached.postControl("bye");
202
+ const error = new InPageChannelError("closed", `in-page channel call dropped: ${disposeOptions?.reason ?? "the in-page channel port was closed"}`);
203
+ attached.rpc.$rejectPendingCalls(({ reject }) => reject(error));
204
+ attached.rpc.$close();
205
+ port.removeEventListener("message", onMessage);
206
+ port.removeEventListener("close", onClose);
207
+ try {
208
+ port.close();
209
+ } catch {}
210
+ }
211
+ };
212
+ function onMessage(event) {
213
+ attached.lastActivity = Date.now();
214
+ const data = event.data;
215
+ if (isControlFrame(data)) {
216
+ if (data.__dfIpc === "bye") options.onPeerClosed();
217
+ else options.onControl(data.__dfIpc);
218
+ return;
219
+ }
220
+ birpcHandler?.(data);
221
+ }
222
+ function onClose() {
223
+ options.onPeerClosed();
224
+ }
225
+ attached.rpc = createBirpc({}, {
226
+ post: (data) => {
227
+ try {
228
+ port.postMessage(data);
229
+ } catch (error) {
230
+ throw wrapPostError(error);
231
+ }
232
+ },
233
+ on: (fn) => {
234
+ birpcHandler = fn;
235
+ },
236
+ off: () => {
237
+ birpcHandler = void 0;
238
+ },
239
+ timeout: -1,
240
+ proxify: false,
241
+ resolver: (name, resolved) => resolved ?? options.resolveLocal(name)
242
+ });
243
+ port.addEventListener("message", onMessage);
244
+ port.addEventListener("close", onClose);
245
+ port.start?.();
246
+ return attached;
247
+ }
248
+ /**
249
+ * Race a call against its deadline, rejecting with a status-aware error
250
+ * (code `timeout`). `ms <= 0` disables the deadline.
251
+ */
252
+ function withCallDeadline(promise, ms, describeTimeout) {
253
+ if (ms <= 0) return promise;
254
+ return new Promise((resolve, reject) => {
255
+ const timer = setTimeout(() => reject(new InPageChannelError("timeout", describeTimeout())), ms);
256
+ promise.then((value) => {
257
+ clearTimeout(timer);
258
+ resolve(value);
259
+ }, (error) => {
260
+ clearTimeout(timer);
261
+ reject(error);
262
+ });
263
+ });
264
+ }
265
+ //#endregion
266
+ //#region src/in-page-channel/state.ts
267
+ /**
268
+ * The channel shared-state layer — `createSharedState` pumped over the
269
+ * in-page channel, mirroring the RPC shared-state wire design with the page
270
+ * script playing the server's role: it owns the canonical value, replays a
271
+ * snapshot to every (re)subscribing panel, and fans syncId-deduplicated
272
+ * updates out to the panels subscribed to each key.
273
+ *
274
+ * Request endpoints (panel → page script) are defined here at their
275
+ * handlers, like the RPC layer's `devframe:rpc:server-state:*`; the
276
+ * page-script → panel notifications live in `DEVFRAME_EVENTS.inPageChannel`.
277
+ */
278
+ const IN_PAGE_STATE_RPC = {
279
+ /** Subscribe to a key; returns the authority's current snapshot. */
280
+ subscribe: "devframe:in-page:page-state:subscribe",
281
+ /** Replace a key's value (panel mutation forwarded up). */
282
+ set: "devframe:in-page:page-state:set",
283
+ /** Patch a key's value (panel mutation forwarded up). */
284
+ patch: "devframe:in-page:page-state:patch"
285
+ };
286
+ /** The page-script (authority) half of the channel shared-state layer. */
287
+ function createPageScriptStateHost(peers) {
288
+ const states = /* @__PURE__ */ new Map();
289
+ return {
290
+ get: async (key, options) => {
291
+ const existing = states.get(key);
292
+ if (existing) return existing;
293
+ if (options?.initialValue === void 0) throw new InPageChannelError("state-uninitialized", `in-page shared state "${key}" was accessed before initialization — the page script is the authority, so its first \`sharedState.get("${key}")\` must provide \`initialValue\``);
294
+ const state = createSharedState({
295
+ initialValue: options.initialValue,
296
+ enablePatches: true
297
+ });
298
+ states.set(key, state);
299
+ state.on("updated", (fullState, patches, syncId) => {
300
+ for (const peer of peers()) {
301
+ if (!peer.subscribedStates.has(key)) continue;
302
+ if (patches) peer.callEventRaw(DEVFRAME_EVENTS.inPageChannel.panelStatePatch, [
303
+ key,
304
+ patches,
305
+ syncId
306
+ ]);
307
+ else peer.callEventRaw(DEVFRAME_EVENTS.inPageChannel.panelStateUpdated, [
308
+ key,
309
+ fullState,
310
+ syncId
311
+ ]);
312
+ }
313
+ });
314
+ return state;
315
+ },
316
+ createPeerHandlers(peer) {
317
+ return {
318
+ [IN_PAGE_STATE_RPC.subscribe]: (key) => {
319
+ peer.subscribedStates.add(key);
320
+ return states.get(key)?.value();
321
+ },
322
+ [IN_PAGE_STATE_RPC.set]: (key, fullState, syncId) => {
323
+ const state = states.get(key);
324
+ if (state && !state.syncIds.has(syncId)) state.mutate(() => fullState, syncId);
325
+ },
326
+ [IN_PAGE_STATE_RPC.patch]: (key, patches, syncId) => {
327
+ states.get(key)?.patch(patches, syncId);
328
+ }
329
+ };
330
+ }
331
+ };
332
+ }
333
+ /** The panel (mirror) half of the channel shared-state layer. */
334
+ function createPanelStateHost(options) {
335
+ const states = /* @__PURE__ */ new Map();
336
+ const seeded = /* @__PURE__ */ new Set();
337
+ const seedWaiters = /* @__PURE__ */ new Map();
338
+ /**
339
+ * SyncIds of snapshot adoptions. Adopting the authority's replay must not
340
+ * echo straight back up as a `set` — the authority already has the value.
341
+ */
342
+ const adoptedSyncIds = /* @__PURE__ */ new Set();
343
+ function markSeeded(key) {
344
+ seeded.add(key);
345
+ const waiters = seedWaiters.get(key);
346
+ if (waiters) {
347
+ seedWaiters.delete(key);
348
+ for (const resolve of waiters) resolve();
349
+ }
350
+ }
351
+ function adopt(key, snapshot) {
352
+ const state = states.get(key);
353
+ if (state && snapshot !== void 0) {
354
+ const syncId = nanoid();
355
+ adoptedSyncIds.add(syncId);
356
+ state.mutate(() => snapshot, syncId);
357
+ }
358
+ markSeeded(key);
359
+ }
360
+ function subscribeNow(key) {
361
+ options.call(IN_PAGE_STATE_RPC.subscribe, [key]).then((snapshot) => adopt(key, snapshot)).catch((error) => {
362
+ console.warn(`[devframe] in-page shared state "${key}": subscribe failed`, error);
363
+ });
364
+ }
365
+ return {
366
+ handlers: {
367
+ [DEVFRAME_EVENTS.inPageChannel.panelStateUpdated]: (key, fullState, syncId) => {
368
+ const state = states.get(key);
369
+ if (state && !state.syncIds.has(syncId)) state.mutate(() => fullState, syncId);
370
+ markSeeded(key);
371
+ },
372
+ [DEVFRAME_EVENTS.inPageChannel.panelStatePatch]: (key, patches, syncId) => {
373
+ states.get(key)?.patch(patches, syncId);
374
+ markSeeded(key);
375
+ }
376
+ },
377
+ resubscribe() {
378
+ for (const key of states.keys()) subscribeNow(key);
379
+ },
380
+ get: (key, getOptions) => {
381
+ const existing = states.get(key);
382
+ if (existing) return Promise.resolve(existing);
383
+ const state = createSharedState({
384
+ initialValue: getOptions?.initialValue,
385
+ enablePatches: true
386
+ });
387
+ states.set(key, state);
388
+ state.on("updated", (fullState, patches, syncId) => {
389
+ if (adoptedSyncIds.delete(syncId)) return;
390
+ if (!options.isConnected()) return;
391
+ if (patches) options.callEvent(IN_PAGE_STATE_RPC.patch, [
392
+ key,
393
+ patches,
394
+ syncId
395
+ ]);
396
+ else options.callEvent(IN_PAGE_STATE_RPC.set, [
397
+ key,
398
+ fullState,
399
+ syncId
400
+ ]);
401
+ });
402
+ if (options.isConnected()) subscribeNow(key);
403
+ if (getOptions?.initialValue !== void 0) return Promise.resolve(state);
404
+ return new Promise((resolve) => {
405
+ if (seeded.has(key)) {
406
+ resolve(state);
407
+ return;
408
+ }
409
+ const waiters = seedWaiters.get(key) ?? [];
410
+ waiters.push(() => resolve(state));
411
+ seedWaiters.set(key, waiters);
412
+ });
413
+ }
414
+ };
415
+ }
416
+ //#endregion
417
+ //#region src/in-page-channel/page-script.ts
418
+ /**
419
+ * Create the page-script endpoint of an in-page channel.
420
+ *
421
+ * It listens for panel hellos on the host page's window and answers each
422
+ * with a dedicated `MessageChannel` port (same-origin enforced both ways),
423
+ * keeping one live peer per panel — dock iframe, popup, and Document-PiP
424
+ * panels all handshake the same way, and a panel reload is simply a new
425
+ * handshake. No server is involved at any point.
426
+ */
427
+ function createPageScriptChannel(options) {
428
+ const { name } = options;
429
+ const callTimeoutMs = options.callTimeoutMs ?? 15e3;
430
+ const win = options.window === false ? void 0 : options.window ?? (typeof window === "undefined" ? void 0 : window);
431
+ const allowedOrigins = resolveAllowedOrigins(options.allowedOrigins, win);
432
+ const instanceId = resolveInstanceId(win);
433
+ const heartbeat = resolveHeartbeat(options.heartbeat);
434
+ const codec = {
435
+ serialize: options.serialize,
436
+ deserialize: options.deserialize
437
+ };
438
+ const events = createEventEmitter();
439
+ const peers = /* @__PURE__ */ new Map();
440
+ let closed = false;
441
+ let heartbeatTimer;
442
+ const registry = createLocalFunctionRegistry(codec);
443
+ for (const definition of options.functions ?? []) registry.register(definition);
444
+ const stateHost = createPageScriptStateHost(function* () {
445
+ for (const peer of peers.values()) yield {
446
+ subscribedStates: peer.subscribedStates,
447
+ callEventRaw: (method, args) => {
448
+ peer.attached.rpc.$callRaw({
449
+ method,
450
+ args,
451
+ event: true,
452
+ optional: true
453
+ }).catch(() => {});
454
+ }
455
+ };
456
+ });
457
+ function removePeer(id, disposeOptions) {
458
+ const peer = peers.get(id);
459
+ if (!peer) return;
460
+ peers.delete(id);
461
+ peer.attached.dispose(disposeOptions);
462
+ if (peers.size === 0 && heartbeatTimer) {
463
+ clearInterval(heartbeatTimer);
464
+ heartbeatTimer = void 0;
465
+ }
466
+ events.emit("panel:disconnected", peer.peer);
467
+ }
468
+ function addPeer(port, id) {
469
+ removePeer(id, { reason: "the panel re-connected" });
470
+ const internal = {
471
+ id,
472
+ subscribedStates: /* @__PURE__ */ new Set()
473
+ };
474
+ internal.internalHandlers = stateHost.createPeerHandlers({
475
+ subscribedStates: internal.subscribedStates,
476
+ callEventRaw: (method, args) => {
477
+ internal.attached.rpc.$callRaw({
478
+ method,
479
+ args,
480
+ event: true,
481
+ optional: true
482
+ }).catch(() => {});
483
+ }
484
+ });
485
+ internal.attached = attachChannelPort(port, {
486
+ resolveLocal: (fnName) => internal.internalHandlers[fnName] ?? registry.resolve(fnName),
487
+ onControl: (kind) => {
488
+ if (kind === "ping") internal.attached.postControl("pong");
489
+ },
490
+ onPeerClosed: () => removePeer(id, { reason: "the panel disconnected" })
491
+ });
492
+ internal.peer = {
493
+ id,
494
+ call: (fnName, ...args) => withCallDeadline(internal.attached.rpc.$call(fnName, ...serializeArgs(codec, args)).then((result) => deserializeResult(codec, result)), callTimeoutMs, () => `in-page channel "${name}": call "${fnName}" to panel "${id}" timed out after ${callTimeoutMs}ms`),
495
+ close: () => removePeer(id, {
496
+ bye: true,
497
+ reason: "the page script closed this panel"
498
+ })
499
+ };
500
+ peers.set(id, internal);
501
+ if (heartbeat && !heartbeatTimer) heartbeatTimer = setInterval(() => {
502
+ const now = Date.now();
503
+ for (const peer of [...peers.values()]) if (now - peer.attached.lastActivity > heartbeat.timeoutMs) removePeer(peer.id, { reason: "the panel went silent (heartbeat timeout)" });
504
+ }, heartbeat.intervalMs);
505
+ events.emit("panel:connected", internal.peer);
506
+ return internal.peer;
507
+ }
508
+ const onWindowMessage = (event) => {
509
+ if (closed) return;
510
+ const data = event.data;
511
+ if (!isHandshakeMessage(data) || data.kind !== "hello" || data.name !== name) return;
512
+ if (data.v !== 1) {
513
+ warnOnce(`in-page channel "${name}": ignoring a hello with protocol version ${data.v} (this side speaks 1) — align the devframe versions of the page script and the panel`);
514
+ return;
515
+ }
516
+ if (!allowedOrigins.includes("*") && !allowedOrigins.includes(event.origin)) {
517
+ warnOnce(`in-page channel "${name}": ignoring a hello from disallowed origin "${event.origin}"`);
518
+ return;
519
+ }
520
+ if (data.instanceId && data.instanceId !== instanceId) return;
521
+ const source = event.source;
522
+ if (!source || typeof source.postMessage !== "function") return;
523
+ const messageChannel = new MessageChannel();
524
+ addPeer(messageChannel.port1, data.panelId);
525
+ const grant = {
526
+ channel: IN_PAGE_CHANNEL_TAG,
527
+ v: 1,
528
+ kind: "grant",
529
+ name,
530
+ panelId: data.panelId,
531
+ instanceId
532
+ };
533
+ try {
534
+ source.postMessage(grant, allowedOrigins.includes("*") ? "*" : event.origin, [messageChannel.port2]);
535
+ } catch (error) {
536
+ console.warn(`[devframe] in-page channel "${name}": failed to grant a port`, error);
537
+ removePeer(data.panelId, { reason: "the grant could not be delivered" });
538
+ }
539
+ };
540
+ win?.addEventListener("message", onWindowMessage);
541
+ return {
542
+ name,
543
+ instanceId,
544
+ get panels() {
545
+ return [...peers.values()].map((peer) => peer.peer);
546
+ },
547
+ events: {
548
+ on: events.on,
549
+ once: events.once
550
+ },
551
+ callEvent: (fnName, ...args) => {
552
+ const wireArgs = serializeArgs(codec, args);
553
+ for (const peer of peers.values()) peer.attached.rpc.$callRaw({
554
+ method: fnName,
555
+ args: wireArgs,
556
+ event: true,
557
+ optional: true
558
+ }).catch(() => {});
559
+ },
560
+ sharedState: stateHost,
561
+ addPanelPort: (port) => addPeer(port, `transport:${nanoid(8)}`),
562
+ close: () => {
563
+ if (closed) return;
564
+ closed = true;
565
+ win?.removeEventListener("message", onWindowMessage);
566
+ for (const id of [...peers.keys()]) removePeer(id, {
567
+ bye: true,
568
+ reason: "the page script closed the channel"
569
+ });
570
+ }
571
+ };
572
+ }
573
+ //#endregion
574
+ //#region src/in-page-channel/panel.ts
575
+ const DEFAULT_HELLO_INTERVAL_MS = 300;
576
+ const HELLO_INTERVAL_CAP_MS = 3e3;
577
+ const DEFAULT_EVENT_BUFFER_LIMIT = 64;
578
+ /**
579
+ * Connect the panel endpoint of an in-page channel.
580
+ *
581
+ * The panel initiates: it posts a versioned hello to every window a
582
+ * same-tab page script can live in (its ancestor chain and its `opener`),
583
+ * retrying with backoff until one answers with a dedicated port — so boot
584
+ * order never matters, and a reload of either side is just a re-handshake
585
+ * (`WindowProxy` references survive navigations). While `connecting`,
586
+ * outgoing calls and events are buffered; when no page script exists at all
587
+ * (e.g. the panel opened standalone), the endpoint stays `connecting` and
588
+ * the UI can key a fallback state off `status` / `whenConnected()`.
589
+ */
590
+ function connectPanelChannel(options) {
591
+ const { name } = options;
592
+ const callTimeoutMs = options.callTimeoutMs ?? 15e3;
593
+ const eventBufferLimit = options.eventBufferLimit ?? DEFAULT_EVENT_BUFFER_LIMIT;
594
+ const win = options.window === false ? void 0 : options.window ?? (typeof window === "undefined" ? void 0 : window);
595
+ const allowedOrigins = resolveAllowedOrigins(options.allowedOrigins, win);
596
+ const heartbeat = resolveHeartbeat(options.heartbeat);
597
+ const codec = {
598
+ serialize: options.serialize,
599
+ deserialize: options.deserialize
600
+ };
601
+ const panelId = nanoid();
602
+ const targets = options.targets ?? (win ? defaultHandshakeTargets(win) : []);
603
+ const canHandshake = !!win && targets.length > 0;
604
+ const events = createEventEmitter();
605
+ const registry = createLocalFunctionRegistry(codec);
606
+ for (const definition of options.functions ?? []) registry.register(definition);
607
+ let status = "connecting";
608
+ let attached;
609
+ let pageScriptInfo;
610
+ let helloTimer;
611
+ let heartbeatTimer;
612
+ let droppedEventsWarned = false;
613
+ const pendingCalls = [];
614
+ const eventBuffer = [];
615
+ const connectedWaiters = [];
616
+ function setStatus(next) {
617
+ if (status !== next) {
618
+ status = next;
619
+ events.emit("status:updated", next);
620
+ }
621
+ }
622
+ const stateHost = createPanelStateHost({
623
+ isConnected: () => status === "connected",
624
+ callEvent: (method, args) => sendEvent(method, args),
625
+ call: (method, args) => enqueueCall(method, args)
626
+ });
627
+ function sendEventNow(method, args) {
628
+ attached?.rpc.$callRaw({
629
+ method,
630
+ args,
631
+ event: true,
632
+ optional: true
633
+ }).catch(() => {});
634
+ }
635
+ function sendEvent(method, args) {
636
+ if (status === "closed") return;
637
+ if (status === "connected" && attached) {
638
+ sendEventNow(method, args);
639
+ return;
640
+ }
641
+ if (eventBuffer.length >= eventBufferLimit) {
642
+ eventBuffer.shift();
643
+ if (!droppedEventsWarned) {
644
+ droppedEventsWarned = true;
645
+ console.warn(`[devframe] in-page channel "${name}": event buffer overflowed while connecting — oldest events are being dropped (limit ${eventBufferLimit})`);
646
+ }
647
+ }
648
+ eventBuffer.push({
649
+ method,
650
+ args
651
+ });
652
+ }
653
+ function enqueueCall(method, args) {
654
+ if (status === "closed") return Promise.reject(new InPageChannelError("closed", `in-page channel "${name}": call "${method}" rejected — the channel is closed`));
655
+ return withCallDeadline(new Promise((resolve, reject) => {
656
+ const run = () => {
657
+ attached.rpc.$call(method, ...args).then((result) => resolve(deserializeResult(codec, result)), reject);
658
+ };
659
+ if (status === "connected" && attached) run();
660
+ else pendingCalls.push({
661
+ run,
662
+ reject
663
+ });
664
+ }), callTimeoutMs, () => `in-page channel "${name}": call "${method}" timed out after ${callTimeoutMs}ms (status: ${status}${status === "connecting" ? " — is the page script loaded?" : ""})`);
665
+ }
666
+ function adoptPort(port, info) {
667
+ attached?.dispose({
668
+ bye: true,
669
+ reason: "the panel adopted a newer port"
670
+ });
671
+ attached = attachChannelPort(port, {
672
+ resolveLocal: (fnName) => stateHost.handlers[fnName] ?? registry.resolve(fnName),
673
+ onControl: (kind) => {
674
+ if (kind === "ping") attached?.postControl("pong");
675
+ },
676
+ onPeerClosed: () => handleDisconnect("the page script went away")
677
+ });
678
+ pageScriptInfo = info;
679
+ stopTimers();
680
+ setStatus("connected");
681
+ if (heartbeat) heartbeatTimer = setInterval(() => {
682
+ if (!attached) return;
683
+ if (Date.now() - attached.lastActivity > heartbeat.timeoutMs) handleDisconnect("the page script went silent (heartbeat timeout)");
684
+ else attached.postControl("ping");
685
+ }, heartbeat.intervalMs);
686
+ for (const call of pendingCalls.splice(0)) call.run();
687
+ for (const { method, args } of eventBuffer.splice(0)) sendEventNow(method, args);
688
+ stateHost.resubscribe();
689
+ for (const waiter of connectedWaiters.splice(0)) waiter.resolve();
690
+ }
691
+ function stopTimers() {
692
+ if (helloTimer) {
693
+ clearTimeout(helloTimer);
694
+ helloTimer = void 0;
695
+ }
696
+ if (heartbeatTimer) {
697
+ clearInterval(heartbeatTimer);
698
+ heartbeatTimer = void 0;
699
+ }
700
+ }
701
+ function handleDisconnect(reason) {
702
+ if (status === "closed") return;
703
+ attached?.dispose({ reason });
704
+ attached = void 0;
705
+ pageScriptInfo = void 0;
706
+ stopTimers();
707
+ setStatus("connecting");
708
+ if (canHandshake) startHelloLoop();
709
+ else warnOnce(`in-page channel "${name}": transport lost (${reason}) and the panel has no handshake targets — staying disconnected`);
710
+ }
711
+ function startHelloLoop() {
712
+ if (helloTimer || !canHandshake || status !== "connecting") return;
713
+ let delay = options.helloIntervalMs ?? DEFAULT_HELLO_INTERVAL_MS;
714
+ const tick = () => {
715
+ const hello = {
716
+ channel: IN_PAGE_CHANNEL_TAG,
717
+ v: 1,
718
+ kind: "hello",
719
+ name,
720
+ panelId,
721
+ instanceId: options.instanceId
722
+ };
723
+ for (const target of targets) for (const origin of allowedOrigins) try {
724
+ target.postMessage(hello, origin);
725
+ } catch {}
726
+ delay = Math.min(delay * 1.5, HELLO_INTERVAL_CAP_MS);
727
+ helloTimer = setTimeout(tick, delay);
728
+ };
729
+ tick();
730
+ }
731
+ const onWindowMessage = (event) => {
732
+ if (status === "closed") return;
733
+ const data = event.data;
734
+ if (!isHandshakeMessage(data) || data.kind !== "grant" || data.name !== name || data.panelId !== panelId) return;
735
+ if (data.v !== 1) {
736
+ warnOnce(`in-page channel "${name}": ignoring a grant with protocol version ${data.v} (this side speaks 1) — align the devframe versions of the page script and the panel`);
737
+ return;
738
+ }
739
+ if (!allowedOrigins.includes("*") && !allowedOrigins.includes(event.origin)) {
740
+ warnOnce(`in-page channel "${name}": ignoring a grant from disallowed origin "${event.origin}"`);
741
+ return;
742
+ }
743
+ if (options.instanceId && data.instanceId !== options.instanceId) return;
744
+ const port = event.ports?.[0];
745
+ if (port) adoptPort(port, { instanceId: data.instanceId });
746
+ };
747
+ win?.addEventListener("message", onWindowMessage);
748
+ if (options.transport) adoptPort(options.transport);
749
+ else if (canHandshake) startHelloLoop();
750
+ else warnOnce(`in-page channel "${name}": the panel has no handshake targets (not embedded, no opener) and no transport — calls will buffer until a transport appears or the channel is closed`);
751
+ return {
752
+ name,
753
+ get status() {
754
+ return status;
755
+ },
756
+ get pageScript() {
757
+ return pageScriptInfo;
758
+ },
759
+ events: {
760
+ on: events.on,
761
+ once: events.once
762
+ },
763
+ whenConnected: (timeoutMs) => {
764
+ if (status === "connected") return Promise.resolve();
765
+ if (status === "closed") return Promise.reject(new InPageChannelError("closed", `in-page channel "${name}" is closed`));
766
+ return new Promise((resolve, reject) => {
767
+ const waiter = {
768
+ resolve,
769
+ reject
770
+ };
771
+ connectedWaiters.push(waiter);
772
+ if (timeoutMs !== void 0 && timeoutMs > 0) setTimeout(() => {
773
+ const index = connectedWaiters.indexOf(waiter);
774
+ if (index >= 0) {
775
+ connectedWaiters.splice(index, 1);
776
+ reject(new InPageChannelError("timeout", `in-page channel "${name}": no page script answered within ${timeoutMs}ms — it may not be loaded in this context (render a fallback state)`));
777
+ }
778
+ }, timeoutMs);
779
+ });
780
+ },
781
+ call: (fnName, ...args) => enqueueCall(fnName, serializeArgs(codec, args)),
782
+ callEvent: (fnName, ...args) => sendEvent(fnName, serializeArgs(codec, args)),
783
+ sharedState: stateHost,
784
+ close: () => {
785
+ if (status === "closed") return;
786
+ setStatus("closed");
787
+ stopTimers();
788
+ win?.removeEventListener("message", onWindowMessage);
789
+ attached?.dispose({
790
+ bye: true,
791
+ reason: "the panel closed the channel"
792
+ });
793
+ attached = void 0;
794
+ pageScriptInfo = void 0;
795
+ const closedError = new InPageChannelError("closed", `in-page channel "${name}" was closed`);
796
+ for (const call of pendingCalls.splice(0)) call.reject(closedError);
797
+ eventBuffer.length = 0;
798
+ for (const waiter of connectedWaiters.splice(0)) waiter.reject(closedError);
799
+ }
800
+ };
801
+ }
802
+ //#endregion
803
+ //#region src/in-page-channel/index.ts
804
+ /**
805
+ * Define one in-page channel function — the `defineRpcFunction` authoring
806
+ * shape narrowed to the browser (see {@link InPageFunctionDefinition}).
807
+ * Pure identity: it only types the definition. Functions live in
808
+ * side-specific files and are passed to their endpoint via `functions`; the
809
+ * shared protocol file carries only the contract type and the channel-name
810
+ * constant.
811
+ */
812
+ function defineChannelFunction(definition) {
813
+ return definition;
814
+ }
815
+ //#endregion
816
+ export { InPageChannelError, connectPanelChannel, createPageScriptChannel, defineChannelFunction };