@coordination-network/user-app-link 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +140 -0
- package/dist/index.cjs +580 -0
- package/dist/index.js +539 -0
- package/dist/web.cjs +560 -0
- package/dist/web.js +555 -0
- package/package.json +55 -0
- package/src/client.ts +138 -0
- package/src/codec.ts +134 -0
- package/src/errors.ts +32 -0
- package/src/handler.ts +95 -0
- package/src/index.ts +6 -0
- package/src/schemas.ts +261 -0
- package/src/types.ts +176 -0
- package/src/web.ts +327 -0
- package/types/index.d.ts +440 -0
- package/types/web.d.ts +81 -0
package/src/types.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/** Protocol v1 types for the user-app <-> network-member-app deep link. */
|
|
2
|
+
|
|
3
|
+
/** A `0x`-prefixed lowercase hexadecimal string. Byte-length aliases below are documentation only. */
|
|
4
|
+
export type Hex = `0x${string}`
|
|
5
|
+
|
|
6
|
+
/** A 20-byte address, e.g. `verifyingContract`. */
|
|
7
|
+
export type Hex20 = Hex
|
|
8
|
+
/** A 31-byte value, sized to fit under the BN254 scalar field for in-circuit use. */
|
|
9
|
+
export type Hex31 = Hex
|
|
10
|
+
/** A 32-byte value, e.g. a `networkId`. */
|
|
11
|
+
export type Hex32 = Hex
|
|
12
|
+
/** A 65-byte uncompressed secp256k1 public key, or a 65-byte signature with a recovery byte. */
|
|
13
|
+
export type Hex65 = Hex
|
|
14
|
+
/** A 66-byte ERC-6538 meta-stealth address (33-byte compressed spending key || 33-byte compressed viewing key). */
|
|
15
|
+
export type Hex66 = Hex
|
|
16
|
+
|
|
17
|
+
/** An EC public key in JWK form (RFC 7517). This protocol uses P-256 only. */
|
|
18
|
+
export interface Jwk {
|
|
19
|
+
kty: "EC"
|
|
20
|
+
crv: "P-256"
|
|
21
|
+
x: string
|
|
22
|
+
y: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Where the user app's device key lives in the platform's key hierarchy. */
|
|
26
|
+
export const SECURITY_LEVELS = ["STRONGBOX", "TRUSTED_ENVIRONMENT", "SOFTWARE", "UNKNOWN"] as const
|
|
27
|
+
export type SecurityLevel = (typeof SECURITY_LEVELS)[number]
|
|
28
|
+
|
|
29
|
+
/** Identifies the member app making a request, as declared by that app (not verified by the OS). */
|
|
30
|
+
export interface Caller {
|
|
31
|
+
name: string
|
|
32
|
+
scheme: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ApprovalScope {
|
|
36
|
+
programme: string
|
|
37
|
+
country: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface RegistrationSignParams {
|
|
41
|
+
chainId: number
|
|
42
|
+
verifyingContract: Hex20
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Mirrors `RegistrationRequest` from `@coordination-network/user-client`, redeclared to avoid a cross-package dependency. */
|
|
46
|
+
export interface SignedRegistrationRequest {
|
|
47
|
+
version: "v1"
|
|
48
|
+
networkId: Hex32
|
|
49
|
+
schemeId: 1
|
|
50
|
+
metaStealthAddress: Hex66
|
|
51
|
+
chainId: number
|
|
52
|
+
verifyingContract: Hex20
|
|
53
|
+
signature: Hex65
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* `request` is posted to the member server verbatim. `securityLevel` rides along because
|
|
58
|
+
* registration is the one op every member app runs first, and it is the only thing a member needs
|
|
59
|
+
* about the wallet that the request itself does not already carry.
|
|
60
|
+
*/
|
|
61
|
+
export interface RegistrationSignResult {
|
|
62
|
+
request: SignedRegistrationRequest
|
|
63
|
+
securityLevel: SecurityLevel
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The wallet drives the whole issuance itself from these four endpoints, so a member app hands
|
|
68
|
+
* over the URLs rather than relaying each hop.
|
|
69
|
+
*/
|
|
70
|
+
export interface CredentialIssueParams {
|
|
71
|
+
issuerNetworkId: Hex32
|
|
72
|
+
approvalScope: ApprovalScope
|
|
73
|
+
challengeUrl: string
|
|
74
|
+
credentialsUrl: string
|
|
75
|
+
/** The issuer's approved-issuer set, which the wallet checks the credential against before storing. */
|
|
76
|
+
issuersUrl: string
|
|
77
|
+
/** The issuer's revocation snapshot, checked in the same step. */
|
|
78
|
+
revocationsUrl: string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* `refused` is the issuer server declining to issue, in its own error vocabulary — not a
|
|
83
|
+
* {@link LinkErrorCode}. A wallet-side failure throws instead.
|
|
84
|
+
*/
|
|
85
|
+
export type CredentialIssueResult =
|
|
86
|
+
| { status: "stored"; credentialId: string }
|
|
87
|
+
| { status: "alreadyHeld"; credentialId: string }
|
|
88
|
+
| { status: "refused"; code: string; message: string }
|
|
89
|
+
|
|
90
|
+
export interface PresentationCreateParams {
|
|
91
|
+
/** From the member server's `POST /v1/presentation-requests`; echoed back in the `/v1/verify` POST. */
|
|
92
|
+
requestId: string
|
|
93
|
+
challenge: Hex31
|
|
94
|
+
audience: string
|
|
95
|
+
currentTime: number
|
|
96
|
+
programme: string
|
|
97
|
+
country: string
|
|
98
|
+
issuersUrl: string
|
|
99
|
+
/** One network-wide tree, so any member serves the same one; the verifier's is the one whose
|
|
100
|
+
* root it will accept. */
|
|
101
|
+
revocationsUrl: string
|
|
102
|
+
responseUrl: string
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The verifier records the outcome under this id; the caller reads it from its own server. */
|
|
106
|
+
export interface PresentationCreateResult {
|
|
107
|
+
verificationId: string
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Every op this protocol version defines, mapped to its params and result shapes. */
|
|
111
|
+
export interface LinkOpMap {
|
|
112
|
+
"registration.sign": { params: RegistrationSignParams; result: RegistrationSignResult }
|
|
113
|
+
"credential.issue": { params: CredentialIssueParams; result: CredentialIssueResult }
|
|
114
|
+
"presentation.create": { params: PresentationCreateParams; result: PresentationCreateResult }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export const LINK_OPS = [
|
|
118
|
+
"registration.sign",
|
|
119
|
+
"credential.issue",
|
|
120
|
+
"presentation.create"
|
|
121
|
+
] as const satisfies readonly (keyof LinkOpMap)[]
|
|
122
|
+
|
|
123
|
+
export type LinkOp = (typeof LINK_OPS)[number]
|
|
124
|
+
|
|
125
|
+
export type Params<Op extends LinkOp> = LinkOpMap[Op]["params"]
|
|
126
|
+
export type Result<Op extends LinkOp> = LinkOpMap[Op]["result"]
|
|
127
|
+
|
|
128
|
+
/** Distributive conditional: without it, `LinkRequest` (Op defaulting to `LinkOp`) would let any op pair with any op's params instead of only its own. */
|
|
129
|
+
export type LinkRequest<Op extends LinkOp = LinkOp> = Op extends LinkOp
|
|
130
|
+
? {
|
|
131
|
+
id: string
|
|
132
|
+
version: "v1"
|
|
133
|
+
caller: Caller
|
|
134
|
+
callback: string
|
|
135
|
+
op: Op
|
|
136
|
+
params: Params<Op>
|
|
137
|
+
}
|
|
138
|
+
: never
|
|
139
|
+
|
|
140
|
+
/** A request whose envelope decoded successfully but whose `op`/`params` are not yet validated against a schema. */
|
|
141
|
+
export interface DecodedLinkRequest {
|
|
142
|
+
id: string
|
|
143
|
+
version: "v1"
|
|
144
|
+
caller: Caller
|
|
145
|
+
callback: string
|
|
146
|
+
op: string
|
|
147
|
+
params: unknown
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export const LINK_ERROR_CODES = [
|
|
151
|
+
"USER_DENIED",
|
|
152
|
+
/** Declined at the share step, after the proof was built and the person saw what it reveals. */
|
|
153
|
+
"CONSENT_DENIED",
|
|
154
|
+
"UNSUPPORTED_OP",
|
|
155
|
+
"INVALID_REQUEST",
|
|
156
|
+
"NOT_ONBOARDED",
|
|
157
|
+
"NO_CREDENTIAL",
|
|
158
|
+
/** A credential for this programme and country is held, but its issuer has revoked it. */
|
|
159
|
+
"CREDENTIAL_REVOKED",
|
|
160
|
+
"PROOF_FAILED",
|
|
161
|
+
"ISSUER_UNREACHABLE",
|
|
162
|
+
"VERIFIER_UNREACHABLE",
|
|
163
|
+
"INTERNAL"
|
|
164
|
+
] as const
|
|
165
|
+
|
|
166
|
+
export type LinkErrorCode = (typeof LINK_ERROR_CODES)[number]
|
|
167
|
+
|
|
168
|
+
/** The response envelope. Unlike the request, it carries no `op`: the caller already knows which op it asked for. */
|
|
169
|
+
export type LinkResponse<Op extends LinkOp = LinkOp> =
|
|
170
|
+
| (Op extends LinkOp ? { id: string; version: "v1"; ok: true; result: Result<Op> } : never)
|
|
171
|
+
| { id: string; version: "v1"; ok: false; code: LinkErrorCode; message?: string }
|
|
172
|
+
|
|
173
|
+
/** The handlers a user app registers with {@link createRequestHandler}, one per op it supports. */
|
|
174
|
+
export type LinkHandlers = {
|
|
175
|
+
[Op in LinkOp]?: (params: Params<Op>, context: { caller: Caller }) => Promise<Result<Op>>
|
|
176
|
+
}
|
package/src/web.ts
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import type { LinkClientOptions } from "./client"
|
|
2
|
+
import { decodeRequest } from "./codec"
|
|
3
|
+
import type { RequestHandler } from "./handler"
|
|
4
|
+
|
|
5
|
+
export interface WebLinkTransportOptions {
|
|
6
|
+
/** Browser URL of the user web app. The encoded request query is appended to this URL. */
|
|
7
|
+
userAppUrl: string
|
|
8
|
+
/** Exact origin trusted to return responses, for example `http://localhost:5173`. */
|
|
9
|
+
userAppOrigin: string
|
|
10
|
+
popupName?: string
|
|
11
|
+
popupFeatures?: string
|
|
12
|
+
/** How often to detect a popup closed before responding. Default 100ms. */
|
|
13
|
+
closePollIntervalMs?: number
|
|
14
|
+
/** Time to wait for an already-posted response after observing a closed popup. Default 250ms. */
|
|
15
|
+
closeGracePeriodMs?: number
|
|
16
|
+
/** Injectable for tests; defaults to the current browser window. */
|
|
17
|
+
window?: Window
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type WebLinkTransport = Pick<LinkClientOptions, "openUrl" | "onUrl"> & {
|
|
21
|
+
/** Reserves the popup synchronously inside a click handler before asynchronous flow work begins. */
|
|
22
|
+
prepare(): void
|
|
23
|
+
close(): void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const DEFAULT_CLOSE_POLL_INTERVAL_MS = 100
|
|
27
|
+
const DEFAULT_HANDSHAKE_TIMEOUT_MS = 5_000
|
|
28
|
+
const READY_MESSAGE = "coordination-network:user-app-ready:v1"
|
|
29
|
+
const HELLO_MESSAGE = "coordination-network:member-app-hello:v1"
|
|
30
|
+
|
|
31
|
+
function normalizedOrigin(value: string, option: string): string {
|
|
32
|
+
let origin: string
|
|
33
|
+
try {
|
|
34
|
+
origin = new URL(value).origin
|
|
35
|
+
} catch {
|
|
36
|
+
throw new Error(`${option} must be an absolute browser URL`)
|
|
37
|
+
}
|
|
38
|
+
if (origin === "null") {
|
|
39
|
+
throw new Error(`${option} must have an http or https origin`)
|
|
40
|
+
}
|
|
41
|
+
return origin
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Supplies the two transport functions consumed by `createLinkClient`. The protocol request stays
|
|
46
|
+
* unchanged; only its query string is carried to the browser user app instead of a mobile scheme.
|
|
47
|
+
*/
|
|
48
|
+
export function createWebLinkTransport(options: WebLinkTransportOptions): WebLinkTransport {
|
|
49
|
+
const host = options.window ?? window
|
|
50
|
+
const userAppOrigin = normalizedOrigin(options.userAppOrigin, "userAppOrigin")
|
|
51
|
+
const popupUrl = new URL(options.userAppUrl)
|
|
52
|
+
if (popupUrl.origin !== userAppOrigin) {
|
|
53
|
+
throw new Error("userAppUrl must belong to userAppOrigin")
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let popup: Window | null = null
|
|
57
|
+
let listener: ((url: string) => void) | null = null
|
|
58
|
+
let closeTimer: ReturnType<typeof setInterval> | null = null
|
|
59
|
+
let finishOpen: (() => void) | null = null
|
|
60
|
+
let failOpen: ((error: Error) => void) | null = null
|
|
61
|
+
let closedAt: number | null = null
|
|
62
|
+
|
|
63
|
+
function clearCloseTimer(): void {
|
|
64
|
+
if (closeTimer !== null) {
|
|
65
|
+
clearInterval(closeTimer)
|
|
66
|
+
closeTimer = null
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function handleMessage(event: MessageEvent): void {
|
|
71
|
+
if (!popup || event.source !== popup || event.origin !== userAppOrigin) {
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
if (event.data === READY_MESSAGE) {
|
|
75
|
+
popup.postMessage(HELLO_MESSAGE, userAppOrigin)
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
if (typeof event.data !== "string") return
|
|
79
|
+
|
|
80
|
+
const resolve = finishOpen
|
|
81
|
+
finishOpen = null
|
|
82
|
+
failOpen = null
|
|
83
|
+
clearCloseTimer()
|
|
84
|
+
resolve?.()
|
|
85
|
+
listener?.(event.data)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
prepare(): void {
|
|
90
|
+
if (popup && !popup.closed) return
|
|
91
|
+
popup = host.open(popupUrl.toString(), options.popupName ?? "coordination-user-app", options.popupFeatures)
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
close(): void {
|
|
95
|
+
popup?.close()
|
|
96
|
+
popup = null
|
|
97
|
+
const fail = failOpen
|
|
98
|
+
finishOpen = null
|
|
99
|
+
failOpen = null
|
|
100
|
+
clearCloseTimer()
|
|
101
|
+
fail?.(new Error("user app popup closed before responding"))
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
onUrl(nextListener): () => void {
|
|
105
|
+
listener = nextListener
|
|
106
|
+
host.addEventListener("message", handleMessage)
|
|
107
|
+
return () => {
|
|
108
|
+
host.removeEventListener("message", handleMessage)
|
|
109
|
+
listener = null
|
|
110
|
+
clearCloseTimer()
|
|
111
|
+
finishOpen?.()
|
|
112
|
+
finishOpen = null
|
|
113
|
+
failOpen = null
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
openUrl(requestUrl): Promise<void> {
|
|
118
|
+
const queryIndex = requestUrl.indexOf("?")
|
|
119
|
+
if (queryIndex === -1) {
|
|
120
|
+
return Promise.reject(new Error("browser link request is missing its encoded query"))
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const destination = new URL(popupUrl.toString())
|
|
124
|
+
destination.search = requestUrl.slice(queryIndex)
|
|
125
|
+
if (popup && !popup.closed) {
|
|
126
|
+
popup.location.href = destination.toString()
|
|
127
|
+
} else {
|
|
128
|
+
popup = host.open(
|
|
129
|
+
destination.toString(),
|
|
130
|
+
options.popupName ?? "coordination-user-app",
|
|
131
|
+
options.popupFeatures
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
if (!popup) {
|
|
135
|
+
return Promise.reject(new Error("user app popup was blocked"))
|
|
136
|
+
}
|
|
137
|
+
closedAt = null
|
|
138
|
+
|
|
139
|
+
return new Promise<void>((resolve, reject) => {
|
|
140
|
+
finishOpen = resolve
|
|
141
|
+
failOpen = reject
|
|
142
|
+
closeTimer = setInterval(() => {
|
|
143
|
+
if (!popup?.closed) return
|
|
144
|
+
|
|
145
|
+
closedAt ??= Date.now()
|
|
146
|
+
if (Date.now() - closedAt < (options.closeGracePeriodMs ?? 250)) return
|
|
147
|
+
|
|
148
|
+
const fail = failOpen
|
|
149
|
+
finishOpen = null
|
|
150
|
+
failOpen = null
|
|
151
|
+
clearCloseTimer()
|
|
152
|
+
fail?.(new Error("user app popup closed before responding"))
|
|
153
|
+
}, options.closePollIntervalMs ?? DEFAULT_CLOSE_POLL_INTERVAL_MS)
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface WebLinkResponderOptions {
|
|
160
|
+
handler: RequestHandler
|
|
161
|
+
/** Member-app origins allowed to open the user app and receive a response. */
|
|
162
|
+
allowedMemberOrigins: readonly string[]
|
|
163
|
+
/** Maximum time to wait for the opener handshake. Default 5000ms. */
|
|
164
|
+
handshakeTimeoutMs?: number
|
|
165
|
+
/** Close the user-app popup after posting. Default true. */
|
|
166
|
+
closeAfterResponding?: boolean
|
|
167
|
+
/** Injectable for tests; defaults to the current browser window. */
|
|
168
|
+
window?: Window
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function authenticateMemberApp(
|
|
172
|
+
host: Window,
|
|
173
|
+
recipient: Window,
|
|
174
|
+
allowedMemberOrigins: ReadonlySet<string>,
|
|
175
|
+
timeoutMs: number
|
|
176
|
+
): Promise<string> {
|
|
177
|
+
return new Promise((resolve, reject) => {
|
|
178
|
+
const cleanup = () => {
|
|
179
|
+
clearTimeout(timer)
|
|
180
|
+
host.removeEventListener("message", handleMessage)
|
|
181
|
+
}
|
|
182
|
+
const handleMessage = (event: MessageEvent) => {
|
|
183
|
+
if (event.source !== recipient || event.data !== HELLO_MESSAGE || !allowedMemberOrigins.has(event.origin))
|
|
184
|
+
return
|
|
185
|
+
cleanup()
|
|
186
|
+
resolve(event.origin)
|
|
187
|
+
}
|
|
188
|
+
const timer = setTimeout(() => {
|
|
189
|
+
cleanup()
|
|
190
|
+
reject(new Error("member app origin handshake timed out"))
|
|
191
|
+
}, timeoutMs)
|
|
192
|
+
|
|
193
|
+
host.addEventListener("message", handleMessage)
|
|
194
|
+
recipient.postMessage(READY_MESSAGE, "*")
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Authenticates the opener, dispatches the request, and posts the response to its verified origin. */
|
|
199
|
+
export async function respondToWebLinkRequest(options: WebLinkResponderOptions): Promise<void> {
|
|
200
|
+
const host = options.window ?? window
|
|
201
|
+
const recipient = host.opener ?? (host.parent !== host ? host.parent : null)
|
|
202
|
+
if (!recipient) throw new Error("user app was not opened or embedded by a member app")
|
|
203
|
+
try {
|
|
204
|
+
const allowedMemberOrigins = new Set(
|
|
205
|
+
options.allowedMemberOrigins.map((origin) => normalizedOrigin(origin, "allowedMemberOrigins"))
|
|
206
|
+
)
|
|
207
|
+
if (allowedMemberOrigins.size === 0) throw new Error("allowedMemberOrigins must not be empty")
|
|
208
|
+
const memberAppOrigin = await authenticateMemberApp(
|
|
209
|
+
host,
|
|
210
|
+
recipient,
|
|
211
|
+
allowedMemberOrigins,
|
|
212
|
+
options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS
|
|
213
|
+
)
|
|
214
|
+
const request = decodeRequest(host.location.href)
|
|
215
|
+
if (normalizedOrigin(request.caller.scheme, "caller.scheme") !== memberAppOrigin) {
|
|
216
|
+
throw new Error("request caller does not match the authenticated member app origin")
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const response = await options.handler.handle(host.location.href)
|
|
220
|
+
recipient.postMessage(response, memberAppOrigin)
|
|
221
|
+
// Give the queued postMessage a task to reach the opener before closing the popup.
|
|
222
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
223
|
+
} finally {
|
|
224
|
+
if (host.opener && (options.closeAfterResponding ?? true)) host.close()
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export interface EmbeddedWebLinkTransportOptions {
|
|
229
|
+
userAppUrl: string
|
|
230
|
+
userAppOrigin: string
|
|
231
|
+
frame: HTMLIFrameElement
|
|
232
|
+
window?: Window
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Carries the browser protocol through an in-page iframe, avoiding popup-blocker behavior. */
|
|
236
|
+
export function createEmbeddedWebLinkTransport(options: EmbeddedWebLinkTransportOptions): WebLinkTransport {
|
|
237
|
+
const host = options.window ?? window
|
|
238
|
+
const userAppOrigin = normalizedOrigin(options.userAppOrigin, "userAppOrigin")
|
|
239
|
+
const userAppUrl = new URL(options.userAppUrl)
|
|
240
|
+
if (userAppUrl.origin !== userAppOrigin) throw new Error("userAppUrl must belong to userAppOrigin")
|
|
241
|
+
let listener: ((url: string) => void) | null = null
|
|
242
|
+
const handleMessage = (event: MessageEvent) => {
|
|
243
|
+
if (event.origin !== userAppOrigin || event.source !== options.frame.contentWindow) return
|
|
244
|
+
if (event.data === READY_MESSAGE) {
|
|
245
|
+
options.frame.contentWindow?.postMessage(HELLO_MESSAGE, userAppOrigin)
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
if (typeof event.data !== "string") return
|
|
249
|
+
listener?.(event.data)
|
|
250
|
+
}
|
|
251
|
+
return {
|
|
252
|
+
prepare() {},
|
|
253
|
+
close() {
|
|
254
|
+
options.frame.src = "about:blank"
|
|
255
|
+
},
|
|
256
|
+
onUrl(nextListener) {
|
|
257
|
+
listener = nextListener
|
|
258
|
+
host.addEventListener("message", handleMessage)
|
|
259
|
+
return () => {
|
|
260
|
+
listener = null
|
|
261
|
+
host.removeEventListener("message", handleMessage)
|
|
262
|
+
}
|
|
263
|
+
},
|
|
264
|
+
async openUrl(requestUrl) {
|
|
265
|
+
const queryIndex = requestUrl.indexOf("?")
|
|
266
|
+
if (queryIndex === -1) throw new Error("browser link request is missing its encoded query")
|
|
267
|
+
const destination = new URL(userAppUrl)
|
|
268
|
+
destination.search = requestUrl.slice(queryIndex)
|
|
269
|
+
options.frame.src = destination.toString()
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export interface RelayWebLinkTransportOptions {
|
|
275
|
+
relayUrl: string
|
|
276
|
+
sender: string
|
|
277
|
+
pollIntervalMs?: number
|
|
278
|
+
fetcher?: typeof fetch
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Carries unchanged link URLs through the local demo relay between independently opened pages. */
|
|
282
|
+
export function createRelayWebLinkTransport(
|
|
283
|
+
options: RelayWebLinkTransportOptions
|
|
284
|
+
): Pick<LinkClientOptions, "openUrl" | "onUrl"> {
|
|
285
|
+
const fetcher = options.fetcher ?? fetch
|
|
286
|
+
const relayUrl = new URL(options.relayUrl).origin
|
|
287
|
+
let after = 0
|
|
288
|
+
return {
|
|
289
|
+
async openUrl(url) {
|
|
290
|
+
const response = await fetcher(`${relayUrl}/v1/requests`, {
|
|
291
|
+
method: "POST",
|
|
292
|
+
headers: { "content-type": "application/json" },
|
|
293
|
+
body: JSON.stringify({ sender: options.sender, url })
|
|
294
|
+
})
|
|
295
|
+
if (!response.ok) throw new Error("wallet request could not be delivered")
|
|
296
|
+
},
|
|
297
|
+
onUrl(listener) {
|
|
298
|
+
let active = true
|
|
299
|
+
let polling = false
|
|
300
|
+
const poll = async () => {
|
|
301
|
+
if (!active || polling) return
|
|
302
|
+
polling = true
|
|
303
|
+
try {
|
|
304
|
+
const response = await fetcher(
|
|
305
|
+
`${relayUrl}/v1/responses?sender=${encodeURIComponent(options.sender)}&after=${after}`
|
|
306
|
+
)
|
|
307
|
+
if (!response.ok) return
|
|
308
|
+
const messages = (await response.json()) as Array<{ seq: number; url: string }>
|
|
309
|
+
for (const message of messages) {
|
|
310
|
+
after = Math.max(after, message.seq)
|
|
311
|
+
listener(message.url)
|
|
312
|
+
}
|
|
313
|
+
} catch {
|
|
314
|
+
// The next poll retries while the local demo stack starts or restarts.
|
|
315
|
+
} finally {
|
|
316
|
+
polling = false
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
void poll()
|
|
320
|
+
const timer = setInterval(() => void poll(), options.pollIntervalMs ?? 500)
|
|
321
|
+
return () => {
|
|
322
|
+
active = false
|
|
323
|
+
clearInterval(timer)
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|