Skip to content

Commit 474a0fa

Browse files
committed
feat: type in-page channel functions from protocol
1 parent d25c4b5 commit 474a0fa

8 files changed

Lines changed: 179 additions & 23 deletions

File tree

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

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

5555
## The page script endpoint
5656

57-
Functions are defined with `defineChannelFunction`the same authoring shape as `defineRpcFunction` (`name`, `type`, Standard-Schema `args`/`returns`, `jsonSerializable`, `handler`), narrowed to the browser. Define each side's functions in that side's source files; the shared protocol file carries only types.
57+
Functions use the same authoring shape as `defineRpcFunction` (`name`, `type`, Standard-Schema `args`/`returns`, `jsonSerializable`, `handler`), narrowed to the browser. Each inline definition is contextually typed from its `name` and the corresponding function in the protocol. `defineChannelFunction` provides the same shape when defining a function outside an endpoint's options. 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'
6161
// inject/index.ts — runs in the user app's page
62-
import { createPageScriptChannel, defineChannelFunction } from 'devframe/in-page-channel'
62+
import { createPageScriptChannel } from 'devframe/in-page-channel'
6363
import { MY_CHANNEL } from '../shared/protocol'
6464

6565
const channel = createPageScriptChannel<MyChannelProtocol>({
6666
name: MY_CHANNEL,
6767
functions: [
68-
defineChannelFunction({
68+
{
6969
name: 'highlight',
7070
type: 'event', // fire-and-forget
7171
jsonSerializable: true,
72-
handler: (selector: string) => drawRing(document.querySelector(selector)),
73-
}),
74-
defineChannelFunction({
72+
handler: selector => drawRing(document.querySelector(selector)),
73+
},
74+
{
7575
name: 'measure', // request/response (the default `query` type)
76-
handler: (selector: string) => {
76+
handler: (selector) => {
7777
const rect = document.querySelector(selector)!.getBoundingClientRect()
7878
return { width: rect.width, height: rect.height }
7979
},
80-
}),
80+
},
8181
],
8282
})
8383

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { InPageChannelProtocol, PageScriptChannel, PanelChannel } from './types'
1+
import type { ConnectPanelChannelOptions, CreatePageScriptChannelOptions, InPageChannelProtocol, PageScriptChannel, PanelChannel } from './types'
22
import { describe, expect, it, vi } from 'vitest'
33
import { defineChannelFunction } from './index'
44
import { InPageChannelError } from './internal'
@@ -40,8 +40,8 @@ function until(predicate: () => boolean, timeoutMs = 2000): Promise<void> {
4040
const noHandshake = { window: false as const, heartbeat: false as const }
4141

4242
function createLinkedPair(options?: {
43-
pageScript?: Partial<Parameters<typeof createPageScriptChannel>[0]>
44-
panel?: Partial<Parameters<typeof connectPanelChannel>[0]>
43+
pageScript?: Partial<CreatePageScriptChannelOptions<TestProtocol>>
44+
panel?: Partial<ConnectPanelChannelOptions<TestProtocol>>
4545
}): { pageScript: PageScriptChannel<TestProtocol>, panel: PanelChannel<TestProtocol>, dispose: () => void } {
4646
const { port1, port2 } = new MessageChannel()
4747
const pageScript = createPageScriptChannel<TestProtocol>({

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

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export type {
1717
InPageChannelProtocol,
1818
InPageChannelStatus,
1919
InPageFunctionDefinition,
20+
InPageFunctionDefinitionFor,
2021
PageScriptChannel,
2122
PanelChannel,
2223
PanelPeer,
@@ -31,14 +32,37 @@ export type {
3132
* constant.
3233
*/
3334
export function defineChannelFunction<
34-
NAME extends string,
35-
TYPE extends InPageFunctionType,
35+
const NAME extends string,
3636
ARGS extends any[],
3737
RETURN = void,
38-
const AS extends RpcArgsSchema | undefined = undefined,
39-
const RS extends RpcReturnSchema | undefined = undefined,
38+
TYPE extends InPageFunctionType = 'query',
4039
>(
41-
definition: InPageFunctionDefinition<NAME, TYPE, ARGS, RETURN, AS, RS>,
42-
): InPageFunctionDefinition<NAME, TYPE, ARGS, RETURN, AS, RS> {
40+
definition: {
41+
name: NAME
42+
type?: TYPE
43+
args?: undefined
44+
returns?: undefined
45+
jsonSerializable?: boolean
46+
handler: (...args: ARGS) => RETURN
47+
},
48+
): InPageFunctionDefinition<NAME, TYPE, ARGS, RETURN>
49+
export function defineChannelFunction<
50+
const NAME extends string,
51+
const AS extends RpcArgsSchema,
52+
const RS extends RpcReturnSchema,
53+
TYPE extends InPageFunctionType = 'query',
54+
>(
55+
definition: {
56+
name: NAME
57+
type?: TYPE
58+
args: AS
59+
returns: RS
60+
jsonSerializable?: boolean
61+
handler: InPageFunctionDefinition<NAME, TYPE, never, never, AS, RS>['handler']
62+
},
63+
): InPageFunctionDefinition<NAME, TYPE, never, never, AS, RS>
64+
export function defineChannelFunction(
65+
definition: InPageFunctionDefinition<string, InPageFunctionType, any[], any, any, any>,
66+
): InPageFunctionDefinition<string, InPageFunctionType, any[], any, any, any> {
4367
return definition
4468
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ interface PeerInternal<P extends InPageChannelProtocol> {
4545
* handshake. No server is involved at any point.
4646
*/
4747
export function createPageScriptChannel<P extends InPageChannelProtocol>(
48-
options: CreatePageScriptChannelOptions,
48+
options: CreatePageScriptChannelOptions<P>,
4949
): PageScriptChannel<P> {
5050
const { name } = options
5151
const callTimeoutMs = options.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT_MS

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ const DEFAULT_EVENT_BUFFER_LIMIT = 64
4545
* the UI can key a fallback state off `status` / `whenConnected()`.
4646
*/
4747
export function connectPanelChannel<P extends InPageChannelProtocol>(
48-
options: ConnectPanelChannelOptions,
48+
options: ConnectPanelChannelOptions<P>,
4949
): PanelChannel<P> {
5050
const { name } = options
5151
const callTimeoutMs = options.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT_MS
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import type { InPageChannelProtocol } from './types'
2+
import { describe, it } from 'vitest'
3+
import { createPageScriptChannel } from './page-script'
4+
import { connectPanelChannel } from './panel'
5+
6+
interface TestProtocol extends InPageChannelProtocol {
7+
pageScript: {
8+
echo: (value: string) => string
9+
sum: (a: number, b: number) => number
10+
save: (value: string) => Promise<void>
11+
}
12+
panel: {
13+
notify: (message: string) => void
14+
}
15+
}
16+
17+
describe('in-page channel function definitions', () => {
18+
it('infers page-script handlers from the protocol name', () => {
19+
createPageScriptChannel<TestProtocol>({
20+
name: 'devframes:test',
21+
functions: [
22+
{
23+
name: 'echo',
24+
handler: value => value.toUpperCase(),
25+
},
26+
{
27+
name: 'sum',
28+
handler: (a, b) => a + b,
29+
},
30+
{
31+
name: 'save',
32+
handler: () => {},
33+
},
34+
],
35+
})
36+
})
37+
38+
it('infers panel handlers from the protocol name', () => {
39+
const { port1 } = new MessageChannel()
40+
const channel = connectPanelChannel<TestProtocol>({
41+
name: 'devframes:test',
42+
window: false,
43+
transport: port1,
44+
functions: [{
45+
name: 'notify',
46+
handler: (message) => {
47+
void message
48+
},
49+
}],
50+
})
51+
channel.close()
52+
})
53+
54+
it('rejects names from the remote side', () => {
55+
createPageScriptChannel<TestProtocol>({
56+
name: 'devframes:test',
57+
functions: [
58+
{
59+
// @ts-expect-error `notify` is implemented by panels.
60+
name: 'notify',
61+
handler: (message: string) => void message,
62+
},
63+
],
64+
})
65+
})
66+
67+
it('rejects handlers incompatible with the named protocol function', () => {
68+
createPageScriptChannel<TestProtocol>({
69+
name: 'devframes:test',
70+
functions: [
71+
// @ts-expect-error `echo` accepts and returns a string.
72+
{
73+
name: 'echo',
74+
handler: (value: number) => value,
75+
},
76+
],
77+
})
78+
})
79+
})

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

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ type SharedStates<P extends InPageChannelProtocol>
3030

3131
type FnArgs<F> = F extends (...args: infer A) => any ? A : never
3232
type FnReturn<F> = F extends (...args: any[]) => infer R ? Awaited<R> : never
33+
type ProtocolHandler<F> = F extends (...args: any[]) => any
34+
? (...args: FnArgs<F>) => Thenable<FnReturn<F>>
35+
: never
3336

3437
/**
3538
* Types of an in-page channel function — `RpcFunctionType` minus the
@@ -78,6 +81,40 @@ export type InPageFunctionDefinition<
7881
/** Loosely-typed definition — the registration unit both endpoints accept. */
7982
export type InPageFunctionDefinitionAny = InPageFunctionDefinition<string, any, any, any, any, any>
8083

84+
type ProtocolSide = 'pageScript' | 'panel'
85+
type ProtocolSideFunctions<
86+
P extends InPageChannelProtocol,
87+
SIDE extends ProtocolSide,
88+
> = SideFunctions<NonNullable<P[SIDE]>>
89+
90+
/**
91+
* A function definition constrained by one side of an in-page channel
92+
* protocol. The `name` discriminant selects the matching protocol function,
93+
* which contextually types the handler's arguments and return value.
94+
*/
95+
export type InPageFunctionDefinitionFor<
96+
P extends InPageChannelProtocol,
97+
SIDE extends ProtocolSide,
98+
> = {
99+
[NAME in keyof ProtocolSideFunctions<P, SIDE> & string]: {
100+
name: NAME
101+
type?: InPageFunctionType
102+
/** Optional Standard Schema array validating the arguments. */
103+
args?: RpcArgsSchema
104+
/** Optional Standard Schema validating the resolved return value. */
105+
returns?: RpcReturnSchema
106+
jsonSerializable?: boolean
107+
handler: ProtocolHandler<ProtocolSideFunctions<P, SIDE>[NAME]>
108+
}
109+
}[keyof ProtocolSideFunctions<P, SIDE> & string]
110+
111+
type InPageFunctionOption<
112+
P extends InPageChannelProtocol,
113+
SIDE extends ProtocolSide,
114+
> = InPageChannelProtocol extends P
115+
? InPageFunctionDefinitionAny
116+
: InPageFunctionDefinitionFor<P, SIDE>
117+
81118
/**
82119
* Connection lifecycle of a panel endpoint: `connecting` (handshake retry
83120
* loop running, outgoing traffic buffered) → `connected` → back to
@@ -91,8 +128,6 @@ interface InPageChannelCommonOptions {
91128
* (e.g. `devframes:plugin:a11y`). Both endpoints must use the same name.
92129
*/
93130
name: string
94-
/** Implementations of this endpoint's side of the protocol. */
95-
functions?: readonly InPageFunctionDefinitionAny[]
96131
/**
97132
* Origins accepted during the handshake (and used as `targetOrigin` when
98133
* posting handshake messages). The in-page channel is same-origin by
@@ -123,7 +158,9 @@ interface InPageChannelCommonOptions {
123158
}
124159

125160
/** Options for {@link createPageScriptChannel}. */
126-
export interface CreatePageScriptChannelOptions extends InPageChannelCommonOptions {
161+
export interface CreatePageScriptChannelOptions<Protocol extends InPageChannelProtocol = InPageChannelProtocol> extends InPageChannelCommonOptions {
162+
/** Implementations of the protocol's page-script functions. */
163+
functions?: readonly InPageFunctionOption<Protocol, 'pageScript'>[]
127164
/**
128165
* Window whose `message` events carry panel hellos. Defaults to the
129166
* global `window`; pass `false` to skip the handshake listener entirely
@@ -133,7 +170,9 @@ export interface CreatePageScriptChannelOptions extends InPageChannelCommonOptio
133170
}
134171

135172
/** Options for {@link connectPanelChannel}. */
136-
export interface ConnectPanelChannelOptions extends InPageChannelCommonOptions {
173+
export interface ConnectPanelChannelOptions<Protocol extends InPageChannelProtocol = InPageChannelProtocol> extends InPageChannelCommonOptions {
174+
/** Implementations of the protocol's panel functions. */
175+
functions?: readonly InPageFunctionOption<Protocol, 'panel'>[]
137176
/**
138177
* The panel's own window (listens for the handshake grant). Defaults to
139178
* the global `window`; pass `false` with `transport` to skip the handshake.

packages/devframe/vitest.config.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { defineConfig } from 'vitest/config'
2+
import { alias } from '../../alias'
3+
4+
export default defineConfig({
5+
resolve: { alias },
6+
test: {
7+
name: 'devframe',
8+
testTimeout: 10_000,
9+
typecheck: {
10+
enabled: true,
11+
tsconfig: './tsconfig.json',
12+
},
13+
},
14+
})

0 commit comments

Comments
 (0)