Skip to content

Commit e678758

Browse files
committed
Support dynamic client action params
1 parent 6b023bb commit e678758

13 files changed

Lines changed: 362 additions & 90 deletions

File tree

apps/docs/src/app/(docs)/2.0/(clients)/client-rendering/page.mdx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,12 @@ input values from `input`, `slider`, `switch`, and `toggle_group` elements are
103103
automatically collected and passed as the `inputs` parameter. See
104104
[Upgrading from v1.0](/snap/client-upgrade) for the POST payload format.
105105

106+
Client action params are resolved from the current local render state before the
107+
handler is called. For example, an `open_url` button can use
108+
`target: { "$template": "https://example.com/?q=${/inputs/query}" }`; the handler
109+
still receives a plain string target after resolution. Clients must validate resolved
110+
URL targets before opening them.
111+
106112
## Full Example
107113

108114
This example shows the complete flow: rendering a snap, handling submit with a signed

apps/docs/src/app/(docs)/2.0/(spec)/actions/page.mdx

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,14 @@ field (`standalone` vs `cast`).
6565

6666
## open_url
6767

68-
Open a URL in the system browser. No server round-trip. No input collection.
68+
Open a URL in the system browser. No server round-trip. No POST payload.
6969

70-
| Param | Type | Required | Description |
71-
| -------- | ------ | -------- | ----------- |
72-
| `target` | string | Yes | URL to open |
70+
`params.target` may be a literal URL or a dynamic string resolved from local render
71+
state at tap time. Field values are available under `/inputs/<name>`.
72+
73+
| Param | Type | Required | Description |
74+
| -------- | ----------------------- | -------- | ----------- |
75+
| `target` | string \| dynamic string | Yes | URL to open |
7376

7477
```json
7578
{
@@ -84,6 +87,27 @@ Open a URL in the system browser. No server round-trip. No input collection.
8487
}
8588
```
8689

90+
```json
91+
{
92+
"type": "button",
93+
"props": { "label": "Start Audit", "icon": "external-link" },
94+
"on": {
95+
"press": {
96+
"action": "open_url",
97+
"params": {
98+
"target": {
99+
"$template": "https://example.com/audit?nsite=${/inputs/website}"
100+
}
101+
}
102+
}
103+
}
104+
}
105+
```
106+
107+
Dynamic targets are resolved by the client before opening. The resolved URL must still
108+
pass the normal URL rules: HTTPS in production, loopback HTTP for local development,
109+
and no `javascript:` URLs.
110+
87111
---
88112

89113
## open_snap
@@ -206,11 +230,15 @@ Navigate to a Farcaster channel.
206230

207231
Open the cast composer with optional pre-filled content.
208232

209-
| Param | Type | Required | Description |
210-
| ------------ | -------- | -------- | ------------------------- |
211-
| `text` | string | No | Pre-filled cast text |
212-
| `channelKey` | string | No | Target channel key |
213-
| `embeds` | string[] | No | URLs to embed in the cast |
233+
Text-like params may be dynamic strings resolved from local render state. For example,
234+
`text: { "$state": "/inputs/castText" }` opens the composer with the current input
235+
value.
236+
237+
| Param | Type | Required | Description |
238+
| ------------ | ----------------------- | -------- | ------------------------- |
239+
| `text` | string \| dynamic string | No | Pre-filled cast text |
240+
| `channelKey` | string \| dynamic string | No | Target channel key |
241+
| `embeds` | string[] | No | URLs to embed in the cast |
214242

215243
> Put URLs in `embeds`, not in `text`.
216244

apps/docs/src/app/(docs)/2.0/(spec)/buttons/page.mdx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,11 @@ production.
9393
For local development, `http://` is valid only when the host is loopback: `localhost`,
9494
`127.0.0.1`, or IPv6 loopback (`[::1]` / `::1`).
9595

96+
For client actions such as `open_url`, `open_mini_app`, and `compose_cast`, action
97+
params may be dynamic strings resolved from local render state when the button is
98+
tapped. Field values are available under `/inputs/<name>`, so a button can open a URL
99+
that includes the current value of an input without sending a POST request.
100+
96101
## Input Data in POST Requests
97102

98103
When a `submit` action fires, the client collects values from all field components on

apps/docs/src/app/(docs)/2.0/(spec)/constraints/page.mdx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,3 +86,6 @@ development and emulators, **`http://` is allowed** when the host is loopback on
8686
targets are invalid.
8787

8888
No `javascript:` URIs.
89+
90+
Dynamic action targets are resolved by the client before the action runs. The resolved
91+
URL must pass the same URL rules before the client opens it.

apps/docs/src/app/(docs)/2.0/(spec)/spec-overview/page.mdx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,13 @@ Example interaction:
1919
3. The client renders the `ui` tree using the component catalog
2020
4. The user interacts with field components (`input`, `slider`, `switch`,
2121
`toggle_group`) — values are stored locally
22-
5. The user taps a `button` element whose `on.press` is bound to a `submit` action
23-
6. The client collects all field values and POSTs a signed payload to the `target` URL
24-
7. The server returns a new SnapResponse — the client renders it as the next page
25-
8. Repeat
22+
5. The user taps a `button` element whose `on.press` is bound to an action
23+
6. For a `submit` action, the client collects all field values and POSTs a signed
24+
payload to the `target` URL
25+
7. For a client action, the client may resolve params from local state and perform the
26+
action without a POST
27+
8. If the action returns a new SnapResponse, the client renders it as the next page
28+
9. Repeat
2629

2730
## Content Negotiation
2831

pkgs/snap/src/react-native/snap-view-core.tsx

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ import {
2828
getUnpresentedSnapEffects,
2929
hasPendingSnapAction,
3030
markSnapEffectsPresented,
31+
optionalSnapStringArray,
32+
resolveSnapActionParams,
33+
validateSnapActionTargetUrl,
3134
type SnapRenderState,
3235
} from "../render-state";
3336
import type { SnapPage, SnapActionHandlers, JsonValue } from "./types";
@@ -42,6 +45,11 @@ function optionalString(value: unknown): string | undefined {
4245
return value ? String(value) : undefined;
4346
}
4447

48+
function validActionTarget(value: unknown): string | undefined {
49+
const target = String(value ?? "");
50+
return validateSnapActionTargetUrl(target) ? undefined : target;
51+
}
52+
4553
function recordValue(value: unknown): Record<string, unknown> | undefined {
4654
return value && typeof value === "object" && !Array.isArray(value)
4755
? (value as Record<string, unknown>)
@@ -259,8 +267,8 @@ export function SnapViewCoreInner({
259267
);
260268

261269
const handleAction = useCallback((name: unknown, params: unknown) => {
270+
const p = resolveSnapActionParams(params, stateRef.current);
262271
const inputs = (stateRef.current.inputs ?? {}) as Record<string, JsonValue>;
263-
const p = (params ?? {}) as Record<string, unknown>;
264272
const h = handlersRef.current;
265273
let result: unknown;
266274
setActionPending(name, p);
@@ -269,15 +277,21 @@ export function SnapViewCoreInner({
269277
case "submit":
270278
result = h.submit(String(p.target ?? ""), inputs);
271279
break;
272-
case "open_url":
273-
result = h.open_url(String(p.target ?? ""));
280+
case "open_url": {
281+
const target = validActionTarget(p.target);
282+
if (target) result = h.open_url(target);
274283
break;
275-
case "open_snap":
276-
result = h.open_snap(String(p.target ?? ""));
284+
}
285+
case "open_snap": {
286+
const target = validActionTarget(p.target);
287+
if (target) result = h.open_snap(target);
277288
break;
278-
case "open_mini_app":
279-
result = h.open_mini_app(String(p.target ?? ""));
289+
}
290+
case "open_mini_app": {
291+
const target = validActionTarget(p.target);
292+
if (target) result = h.open_mini_app(target);
280293
break;
294+
}
281295
case "view_cast":
282296
result = h.view_cast({ hash: String(p.hash ?? "") });
283297
break;
@@ -291,7 +305,7 @@ export function SnapViewCoreInner({
291305
result = h.compose_cast({
292306
text: p.text ? String(p.text) : undefined,
293307
channelKey: p.channelKey ? String(p.channelKey) : undefined,
294-
embeds: Array.isArray(p.embeds) ? (p.embeds as string[]) : undefined,
308+
embeds: optionalSnapStringArray(p.embeds),
295309
});
296310
break;
297311
case "view_token":

pkgs/snap/src/react/snap-view-core.tsx

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ import {
1515
getUnpresentedSnapEffects,
1616
hasPendingSnapAction,
1717
markSnapEffectsPresented,
18+
optionalSnapStringArray,
19+
resolveSnapActionParams,
20+
validateSnapActionTargetUrl,
1821
type SnapRenderState,
1922
} from "../render-state";
2023
import {
@@ -38,6 +41,11 @@ function optionalString(value: unknown): string | undefined {
3841
return value ? String(value) : undefined;
3942
}
4043

44+
function validActionTarget(value: unknown): string | undefined {
45+
const target = String(value ?? "");
46+
return validateSnapActionTargetUrl(target) ? undefined : target;
47+
}
48+
4149
function recordValue(value: unknown): Record<string, unknown> | undefined {
4250
return value && typeof value === "object" && !Array.isArray(value)
4351
? (value as Record<string, unknown>)
@@ -543,27 +551,33 @@ export function SnapViewCore({
543551

544552
const handleAction = useCallback(
545553
(name: unknown, params: unknown) => {
554+
const p = resolveSnapActionParams(params, stateRef.current);
546555
const inputs = (stateRef.current.inputs ?? {}) as Record<
547556
string,
548557
JsonValue
549558
>;
550-
const p = (params ?? {}) as Record<string, unknown>;
551559
let result: unknown;
552560
setActionPending(name, p);
553561

554562
switch (name) {
555563
case "submit":
556564
result = handlers.submit(String(p.target ?? ""), inputs);
557565
break;
558-
case "open_url":
559-
result = handlers.open_url(String(p.target ?? ""));
566+
case "open_url": {
567+
const target = validActionTarget(p.target);
568+
if (target) result = handlers.open_url(target);
560569
break;
561-
case "open_snap":
562-
result = handlers.open_snap(String(p.target ?? ""));
570+
}
571+
case "open_snap": {
572+
const target = validActionTarget(p.target);
573+
if (target) result = handlers.open_snap(target);
563574
break;
564-
case "open_mini_app":
565-
result = handlers.open_mini_app(String(p.target ?? ""));
575+
}
576+
case "open_mini_app": {
577+
const target = validActionTarget(p.target);
578+
if (target) result = handlers.open_mini_app(target);
566579
break;
580+
}
567581
case "view_cast":
568582
result = handlers.view_cast({ hash: String(p.hash ?? "") });
569583
break;
@@ -579,9 +593,7 @@ export function SnapViewCore({
579593
result = handlers.compose_cast({
580594
text: p.text ? String(p.text) : undefined,
581595
channelKey: p.channelKey ? String(p.channelKey) : undefined,
582-
embeds: Array.isArray(p.embeds)
583-
? (p.embeds as string[])
584-
: undefined,
596+
embeds: optionalSnapStringArray(p.embeds),
585597
});
586598
break;
587599
case "view_token":

pkgs/snap/src/render-state.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { resolveActionParam } from "@json-render/core";
2+
13
export type SnapRenderState = Record<string, unknown>;
24

35
export type SnapRenderStateChanges =
@@ -13,6 +15,31 @@ function isRecord(value: unknown): value is Record<string, unknown> {
1315
return typeof value === "object" && value !== null && !Array.isArray(value);
1416
}
1517

18+
function isLoopback(url: URL): boolean {
19+
const host = url.hostname;
20+
return (
21+
host === "localhost" ||
22+
host === "127.0.0.1" ||
23+
host === "::1" ||
24+
host === "[::1]"
25+
);
26+
}
27+
28+
export function validateSnapActionTargetUrl(raw: string): string | null {
29+
let url: URL;
30+
try {
31+
url = new URL(raw);
32+
} catch {
33+
return `Invalid URL: "${raw}"`;
34+
}
35+
36+
if (url.protocol === "https:") return null;
37+
if (url.protocol === "http:" && isLoopback(url)) return null;
38+
if (url.protocol === "javascript:") return `javascript: URIs are not allowed`;
39+
40+
return `URL must use HTTPS (got ${url.protocol.replace(":", "")}): "${raw}"`;
41+
}
42+
1643
function normalizeEffects(effects: readonly string[] | undefined): string[] {
1744
if (!effects) return [];
1845

@@ -158,6 +185,24 @@ export function buildActionActivityStateChanges({
158185
];
159186
}
160187

188+
export function resolveSnapActionParams(
189+
params: unknown,
190+
state: SnapRenderState,
191+
): Record<string, unknown> {
192+
if (!isRecord(params)) return {};
193+
194+
const resolved: Record<string, unknown> = {};
195+
for (const [key, value] of Object.entries(params)) {
196+
resolved[key] = resolveActionParam(value, { stateModel: state });
197+
}
198+
return resolved;
199+
}
200+
201+
export function optionalSnapStringArray(value: unknown): string[] | undefined {
202+
if (Array.isArray(value)) return value.map((item) => String(item));
203+
return value ? [String(value)] : undefined;
204+
}
205+
161206
export function hasPendingSnapAction(model: SnapRenderState): boolean {
162207
const actions = model.actions;
163208
if (!isRecord(actions)) return false;

0 commit comments

Comments
 (0)