Skip to content

Commit c5337a8

Browse files
committed
fix: preserve required in-page function declarations
1 parent 0ed9101 commit c5337a8

9 files changed

Lines changed: 115 additions & 44 deletions

File tree

docs/content/1.guide/12.in-page-channel.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ Channel names are namespaced with the devframe id, like RPC ids. Function names
5454

5555
## The page script endpoint
5656

57-
Request/response functions use the same authoring metadata as `defineRpcFunction` (`type`, Standard-Schema `args`/`returns`, `jsonSerializable`, `handler`), narrowed to the browser. The optional `functions` object registers initial handlers, while `channel.on()` subscribes event listeners at runtime. Each handler is contextually typed from its key and the corresponding function in the protocol. `defineChannelFunction` retains the named definition shape for lower-level authoring. Define each side's functions in that side's source files; the shared protocol file carries only types.
57+
The required `functions` object declares every function on that endpoint's protocol side, preserving a compile-time completeness check. Request/response declarations require a `handler`; an event declaration uses `type: 'event'` and may receive events through either its optional `handler` or runtime `channel.on()` listeners. Functions use the same Standard-Schema `args`/`returns` and `jsonSerializable` metadata as `defineRpcFunction`, narrowed to the browser. Each handler is contextually typed from its key and the corresponding protocol function. `defineChannelFunction` retains the named definition shape for lower-level authoring. Define each side's functions in that side's source files; the shared protocol file carries only types.
5858

5959
```ts
6060
import type { MyChannelProtocol } from '../shared/protocol'
@@ -96,6 +96,9 @@ import { MY_CHANNEL } from '../shared/protocol'
9696

9797
const channel = connectPanelChannel<MyChannelProtocol>({
9898
name: MY_CHANNEL,
99+
functions: {
100+
flash: { type: 'event' },
101+
},
99102
})
100103

101104
const offFlash = channel.on('flash', message => showFlash(message))

packages/devframe/src/in-page-channel/in-page-channel.test.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,12 @@ const defaultPageScriptFunctions: NonNullable<CreatePageScriptChannelOptions<Tes
4343
sum: { handler: (a, b) => a + b },
4444
boom: { handler: () => {} },
4545
strict: { handler: payload => payload },
46-
note: { type: 'event', handler: () => {} },
46+
note: { type: 'event' },
4747
}
4848

4949
const defaultPanelFunctions: NonNullable<ConnectPanelChannelOptions<TestProtocol>['functions']> = {
5050
'ping-panel': { handler: value => `pong:${value}` },
51-
'notify': { type: 'event', handler: () => {} },
51+
'notify': { type: 'event' },
5252
}
5353

5454
function createLinkedPair(options?: {
@@ -196,9 +196,7 @@ describe('in-page channel over bring-your-own ports', () => {
196196
name: 'devframes:test',
197197
...noHandshake,
198198
transport: a.port2,
199-
functions: {
200-
'ping-panel': defaultPanelFunctions['ping-panel'],
201-
},
199+
functions: defaultPanelFunctions,
202200
})
203201
pageScript.emit('notify', 'before-listener')
204202
await new Promise(resolve => setTimeout(resolve, 20))
@@ -209,6 +207,7 @@ describe('in-page channel over bring-your-own ports', () => {
209207
name: 'devframes:test',
210208
...noHandshake,
211209
transport: b.port2,
210+
functions: {},
212211
})
213212
try {
214213
expect(pageScript.panels).toHaveLength(2)

packages/devframe/src/in-page-channel/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export type {
3333
export function defineChannelFunction<
3434
NAME extends string,
3535
TYPE extends InPageFunctionType,
36-
ARGS extends any[],
36+
ARGS extends any[] = [],
3737
RETURN = void,
3838
const AS extends RpcArgsSchema | undefined = undefined,
3939
const RS extends RpcReturnSchema | undefined = undefined,

packages/devframe/src/in-page-channel/internal.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ export function createLocalFunctionRegistry(codec: InPageChannelSerialization):
199199
assertJsonSerializable(args, 'its arguments', definition.name)
200200
if (definition?.args?.length)
201201
await validateArgs(definition.name, definition.args, args)
202-
const result = await definition?.handler(...args)
202+
const result = await definition?.handler?.(...args)
203203
for (const listener of [...(listeners.get(name) ?? [])])
204204
listener(...args)
205205
if (definition?.jsonSerializable)

packages/devframe/src/in-page-channel/page-script.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,8 @@ export function createPageScriptChannel<P extends InPageChannelProtocol>(
6363
let heartbeatTimer: ReturnType<typeof setInterval> | undefined
6464

6565
const registry = createLocalFunctionRegistry(codec)
66-
for (const [fnName, definition] of Object.entries(options.functions ?? {})) {
67-
if (definition)
68-
registry.register({ ...definition, name: fnName })
69-
}
66+
for (const [fnName, definition] of Object.entries(options.functions))
67+
registry.register({ ...definition, name: fnName })
7068

7169
const stateHost = createPageScriptStateHost<P>(function* () {
7270
for (const peer of peers.values()) {

packages/devframe/src/in-page-channel/panel.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,8 @@ export function connectPanelChannel<P extends InPageChannelProtocol>(
6262

6363
const events = createEventEmitter<PanelChannelEvents>()
6464
const registry = createLocalFunctionRegistry(codec)
65-
for (const [fnName, definition] of Object.entries(options.functions ?? {})) {
66-
if (definition)
67-
registry.register({ ...definition, name: fnName })
68-
}
65+
for (const [fnName, definition] of Object.entries(options.functions))
66+
registry.register({ ...definition, name: fnName })
6967

7068
let status: InPageChannelStatus = 'connecting'
7169
let attached: AttachedChannelPort | undefined

packages/devframe/src/in-page-channel/types.test-d.ts

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, expectTypeOf, it } from 'vitest'
2+
import { defineChannelFunction } from './index'
23
import { createPageScriptChannel } from './page-script'
34
import { connectPanelChannel } from './panel'
45

@@ -20,6 +21,19 @@ interface PageScriptOnlyProtocol {
2021
panel: Record<string, never>
2122
}
2223

24+
describe('Channel function definitions', () => {
25+
it('allows events without handlers', () => {
26+
defineChannelFunction({ name: 'notify', type: 'event' })
27+
})
28+
29+
it('requires handlers for request/response functions', () => {
30+
// @ts-expect-error Query functions require a handler.
31+
defineChannelFunction({ name: 'load', type: 'query' })
32+
// @ts-expect-error Action functions require a handler.
33+
defineChannelFunction({ name: 'save', type: 'action' })
34+
})
35+
})
36+
2337
describe('In-page script channel', () => {
2438
const channel = createPageScriptChannel<TestProtocol>({
2539
name: 'devframes:test',
@@ -57,17 +71,40 @@ describe('In-page script channel', () => {
5771
})
5872
})
5973

60-
it('accepts runtime-only and partial function implementations', () => {
74+
it('requires every page-script function declaration', () => {
75+
// @ts-expect-error `functions` is required.
6176
createPageScriptChannel<TestProtocol>({ name: 'devframes:test' })
6277

6378
createPageScriptChannel<TestProtocol>({
6479
name: 'devframes:test',
80+
// @ts-expect-error `sum` and `save` must be declared.
6581
functions: {
6682
echo: { handler: value => value },
6783
},
6884
})
6985
})
7086

87+
it('allows event declarations to omit their handler', () => {
88+
createPageScriptChannel<TestProtocol>({
89+
name: 'devframes:test',
90+
functions: {
91+
echo: { handler: value => value },
92+
sum: { handler: (a, b) => a + b },
93+
save: { type: 'event' },
94+
},
95+
})
96+
97+
createPageScriptChannel<TestProtocol>({
98+
name: 'devframes:test',
99+
functions: {
100+
// @ts-expect-error Request/response functions require a handler.
101+
echo: { type: 'query' },
102+
sum: { handler: (a, b) => a + b },
103+
save: { type: 'event' },
104+
},
105+
})
106+
})
107+
71108
it('rejects panel functions', () => {
72109
createPageScriptChannel<TestProtocol>({
73110
name: 'devframes:test',
@@ -195,15 +232,34 @@ describe('Panel channel', () => {
195232
inferredChannel.close()
196233
})
197234

198-
it('accepts runtime-only and partial function implementations', () => {
235+
it('requires every panel function declaration', () => {
236+
// @ts-expect-error `functions` is required.
199237
connectPanelChannel<TestProtocol>({ name: 'devframes:test' })
200238

201239
connectPanelChannel<TestProtocol>({
202240
name: 'devframes:test',
241+
// @ts-expect-error `notify` must be declared.
203242
functions: {},
204243
})
205244
})
206245

246+
it('allows event declarations to omit their handler', () => {
247+
connectPanelChannel<TestProtocol>({
248+
name: 'devframes:test',
249+
functions: {
250+
notify: { type: 'event' },
251+
},
252+
})
253+
254+
connectPanelChannel<TestProtocol>({
255+
name: 'devframes:test',
256+
functions: {
257+
// @ts-expect-error Request/response functions require a handler.
258+
notify: { type: 'action' },
259+
},
260+
})
261+
})
262+
207263
it('rejects in-page script functions', () => {
208264
connectPanelChannel<TestProtocol>({
209265
name: 'devframes:test',

packages/devframe/src/in-page-channel/types.ts

Lines changed: 29 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@ export type InPageFunctionType = 'action' | 'event' | 'query'
5454
* `dump`/`snapshot`/`cacheable`/`agent`. When `jsonSerializable` is `true`,
5555
* payloads are strictly validated at the receiving endpoint and misshapen
5656
* values reject the call with a descriptive `InPageChannelError` instead of
57-
* a cryptic `DataCloneError` in the port.
57+
* a cryptic `DataCloneError` in the port. Event definitions may omit their
58+
* handler when runtime listeners subscribe through `channel.on()`.
5859
*/
5960
export type InPageFunctionDefinition<
6061
NAME extends string,
@@ -65,24 +66,26 @@ export type InPageFunctionDefinition<
6566
RS extends RpcReturnSchema | undefined = undefined,
6667
>
6768
= [AS, RS] extends [undefined, undefined]
68-
? {
69+
? ({
6970
name: NAME
7071
type?: TYPE
7172
args?: AS
7273
returns?: RS
7374
jsonSerializable?: boolean
74-
handler: (...args: ARGS) => RETURN
75-
}
76-
: {
75+
} & (TYPE extends 'event'
76+
? { handler?: (...args: ARGS) => RETURN }
77+
: { handler: (...args: ARGS) => RETURN }))
78+
: ({
7779
name: NAME
7880
type?: TYPE
7981
/** Standard Schema array validating (and typing) the arguments. */
8082
args: AS
8183
/** Standard Schema typing the resolved return value. */
8284
returns: RS
8385
jsonSerializable?: boolean
84-
handler: (...args: InferArgsType<AS>) => Thenable<InferReturnType<RS>>
85-
}
86+
} & (TYPE extends 'event'
87+
? { handler?: (...args: InferArgsType<AS>) => Thenable<InferReturnType<RS>> }
88+
: { handler: (...args: InferArgsType<AS>) => Thenable<InferReturnType<RS>> }))
8689

8790
/**
8891
* Loosely-typed definition used by the internal function registry.
@@ -96,33 +99,41 @@ export type InPageFunctionDefinitionAny = InPageFunctionDefinition<string, any,
9699
*
97100
* @internal
98101
*/
99-
interface InPageFunctionOption<F> {
100-
type?: InPageFunctionType
102+
interface InPageFunctionOptionBase {
101103
/** Optional Standard Schema array validating the arguments. */
102104
args?: RpcArgsSchema
103105
/** Optional Standard Schema validating the resolved return value. */
104106
returns?: RpcReturnSchema
105107
jsonSerializable?: boolean
106-
handler: ProtocolHandler<F>
107108
}
108109

110+
type InPageFunctionOption<F>
111+
= | (InPageFunctionOptionBase & {
112+
type: 'event'
113+
handler?: ProtocolHandler<F>
114+
})
115+
| (InPageFunctionOptionBase & {
116+
type?: Exclude<InPageFunctionType, 'event'>
117+
handler: ProtocolHandler<F>
118+
})
119+
109120
/**
110121
* Functions implemented by {@link createPageScriptChannel}.
111122
*
112123
* @internal
113124
*/
114-
type CreatePageScriptChannelOptionsFunctions<P extends InPageChannelProtocol> = Partial<{
125+
type CreatePageScriptChannelOptionsFunctions<P extends InPageChannelProtocol> = {
115126
[NAME in keyof PageScriptFunctions<P> & string]: InPageFunctionOption<PageScriptFunctions<P>[NAME]>
116-
}>
127+
}
117128

118129
/**
119130
* Functions implemented by {@link connectPanelChannel}.
120131
*
121132
* @internal
122133
*/
123-
type ConnectPanelChannelOptionsFunctions<P extends InPageChannelProtocol> = Partial<{
134+
type ConnectPanelChannelOptionsFunctions<P extends InPageChannelProtocol> = {
124135
[NAME in keyof PanelFunctions<P> & string]: InPageFunctionOption<PanelFunctions<P>[NAME]>
125-
}>
136+
}
126137

127138
/**
128139
* Connection lifecycle of a panel endpoint: `connecting` (handshake retry
@@ -173,8 +184,8 @@ interface InPageChannelCommonOptions {
173184

174185
/** Options for {@link createPageScriptChannel}. */
175186
export interface CreatePageScriptChannelOptions<Protocol extends InPageChannelProtocol = InPageChannelProtocol> extends InPageChannelCommonOptions {
176-
/** Initial page-script handlers. Event listeners may also use `channel.on()`. */
177-
functions?: CreatePageScriptChannelOptionsFunctions<Protocol>
187+
/** Every page-script function declaration; event handlers may use `channel.on()`. */
188+
functions: CreatePageScriptChannelOptionsFunctions<Protocol>
178189
/**
179190
* Window whose `message` events carry panel hellos. Defaults to the
180191
* global `window`; pass `false` to skip the handshake listener entirely
@@ -185,8 +196,8 @@ export interface CreatePageScriptChannelOptions<Protocol extends InPageChannelPr
185196

186197
/** Options for {@link connectPanelChannel}. */
187198
export interface ConnectPanelChannelOptions<Protocol extends InPageChannelProtocol = InPageChannelProtocol> extends InPageChannelCommonOptions {
188-
/** Initial panel handlers. Event listeners may also use `channel.on()`. */
189-
functions?: ConnectPanelChannelOptionsFunctions<Protocol>
199+
/** Every panel function declaration; event handlers may use `channel.on()`. */
200+
functions: ConnectPanelChannelOptionsFunctions<Protocol>
190201
/**
191202
* The panel's own window (listens for the handshake grant). Defaults to
192203
* the global `window`; pass `false` with `transport` to skip the handshake.

tests/__snapshots__/tsnapi/devframe/in-page-channel.snapshot.d.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
*/
44
// #region Interfaces
55
export interface ConnectPanelChannelOptions<Protocol extends InPageChannelProtocol = InPageChannelProtocol> extends InPageChannelCommonOptions {
6-
functions?: ConnectPanelChannelOptionsFunctions<Protocol>;
6+
functions: ConnectPanelChannelOptionsFunctions<Protocol>;
77
window?: Window | false;
88
targets?: Window[];
99
transport?: MessagePort;
@@ -12,7 +12,7 @@ export interface ConnectPanelChannelOptions<Protocol extends InPageChannelProtoc
1212
eventBufferLimit?: number;
1313
}
1414
export interface CreatePageScriptChannelOptions<Protocol extends InPageChannelProtocol = InPageChannelProtocol> extends InPageChannelCommonOptions {
15-
functions?: CreatePageScriptChannelOptionsFunctions<Protocol>;
15+
functions: CreatePageScriptChannelOptionsFunctions<Protocol>;
1616
window?: Window | false;
1717
}
1818
export interface InPageChannelProtocol {
@@ -62,21 +62,27 @@ export type InPageChannelErrorCode = 'timeout' |
6262
'invalid-args' |
6363
'state-uninitialized';
6464
export type InPageChannelStatus = 'connecting' | 'connected' | 'closed';
65-
export type InPageFunctionDefinition<NAME extends string, TYPE extends InPageFunctionType = 'query', ARGS extends any[] = [], RETURN = void, AS extends RpcArgsSchema | undefined = undefined, RS extends RpcReturnSchema | undefined = undefined> = [AS, RS] extends [undefined, undefined] ? {
65+
export type InPageFunctionDefinition<NAME extends string, TYPE extends InPageFunctionType = 'query', ARGS extends any[] = [], RETURN = void, AS extends RpcArgsSchema | undefined = undefined, RS extends RpcReturnSchema | undefined = undefined> = [AS, RS] extends [undefined, undefined] ? ({
6666
name: NAME;
6767
type?: TYPE;
6868
args?: AS;
6969
returns?: RS;
7070
jsonSerializable?: boolean;
71-
handler: (...args: ARGS) => RETURN;
71+
} & (TYPE extends 'event' ? {
72+
handler?: (...args: ARGS) => RETURN;
7273
} : {
74+
handler: (...args: ARGS) => RETURN;
75+
})) : ({
7376
name: NAME;
7477
type?: TYPE;
7578
args: AS;
7679
returns: RS;
7780
jsonSerializable?: boolean;
81+
} & (TYPE extends 'event' ? {
82+
handler?: (...args: InferArgsType<AS>) => Thenable<InferReturnType<RS>>;
83+
} : {
7884
handler: (...args: InferArgsType<AS>) => Thenable<InferReturnType<RS>>;
79-
};
85+
}));
8086
// #endregion
8187

8288
// #region Classes
@@ -92,12 +98,12 @@ export declare class InPageChannelError extends Error {
9298
// #region Functions
9399
export declare function connectPanelChannel<P extends InPageChannelProtocol>(_: ConnectPanelChannelOptions<P>): PanelChannel<P>;
94100
export declare function createPageScriptChannel<P extends InPageChannelProtocol>(_: CreatePageScriptChannelOptions<P>): PageScriptChannel<P>;
95-
export declare function defineChannelFunction<NAME extends string, TYPE extends InPageFunctionType, ARGS extends any[], RETURN = void, const AS extends RpcArgsSchema | undefined = undefined, const RS extends RpcReturnSchema | undefined = undefined>(_: InPageFunctionDefinition<NAME, TYPE, ARGS, RETURN, AS, RS>): InPageFunctionDefinition<NAME, TYPE, ARGS, RETURN, AS, RS>;
101+
export declare function defineChannelFunction<NAME extends string, TYPE extends InPageFunctionType, ARGS extends any[] = [], RETURN = void, const AS extends RpcArgsSchema | undefined = undefined, const RS extends RpcReturnSchema | undefined = undefined>(_: InPageFunctionDefinition<NAME, TYPE, ARGS, RETURN, AS, RS>): InPageFunctionDefinition<NAME, TYPE, ARGS, RETURN, AS, RS>;
96102
// #endregion
97103

98104
// #region Referenced (internal)
99-
type ConnectPanelChannelOptionsFunctions<P extends InPageChannelProtocol> = Partial<{ [NAME in keyof PanelFunctions<P> & string]: InPageFunctionOption<PanelFunctions<P>[NAME]>; }>;
100-
type CreatePageScriptChannelOptionsFunctions<P extends InPageChannelProtocol> = Partial<{ [NAME in keyof PageScriptFunctions<P> & string]: InPageFunctionOption<PageScriptFunctions<P>[NAME]>; }>;
105+
type ConnectPanelChannelOptionsFunctions<P extends InPageChannelProtocol> = { [NAME in keyof PanelFunctions<P> & string]: InPageFunctionOption<PanelFunctions<P>[NAME]>; };
106+
type CreatePageScriptChannelOptionsFunctions<P extends InPageChannelProtocol> = { [NAME in keyof PageScriptFunctions<P> & string]: InPageFunctionOption<PageScriptFunctions<P>[NAME]>; };
101107
type FnArgs<F> = F extends ((...args: infer A) => any) ? A : never;
102108
type FnReturn<F> = F extends ((...args: any[]) => infer R) ? Awaited<R> : never;
103109
interface InPageChannelCommonOptions {

0 commit comments

Comments
 (0)