From 917d30f515dd37176b7fd43fae2c71186070328f Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Wed, 9 Sep 2026 23:54:46 +0000 Subject: [PATCH] fix: resolve in-page channel functions across chained registries The DF0077 "not registered" fallback and the channelMethod wrapping introduced in #371 broke every user call and all shared-state traffic: the per-name fallback made a registry return a throwing handler for any unknown function name, so chaining `stateRegistry.resolve ?? registry.resolve` short-circuited before the real registry was consulted, and the state layer's already-namespaced handlers got double-prefixed by channelMethod. Move the fallback into a resolveLocalHandler seam that runs after every registry, and register the shared-state handlers verbatim. --- .../devframe/src/in-page-channel/internal.ts | 40 +++++++++++++++---- .../src/in-page-channel/page-script.ts | 7 ++-- .../devframe/src/in-page-channel/panel.ts | 7 ++-- 3 files changed, 41 insertions(+), 13 deletions(-) diff --git a/packages/devframe/src/in-page-channel/internal.ts b/packages/devframe/src/in-page-channel/internal.ts index c5f283bf..88fc5d08 100644 --- a/packages/devframe/src/in-page-channel/internal.ts +++ b/packages/devframe/src/in-page-channel/internal.ts @@ -174,6 +174,7 @@ const FUNCTION_METHOD_PREFIX = channelMethod('function', '') */ export function createLocalFunctionRegistry(codec: InPageChannelSerialization): { register: (definition: InPageFunctionDefinitionAny) => void + registerInternal: (method: string, handler: (...args: unknown[]) => unknown) => void on: (name: string, listener: (...args: unknown[]) => void) => () => void resolve: (name: string) => ((...args: unknown[]) => unknown) | undefined } { @@ -183,6 +184,12 @@ export function createLocalFunctionRegistry(codec: InPageChannelSerialization): register(definition) { definitions.set(channelMethod(definition.type, definition.name), definition) }, + // The shared-state layer keys its handlers by their own fully-qualified + // wire methods (`devframe:in-page:page-state:*`, the panel-state events), + // so they register verbatim rather than through `channelMethod`. + registerInternal(method, handler) { + definitions.set(method, { name: method, handler }) + }, on(name, listener) { const key = channelMethod('event', name) let registered = listeners.get(key) @@ -200,14 +207,8 @@ export function createLocalFunctionRegistry(codec: InPageChannelSerialization): resolve(name) { const definition = definitions.get(name) const registered = listeners.get(name) - if (!definition && !registered?.size) { - if (name.startsWith(FUNCTION_METHOD_PREFIX)) { - return () => { - throw diagnostics.DF0077({ name: name.slice(FUNCTION_METHOD_PREFIX.length) }) - } - } + if (!definition && !registered?.size) return undefined - } return async (...rawArgs: unknown[]) => { const args = codec.deserialize ? rawArgs.map(codec.deserialize) : rawArgs if (definition?.jsonSerializable) @@ -227,6 +228,31 @@ export function createLocalFunctionRegistry(codec: InPageChannelSerialization): } } +type LocalHandler = (...args: unknown[]) => unknown + +/** + * Consult each registry in order and, only once none owns the name, fall back + * to the coded "not registered" error for a `function:` call (events stay + * silent). The fallback lives here, after every registry, so chaining a + * state registry ahead of the user registry never masks a real handler. + */ +export function resolveLocalHandler( + name: string, + registries: ((name: string) => LocalHandler | undefined)[], +): LocalHandler | undefined { + for (const registry of registries) { + const handler = registry(name) + if (handler) + return handler + } + if (name.startsWith(FUNCTION_METHOD_PREFIX)) { + return () => { + throw diagnostics.DF0077({ name: name.slice(FUNCTION_METHOD_PREFIX.length) }) + } + } + return undefined +} + type RemoteFunctions = Record any> export interface AttachChannelPortOptions { diff --git a/packages/devframe/src/in-page-channel/page-script.ts b/packages/devframe/src/in-page-channel/page-script.ts index bfea24b5..f00846c6 100644 --- a/packages/devframe/src/in-page-channel/page-script.ts +++ b/packages/devframe/src/in-page-channel/page-script.ts @@ -15,6 +15,7 @@ import { DEFAULT_CALL_TIMEOUT_MS, deserializeResult, resolveHeartbeat, + resolveLocalHandler, serializeArgs, warnOnce, withCallDeadline, @@ -106,10 +107,10 @@ export function createPageScriptChannel

( }, }) const stateRegistry = createLocalFunctionRegistry(codec) - for (const [name, handler] of Object.entries(internal.internalHandlers)) - stateRegistry.register({ name, handler }) + for (const [method, handler] of Object.entries(internal.internalHandlers)) + stateRegistry.registerInternal(method, handler) internal.attached = attachChannelPort(port, { - resolveLocal: fnName => stateRegistry.resolve(fnName) ?? registry.resolve(fnName), + resolveLocal: fnName => resolveLocalHandler(fnName, [stateRegistry.resolve, registry.resolve]), onControl: (kind) => { if (kind === 'ping') internal.attached.postControl('pong') diff --git a/packages/devframe/src/in-page-channel/panel.ts b/packages/devframe/src/in-page-channel/panel.ts index 5ea14f34..63ce2ed6 100644 --- a/packages/devframe/src/in-page-channel/panel.ts +++ b/packages/devframe/src/in-page-channel/panel.ts @@ -16,6 +16,7 @@ import { deserializeResult, InPageChannelError, resolveHeartbeat, + resolveLocalHandler, serializeArgs, warnOnce, withCallDeadline, @@ -91,8 +92,8 @@ export function connectPanelChannel

( call: (method, args) => enqueueCall(method, serializeArgs(codec, args)), }) const stateRegistry = createLocalFunctionRegistry(codec) - for (const [name, handler] of Object.entries(stateHost.handlers)) - stateRegistry.register({ name, handler }) + for (const [method, handler] of Object.entries(stateHost.handlers)) + stateRegistry.registerInternal(method, handler) function sendEventNow(method: string, args: unknown[]): void { void attached?.rpc.$callRaw({ method, args, event: true, optional: true }).catch(() => {}) @@ -144,7 +145,7 @@ export function connectPanelChannel

( // another instance the user pinned to) replaces the previous port. attached?.dispose({ bye: true, reason: 'the panel adopted a newer port' }) attached = attachChannelPort(port, { - resolveLocal: fnName => stateRegistry.resolve(fnName) ?? registry.resolve(fnName), + resolveLocal: fnName => resolveLocalHandler(fnName, [stateRegistry.resolve, registry.resolve]), onControl: (kind) => { if (kind === 'ping') attached?.postControl('pong')