Skip to content

Commit 706f190

Browse files
authored
Support dynamic client action params
* chore: start NEYN-12016 snap education work * Support dynamic client action params * Add dynamic client params changeset * Keep submit action targets literal
1 parent a62d688 commit 706f190

14 files changed

Lines changed: 416 additions & 92 deletions

File tree

.changeset/lemon-tips-roll.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@farcaster/snap": minor
3+
---
4+
5+
Add dynamic client action params

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: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ import {
2828
getUnpresentedSnapEffects,
2929
hasPendingSnapAction,
3030
markSnapEffectsPresented,
31+
optionalSnapStringArray,
32+
resolveSnapActionParamsForAction,
33+
validateSnapActionTargetUrl,
3134
type SnapRenderState,
3235
} from "../render-state";
3336
import type { SnapPage, SnapActionHandlers, JsonValue } from "./types";
@@ -42,6 +45,15 @@ 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+
53+
function literalStringParam(value: unknown): string {
54+
return typeof value === "string" ? value : "";
55+
}
56+
4557
function recordValue(value: unknown): Record<string, unknown> | undefined {
4658
return value && typeof value === "object" && !Array.isArray(value)
4759
? (value as Record<string, unknown>)
@@ -259,25 +271,31 @@ export function SnapViewCoreInner({
259271
);
260272

261273
const handleAction = useCallback((name: unknown, params: unknown) => {
274+
const p = resolveSnapActionParamsForAction(name, params, stateRef.current);
262275
const inputs = (stateRef.current.inputs ?? {}) as Record<string, JsonValue>;
263-
const p = (params ?? {}) as Record<string, unknown>;
264276
const h = handlersRef.current;
265277
let result: unknown;
266278
setActionPending(name, p);
267279

268280
switch (name) {
269281
case "submit":
270-
result = h.submit(String(p.target ?? ""), inputs);
282+
result = h.submit(literalStringParam(p.target), inputs);
271283
break;
272-
case "open_url":
273-
result = h.open_url(String(p.target ?? ""));
284+
case "open_url": {
285+
const target = validActionTarget(p.target);
286+
if (target) result = h.open_url(target);
274287
break;
275-
case "open_snap":
276-
result = h.open_snap(String(p.target ?? ""));
288+
}
289+
case "open_snap": {
290+
const target = validActionTarget(p.target);
291+
if (target) result = h.open_snap(target);
277292
break;
278-
case "open_mini_app":
279-
result = h.open_mini_app(String(p.target ?? ""));
293+
}
294+
case "open_mini_app": {
295+
const target = validActionTarget(p.target);
296+
if (target) result = h.open_mini_app(target);
280297
break;
298+
}
281299
case "view_cast":
282300
result = h.view_cast({ hash: String(p.hash ?? "") });
283301
break;
@@ -291,7 +309,7 @@ export function SnapViewCoreInner({
291309
result = h.compose_cast({
292310
text: p.text ? String(p.text) : undefined,
293311
channelKey: p.channelKey ? String(p.channelKey) : undefined,
294-
embeds: Array.isArray(p.embeds) ? (p.embeds as string[]) : undefined,
312+
embeds: optionalSnapStringArray(p.embeds),
295313
});
296314
break;
297315
case "view_token":

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

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ import {
1515
getUnpresentedSnapEffects,
1616
hasPendingSnapAction,
1717
markSnapEffectsPresented,
18+
optionalSnapStringArray,
19+
resolveSnapActionParamsForAction,
20+
validateSnapActionTargetUrl,
1821
type SnapRenderState,
1922
} from "../render-state";
2023
import {
@@ -38,6 +41,15 @@ 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+
49+
function literalStringParam(value: unknown): string {
50+
return typeof value === "string" ? value : "";
51+
}
52+
4153
function recordValue(value: unknown): Record<string, unknown> | undefined {
4254
return value && typeof value === "object" && !Array.isArray(value)
4355
? (value as Record<string, unknown>)
@@ -543,27 +555,37 @@ export function SnapViewCore({
543555

544556
const handleAction = useCallback(
545557
(name: unknown, params: unknown) => {
558+
const p = resolveSnapActionParamsForAction(
559+
name,
560+
params,
561+
stateRef.current,
562+
);
546563
const inputs = (stateRef.current.inputs ?? {}) as Record<
547564
string,
548565
JsonValue
549566
>;
550-
const p = (params ?? {}) as Record<string, unknown>;
551567
let result: unknown;
552568
setActionPending(name, p);
553569

554570
switch (name) {
555571
case "submit":
556-
result = handlers.submit(String(p.target ?? ""), inputs);
572+
result = handlers.submit(literalStringParam(p.target), inputs);
557573
break;
558-
case "open_url":
559-
result = handlers.open_url(String(p.target ?? ""));
574+
case "open_url": {
575+
const target = validActionTarget(p.target);
576+
if (target) result = handlers.open_url(target);
560577
break;
561-
case "open_snap":
562-
result = handlers.open_snap(String(p.target ?? ""));
578+
}
579+
case "open_snap": {
580+
const target = validActionTarget(p.target);
581+
if (target) result = handlers.open_snap(target);
563582
break;
564-
case "open_mini_app":
565-
result = handlers.open_mini_app(String(p.target ?? ""));
583+
}
584+
case "open_mini_app": {
585+
const target = validActionTarget(p.target);
586+
if (target) result = handlers.open_mini_app(target);
566587
break;
588+
}
567589
case "view_cast":
568590
result = handlers.view_cast({ hash: String(p.hash ?? "") });
569591
break;
@@ -579,9 +601,7 @@ export function SnapViewCore({
579601
result = handlers.compose_cast({
580602
text: p.text ? String(p.text) : undefined,
581603
channelKey: p.channelKey ? String(p.channelKey) : undefined,
582-
embeds: Array.isArray(p.embeds)
583-
? (p.embeds as string[])
584-
: undefined,
604+
embeds: optionalSnapStringArray(p.embeds),
585605
});
586606
break;
587607
case "view_token":

pkgs/snap/src/render-state.ts

Lines changed: 59 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,38 @@ export function buildActionActivityStateChanges({
158185
];
159186
}
160187

188+
export function resolveSnapActionParams(
189+
params: unknown,
190+
state: SnapRenderState,
191+
): Record<string, unknown> {
192+
const actionParams = getSnapActionParams(params);
193+
194+
const resolved: Record<string, unknown> = {};
195+
for (const [key, value] of Object.entries(actionParams)) {
196+
resolved[key] = resolveActionParam(value, { stateModel: state });
197+
}
198+
return resolved;
199+
}
200+
201+
export function getSnapActionParams(params: unknown): Record<string, unknown> {
202+
if (!isRecord(params)) return {};
203+
return cloneSnapRenderState(params);
204+
}
205+
206+
export function resolveSnapActionParamsForAction(
207+
actionName: unknown,
208+
params: unknown,
209+
state: SnapRenderState,
210+
): Record<string, unknown> {
211+
if (actionName === "submit") return getSnapActionParams(params);
212+
return resolveSnapActionParams(params, state);
213+
}
214+
215+
export function optionalSnapStringArray(value: unknown): string[] | undefined {
216+
if (Array.isArray(value)) return value.map((item) => String(item));
217+
return value ? [String(value)] : undefined;
218+
}
219+
161220
export function hasPendingSnapAction(model: SnapRenderState): boolean {
162221
const actions = model.actions;
163222
if (!isRecord(actions)) return false;

0 commit comments

Comments
 (0)