@leancodepl/cyberware-contract 10.1.3
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 +201 -0
- package/README.md +721 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +160 -0
- package/dist/index.umd.cjs +1 -0
- package/dist/lib/ConnectToHostProvider.d.ts +17 -0
- package/dist/lib/ConnectToHostProvider.d.ts.map +1 -0
- package/dist/lib/connect.d.ts +46 -0
- package/dist/lib/connect.d.ts.map +1 -0
- package/dist/lib/createContract.d.ts +43 -0
- package/dist/lib/createContract.d.ts.map +1 -0
- package/dist/lib/enums.d.ts +7 -0
- package/dist/lib/enums.d.ts.map +1 -0
- package/dist/lib/types.d.ts +10 -0
- package/dist/lib/types.d.ts.map +1 -0
- package/dist/lib/urlParams.d.ts +12 -0
- package/dist/lib/urlParams.d.ts.map +1 -0
- package/dist/lib/useConnectToHost.d.ts +29 -0
- package/dist/lib/useConnectToHost.d.ts.map +1 -0
- package/dist/lib/useConnectToRemote.d.ts +47 -0
- package/dist/lib/useConnectToRemote.d.ts.map +1 -0
- package/dist/lib/zod.d.ts +81 -0
- package/dist/lib/zod.d.ts.map +1 -0
- package/package.json +65 -0
package/README.md
ADDED
|
@@ -0,0 +1,721 @@
|
|
|
1
|
+
# @leancodepl/cyberware-contract
|
|
2
|
+
|
|
3
|
+
Creates type-safe contracts between a host app and a remote iframe app (e.g., Replit embed). Uses `postMessage` via
|
|
4
|
+
Penpal for secure cross-origin communication. Supports **Zod** for defining method params/returns and URL params with
|
|
5
|
+
runtime validation and inferred TypeScript types.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @leancodepl/cyberware-contract
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
yarn add @leancodepl/cyberware-contract
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## API
|
|
18
|
+
|
|
19
|
+
### `createContract(options)`
|
|
20
|
+
|
|
21
|
+
Creates a type-safe contract shared between host and remote. Use the same contract type on both sides to ensure method
|
|
22
|
+
signatures match.
|
|
23
|
+
|
|
24
|
+
**Options:**
|
|
25
|
+
|
|
26
|
+
- `contractVersion` - **Required.** Semver version of the contract (e.g. `"1.0.0"`). Host passes it via URL params;
|
|
27
|
+
remote verifies before connecting. When using the contract, this value is auto-injected into `useConnectToRemote`,
|
|
28
|
+
`useConnectToHost`, and `ConnectToHostProvider`—you don't pass it explicitly.
|
|
29
|
+
- `contractVersionRange` - **Required.** Semver range the host contract version must satisfy (e.g. `">=1.0.0 <2.0.0"`,
|
|
30
|
+
`"^2.0.0"`, `"~2.1.0"`). Remote checks `semver.satisfies(hostVersion, contractVersionRange)` before connecting.
|
|
31
|
+
|
|
32
|
+
**Returns:** Object with `useConnectToRemote`, `useConnectToHost`, `ConnectToHostProvider`, `useConnectToHostContext`,
|
|
33
|
+
and `getUrlParams`
|
|
34
|
+
|
|
35
|
+
### `ConnectStatus`
|
|
36
|
+
|
|
37
|
+
Enum for connection state: `ConnectStatus.IDLE`, `ConnectStatus.CONNECTED`, `ConnectStatus.ERROR`,
|
|
38
|
+
`ConnectStatus.INCOMPATIBLE`. Used by `useConnectToRemote` and `useConnectToHost` (and context) return values. Check
|
|
39
|
+
`status === ConnectStatus.CONNECTED` before using `remote` or `host`; when `status === ConnectStatus.ERROR`, `error` is
|
|
40
|
+
set; when `status === ConnectStatus.INCOMPATIBLE` (host connect only), `hostVersion` and `remoteVersion` are set.
|
|
41
|
+
|
|
42
|
+
### `connectToRemote(iframe, options)`
|
|
43
|
+
|
|
44
|
+
Connects host (parent) to remote (child iframe). Call from the host app with the iframe element.
|
|
45
|
+
|
|
46
|
+
**Parameters:**
|
|
47
|
+
|
|
48
|
+
- `iframe` - `HTMLIFrameElement` - The iframe element to connect to
|
|
49
|
+
- `options` - `ConnectToRemoteOptions<THost>` - Connection options
|
|
50
|
+
|
|
51
|
+
**Returns:** Penpal connection object with `promise` resolving to remote proxy and `destroy` method
|
|
52
|
+
|
|
53
|
+
### `connectToHost(options)`
|
|
54
|
+
|
|
55
|
+
Connects remote (child iframe) to host (parent window). Call from the remote app.
|
|
56
|
+
|
|
57
|
+
**Parameters:**
|
|
58
|
+
|
|
59
|
+
- `options` - `ConnectToHostOptions<TRemote>` - Connection options
|
|
60
|
+
|
|
61
|
+
**Returns:** Penpal connection object with `promise` resolving to host proxy and `destroy` method
|
|
62
|
+
|
|
63
|
+
### `useConnectToRemote(options)`
|
|
64
|
+
|
|
65
|
+
Connects host (parent) to remote (child iframe) and renders the iframe. Call from the host app.
|
|
66
|
+
|
|
67
|
+
**Parameters:**
|
|
68
|
+
|
|
69
|
+
- `options` - `UseConnectToRemoteOptions<THost, TParams>` - Connection options including `remoteUrl`, `iframeProps`
|
|
70
|
+
(with required `title`), `methods`, optional `params`, `allowedOrigins`. `contractVersion` is auto-injected from the
|
|
71
|
+
contract.
|
|
72
|
+
|
|
73
|
+
**Returns:** `UseConnectToRemoteResult<TRemote>` - Object with `iframe` element and connection state: `status`
|
|
74
|
+
(`ConnectStatus`), and when connected `remote` proxy, when error `error`
|
|
75
|
+
|
|
76
|
+
### `useConnectToHost(options)`
|
|
77
|
+
|
|
78
|
+
Connects remote (child iframe) to host (parent window). Call from the remote app. Does nothing when not embedded in an
|
|
79
|
+
iframe.
|
|
80
|
+
|
|
81
|
+
**Parameters:**
|
|
82
|
+
|
|
83
|
+
- `options` - `UseConnectToHostOptions<TRemote>` - Connection options including `methods`, optional `allowedOrigins`.
|
|
84
|
+
`contractVersion` and `contractVersionRange` are auto-injected from the contract.
|
|
85
|
+
|
|
86
|
+
**Returns:** `UseConnectToHostResult<THost>` - Connection state: `status` (`ConnectStatus`), and when connected `host`
|
|
87
|
+
proxy, when error `error`, when incompatible `hostVersion` and `remoteVersion`
|
|
88
|
+
|
|
89
|
+
### `createConnectToHostProvider()` / `ConnectToHostProvider`
|
|
90
|
+
|
|
91
|
+
`createConnectToHostProvider` creates a typed `ConnectToHostProvider` and `useConnectToHostContext` pair. Each contract
|
|
92
|
+
calls this internally; use `contract.ConnectToHostProvider` and `contract.useConnectToHostContext`.
|
|
93
|
+
|
|
94
|
+
**ConnectToHostProvider props:** `methods`, optional `allowedOrigins`, `children`. `contractVersion` and
|
|
95
|
+
`contractVersionRange` are auto-injected from the contract.
|
|
96
|
+
|
|
97
|
+
### `buildRemoteUrl(baseUrl, params)`
|
|
98
|
+
|
|
99
|
+
Builds remote URL with query parameters. Merges params into the URL, preserving existing search params.
|
|
100
|
+
|
|
101
|
+
**Parameters:**
|
|
102
|
+
|
|
103
|
+
- `baseUrl` - `string` - Base URL without params
|
|
104
|
+
- `params` - `RemoteParamsWithContractVersion` (optional) - Params to merge (must include `contractVersion`)
|
|
105
|
+
|
|
106
|
+
**Returns:** `string` - Full URL with query string
|
|
107
|
+
|
|
108
|
+
### `getUrlParams()`
|
|
109
|
+
|
|
110
|
+
Reads URL search params from the current location as a typed object. No validation is performed. Call from the remote
|
|
111
|
+
(iframe) to read params passed by the host. Always uses `location.search` in the browser.
|
|
112
|
+
|
|
113
|
+
**Returns:** `TParams` - Typed object of params (includes `contractVersion` when using a contract)
|
|
114
|
+
|
|
115
|
+
### Zod helpers (optional)
|
|
116
|
+
|
|
117
|
+
When using Zod, you get inferred types and optional runtime validation:
|
|
118
|
+
|
|
119
|
+
- **`methodDef(def?)`** - Defines a method schema. `def` can have `params` (Zod object) and/or `returns` (Zod type). Use
|
|
120
|
+
for host and remote method signatures.
|
|
121
|
+
- **`InferMethodsFromSchema<T>`** - Infers `HostMethods` or `RemoteMethods` from a record of method-def schemas.
|
|
122
|
+
- **`InferParamsFromSchema<T>`** - Infers `RemoteParams` from a record of param schemas (e.g. `{ userId: z.string() }`).
|
|
123
|
+
- **`MethodType<S>`, `MethodParamsType<S>`, `MethodReturnType<S>`** - Per-method inferred types from a method-def
|
|
124
|
+
schema.
|
|
125
|
+
- **`mkZodContractSchema({ hostMethods, remoteMethods, remoteParams })`** - Builds a Zod schema for the full contract
|
|
126
|
+
(e.g. for validation or tooling). Types are still passed to `createContract` via the inferred types.
|
|
127
|
+
|
|
128
|
+
## Usage Examples
|
|
129
|
+
|
|
130
|
+
### Shared contract definition (TypeScript only)
|
|
131
|
+
|
|
132
|
+
Define the contract in a shared package or file used by both host and remote:
|
|
133
|
+
|
|
134
|
+
```typescript
|
|
135
|
+
import { createContract } from "@leancodepl/cyberware-contract"
|
|
136
|
+
|
|
137
|
+
type HostMethods = {
|
|
138
|
+
navigateTo: (path: string) => Promise<void>
|
|
139
|
+
showNotification: (msg: string, type: "success" | "error") => Promise<void>
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
type RemoteMethods = {
|
|
143
|
+
getCurrentPath: () => Promise<string>
|
|
144
|
+
refresh: () => Promise<void>
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
type RemoteParams = { userId?: string; tenantId?: string }
|
|
148
|
+
|
|
149
|
+
export const contract = createContract<HostMethods, RemoteMethods, RemoteParams>({
|
|
150
|
+
contractVersion: "1.0.0",
|
|
151
|
+
contractVersionRange: ">=1.0.0 <2.0.0",
|
|
152
|
+
})
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### Shared contract definition (with Zod)
|
|
156
|
+
|
|
157
|
+
Define method params/returns and URL params with Zod; types are inferred and you can reuse schemas for validation:
|
|
158
|
+
|
|
159
|
+
```typescript
|
|
160
|
+
import { z } from "zod"
|
|
161
|
+
import {
|
|
162
|
+
createContract,
|
|
163
|
+
methodDef,
|
|
164
|
+
InferMethodsFromSchema,
|
|
165
|
+
InferParamsFromSchema,
|
|
166
|
+
type HostMethodsSchemaBase,
|
|
167
|
+
type RemoteMethodsSchemaBase,
|
|
168
|
+
type RemoteParamsSchemaBase,
|
|
169
|
+
} from "@leancodepl/cyberware-contract"
|
|
170
|
+
|
|
171
|
+
const NotificationTypeSchema = z.enum(["success", "error", "info"])
|
|
172
|
+
|
|
173
|
+
const HostMethods = {
|
|
174
|
+
navigateTo: methodDef({ params: z.object({ path: z.string() }) }),
|
|
175
|
+
showNotification: methodDef({
|
|
176
|
+
params: z.object({ message: z.string(), type: NotificationTypeSchema }),
|
|
177
|
+
}),
|
|
178
|
+
} satisfies HostMethodsSchemaBase
|
|
179
|
+
|
|
180
|
+
const RemoteMethods = {
|
|
181
|
+
getCurrentPath: methodDef({ returns: z.string() }),
|
|
182
|
+
refresh: methodDef(),
|
|
183
|
+
} satisfies RemoteMethodsSchemaBase
|
|
184
|
+
|
|
185
|
+
const RemoteParams = {
|
|
186
|
+
userId: z.string(),
|
|
187
|
+
tenantId: z.string().optional(),
|
|
188
|
+
} satisfies RemoteParamsSchemaBase
|
|
189
|
+
|
|
190
|
+
export type HostMethodsType = InferMethodsFromSchema<typeof HostMethods>
|
|
191
|
+
export type RemoteMethodsType = InferMethodsFromSchema<typeof RemoteMethods>
|
|
192
|
+
export type RemoteParamsType = InferParamsFromSchema<typeof RemoteParams>
|
|
193
|
+
|
|
194
|
+
export const contract = createContract<HostMethodsType, RemoteMethodsType, RemoteParamsType>({
|
|
195
|
+
contractVersion: "1.0.0",
|
|
196
|
+
contractVersionRange: ">=1.0.0 <2.0.0",
|
|
197
|
+
})
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### Host app: embed remote iframe with React hook
|
|
201
|
+
|
|
202
|
+
```tsx
|
|
203
|
+
import { ConnectStatus } from "@leancodepl/cyberware-contract"
|
|
204
|
+
import { contract } from "./contract"
|
|
205
|
+
|
|
206
|
+
function HostApp() {
|
|
207
|
+
const connection = contract.useConnectToRemote({
|
|
208
|
+
remoteUrl: "https://replit.example.com/app",
|
|
209
|
+
iframeProps: { title: "Remote app" },
|
|
210
|
+
methods: {
|
|
211
|
+
navigateTo: path => router.navigate(path),
|
|
212
|
+
showNotification: (msg, type) => messageApi[type](msg),
|
|
213
|
+
},
|
|
214
|
+
params: { userId: "123", tenantId: "acme" },
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
const handleSync = async () => {
|
|
218
|
+
if (connection.status === ConnectStatus.CONNECTED) await connection.remote.refresh()
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return (
|
|
222
|
+
<div>
|
|
223
|
+
{connection.iframe}
|
|
224
|
+
{connection.status === ConnectStatus.CONNECTED && <button onClick={handleSync}>Sync</button>}
|
|
225
|
+
</div>
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
### Contract version checking
|
|
231
|
+
|
|
232
|
+
`contractVersion` and `contractVersionRange` are required in `createContract`. The host passes its version via URL
|
|
233
|
+
params to the iframe. The remote verifies compatibility with `semver.satisfies(hostVersion, contractVersionRange)`
|
|
234
|
+
before connecting. If versions are incompatible, connection state is `ConnectStatus.INCOMPATIBLE` with `hostVersion` and
|
|
235
|
+
`remoteVersion`. When using the contract, version values are auto-injected—no need to pass them to hooks or the
|
|
236
|
+
provider.
|
|
237
|
+
|
|
238
|
+
### Remote app: using ConnectToHostProvider (Recommended)
|
|
239
|
+
|
|
240
|
+
Wrap the remote app with `ConnectToHostProvider` and use `useConnectToHostContext` in child components to access the
|
|
241
|
+
host connection without prop drilling:
|
|
242
|
+
|
|
243
|
+
```tsx
|
|
244
|
+
import { ConnectStatus } from "@leancodepl/cyberware-contract"
|
|
245
|
+
import { contract } from "./contract"
|
|
246
|
+
|
|
247
|
+
function RemoteAppRoot() {
|
|
248
|
+
return (
|
|
249
|
+
<contract.ConnectToHostProvider
|
|
250
|
+
methods={{
|
|
251
|
+
getCurrentPath: () => Promise.resolve(location.pathname),
|
|
252
|
+
refresh: () => refetch(),
|
|
253
|
+
}}>
|
|
254
|
+
<RemoteApp />
|
|
255
|
+
</contract.ConnectToHostProvider>
|
|
256
|
+
)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function RemoteApp() {
|
|
260
|
+
const params = contract.getUrlParams()
|
|
261
|
+
const connection = contract.useConnectToHostContext()
|
|
262
|
+
|
|
263
|
+
const handleSave = async () => {
|
|
264
|
+
if (connection.status === ConnectStatus.CONNECTED)
|
|
265
|
+
await connection.host.showNotification("Settings saved", "success")
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return (
|
|
269
|
+
<div>
|
|
270
|
+
<p>User: {params.userId}</p>
|
|
271
|
+
{connection.status === ConnectStatus.INCOMPATIBLE && (
|
|
272
|
+
<p>
|
|
273
|
+
Version mismatch: host {connection.hostVersion}, remote {connection.remoteVersion}
|
|
274
|
+
</p>
|
|
275
|
+
)}
|
|
276
|
+
{connection.status === ConnectStatus.CONNECTED && <button onClick={handleSave}>Save</button>}
|
|
277
|
+
</div>
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
### Remote app: connect to host and read params
|
|
283
|
+
|
|
284
|
+
```tsx
|
|
285
|
+
import { ConnectStatus } from "@leancodepl/cyberware-contract"
|
|
286
|
+
import { contract } from "./contract"
|
|
287
|
+
|
|
288
|
+
function RemoteApp() {
|
|
289
|
+
const params = contract.getUrlParams()
|
|
290
|
+
const connection = contract.useConnectToHost({
|
|
291
|
+
methods: {
|
|
292
|
+
getCurrentPath: () => Promise.resolve(location.pathname),
|
|
293
|
+
refresh: () => refetch(),
|
|
294
|
+
},
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
const handleSave = async () => {
|
|
298
|
+
if (connection.status === ConnectStatus.CONNECTED)
|
|
299
|
+
await connection.host.showNotification("Settings saved", "success")
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return (
|
|
303
|
+
<div>
|
|
304
|
+
<p>User: {params.userId}</p>
|
|
305
|
+
{connection.status === ConnectStatus.INCOMPATIBLE && (
|
|
306
|
+
<p>
|
|
307
|
+
Version mismatch: host {connection.hostVersion}, remote {connection.remoteVersion}
|
|
308
|
+
</p>
|
|
309
|
+
)}
|
|
310
|
+
{connection.status === ConnectStatus.CONNECTED && <button onClick={handleSave}>Save</button>}
|
|
311
|
+
</div>
|
|
312
|
+
)
|
|
313
|
+
}
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
### Imperative connection (without React)
|
|
317
|
+
|
|
318
|
+
Host side:
|
|
319
|
+
|
|
320
|
+
```typescript
|
|
321
|
+
import { connectToRemote, buildRemoteUrl } from "@leancodepl/cyberware-contract"
|
|
322
|
+
|
|
323
|
+
const iframe = document.getElementById("remote") as HTMLIFrameElement
|
|
324
|
+
iframe.src = buildRemoteUrl("https://replit.example.com/app", { contractVersion: "1.0.0", userId: "123" })
|
|
325
|
+
|
|
326
|
+
const connection = connectToRemote(iframe, {
|
|
327
|
+
methods: {
|
|
328
|
+
navigateTo: path => router.navigate(path),
|
|
329
|
+
},
|
|
330
|
+
})
|
|
331
|
+
const remote = await connection.promise
|
|
332
|
+
await remote.refresh()
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
Remote side:
|
|
336
|
+
|
|
337
|
+
```typescript
|
|
338
|
+
import { connectToHost, getUrlParams } from "@leancodepl/cyberware-contract"
|
|
339
|
+
|
|
340
|
+
const params = getUrlParams<{ userId?: string }>()
|
|
341
|
+
const connection = connectToHost({
|
|
342
|
+
methods: {
|
|
343
|
+
getCurrentPath: () => Promise.resolve(location.pathname),
|
|
344
|
+
},
|
|
345
|
+
})
|
|
346
|
+
const host = await connection.promise
|
|
347
|
+
await host.showNotification("Ready", "success")
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
### Restricting allowed origins
|
|
351
|
+
|
|
352
|
+
```typescript
|
|
353
|
+
connectToHost({
|
|
354
|
+
methods: { getCurrentPath: () => Promise.resolve(location.pathname) },
|
|
355
|
+
allowedOrigins: ["https://my-host-app.com", "https://localhost:3000"],
|
|
356
|
+
})
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
## React Host ↔ Flutter Remote
|
|
360
|
+
|
|
361
|
+
The contract can connect a **React host** to a **Flutter web remote** running inside an iframe. The Zod contract schema
|
|
362
|
+
is the single source of truth: TypeScript types are inferred for the React Host side, and
|
|
363
|
+
[`@leancodepl/cyberware-contract-generator-dart`](../cyberware-contract-generator-dart) generates Dart extension types
|
|
364
|
+
for the Flutter Remote side. The Flutter app uses
|
|
365
|
+
[`leancode_cyberware_contract_base`](https://github.com/nicepage/flutter_corelibrary/tree/main/packages/leancode_cyberware_contract_base)
|
|
366
|
+
for Cubit-based connection state management.
|
|
367
|
+
|
|
368
|
+
### Example step-by-step setup
|
|
369
|
+
|
|
370
|
+
#### 1. Create the contract package
|
|
371
|
+
|
|
372
|
+
The contract package is both an **npm package** (for the React host) and a **Dart package** (for the Flutter remote). It
|
|
373
|
+
has both a `package.json` and a `pubspec.yaml`.
|
|
374
|
+
|
|
375
|
+
**Directory structure:**
|
|
376
|
+
|
|
377
|
+
```
|
|
378
|
+
packages/my-contract/
|
|
379
|
+
├── package.json
|
|
380
|
+
├── pubspec.yaml
|
|
381
|
+
├── cyberware-contract-generator-dart.config.js
|
|
382
|
+
├── src/
|
|
383
|
+
│ └── lib/
|
|
384
|
+
│ ├── contract-schema.ts # Zod schema (source of truth)
|
|
385
|
+
│ ├── contract.ts # createContract call
|
|
386
|
+
│ └── types.ts # Re-exports inferred TS types
|
|
387
|
+
└── lib/
|
|
388
|
+
├── my_contract.dart # Dart library barrel file
|
|
389
|
+
├── generated/ # Generated Dart files (do not edit)
|
|
390
|
+
│ ├── contract.dart
|
|
391
|
+
│ ├── types.dart
|
|
392
|
+
│ └── connect_to_host.dart
|
|
393
|
+
└── contract/ # Hand-written Dart glue code
|
|
394
|
+
└── contract.dart # ConnectToHostCubit wrapper
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
`pubspec.yaml` — declare the package as a Dart package that depends on `leancode_cyberware_contract_base`:
|
|
398
|
+
|
|
399
|
+
```yaml
|
|
400
|
+
name: my_contract
|
|
401
|
+
publish_to: "none"
|
|
402
|
+
version: 1.0.0+1
|
|
403
|
+
|
|
404
|
+
environment:
|
|
405
|
+
sdk: ">=3.11.0 <4.0.0"
|
|
406
|
+
|
|
407
|
+
dependencies:
|
|
408
|
+
flutter:
|
|
409
|
+
sdk: flutter
|
|
410
|
+
bloc: ^9.0.0
|
|
411
|
+
flutter_bloc: ^9.0.0
|
|
412
|
+
leancode_cyberware_contract_base:
|
|
413
|
+
path: <path-to-leancode_cyberware_contract_base>
|
|
414
|
+
pub_semver: ^2.1.4
|
|
415
|
+
web: ^1.1.0
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
`package.json` — include dev dependencies on `@leancodepl/cyberware-contract`,
|
|
419
|
+
`@leancodepl/cyberware-contract-generator-dart`, and `zod`:
|
|
420
|
+
|
|
421
|
+
```json
|
|
422
|
+
{
|
|
423
|
+
"devDependencies": {
|
|
424
|
+
"@leancodepl/cyberware-contract": "*",
|
|
425
|
+
"@leancodepl/cyberware-contract-generator-dart": "*",
|
|
426
|
+
"zod": "^4.1.0"
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
#### 2. Define the contract schema
|
|
432
|
+
|
|
433
|
+
Create the Zod schema in `contract-schema.ts`. This is the single source of truth for both TypeScript and Dart types:
|
|
434
|
+
|
|
435
|
+
```typescript
|
|
436
|
+
import { z } from "zod"
|
|
437
|
+
import {
|
|
438
|
+
methodDef,
|
|
439
|
+
mkZodContractSchema,
|
|
440
|
+
type InferMethodsFromSchema,
|
|
441
|
+
type InferParamsFromSchema,
|
|
442
|
+
} from "@leancodepl/cyberware-contract"
|
|
443
|
+
|
|
444
|
+
const RemoteParamsSchema = {
|
|
445
|
+
userId: z.string(),
|
|
446
|
+
theme: z.enum(["light", "dark"]),
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const HostMethodsSchema = {
|
|
450
|
+
navigateTo: methodDef({ params: z.object({ path: z.string() }) }),
|
|
451
|
+
showNotification: methodDef({
|
|
452
|
+
params: z.object({ message: z.string(), type: z.string().optional() }),
|
|
453
|
+
}),
|
|
454
|
+
getCurrentUserId: methodDef({ returns: z.string().nullable() }),
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const RemoteMethodsSchema = {
|
|
458
|
+
onRouteChange: methodDef({ params: z.object({ path: z.string() }) }),
|
|
459
|
+
getCurrentPath: methodDef({ returns: z.string() }),
|
|
460
|
+
refresh: methodDef(),
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export type HostMethods = InferMethodsFromSchema<typeof HostMethodsSchema>
|
|
464
|
+
export type RemoteMethods = InferMethodsFromSchema<typeof RemoteMethodsSchema>
|
|
465
|
+
export type RemoteParams = InferParamsFromSchema<typeof RemoteParamsSchema>
|
|
466
|
+
|
|
467
|
+
export const ContractSchema = mkZodContractSchema({
|
|
468
|
+
hostMethods: HostMethodsSchema,
|
|
469
|
+
remoteMethods: RemoteMethodsSchema,
|
|
470
|
+
remoteParams: RemoteParamsSchema,
|
|
471
|
+
})
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
Create the contract instance in `contract.ts`:
|
|
475
|
+
|
|
476
|
+
```typescript
|
|
477
|
+
import type { HostMethods, RemoteMethods, RemoteParams } from "./contract-schema"
|
|
478
|
+
import { createContract } from "@leancodepl/cyberware-contract"
|
|
479
|
+
|
|
480
|
+
export const { useConnectToRemote } = createContract<HostMethods, RemoteMethods, RemoteParams>({
|
|
481
|
+
contractVersion: "1.0.0",
|
|
482
|
+
contractVersionRange: ">=1.0.0",
|
|
483
|
+
})
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
#### 3. Generate Dart types
|
|
487
|
+
|
|
488
|
+
Create `cyberware-contract-generator-dart.config.js` in the contract package root:
|
|
489
|
+
|
|
490
|
+
```javascript
|
|
491
|
+
import { ContractSchema } from "./src/lib/contract-schema.ts"
|
|
492
|
+
|
|
493
|
+
/** @type {import("@leancodepl/cyberware-contract-generator-dart").CyberwareContractGeneratorDartConfig} */
|
|
494
|
+
const config = {
|
|
495
|
+
schema: ContractSchema,
|
|
496
|
+
outputDir: "./lib/generated",
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
export default config
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
Run the generator:
|
|
503
|
+
|
|
504
|
+
```bash
|
|
505
|
+
npx cyberware-contract-generator-dart
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
This produces files in `lib/generated/`.
|
|
509
|
+
|
|
510
|
+
#### 4. Write Dart glue code
|
|
511
|
+
|
|
512
|
+
Create a `ConnectToHostCubit` wrapper that passes the contract version and wires the generated `connectToHost`:
|
|
513
|
+
|
|
514
|
+
```dart
|
|
515
|
+
// lib/contract/contract.dart
|
|
516
|
+
|
|
517
|
+
import 'package:leancode_cyberware_contract_base/leancode_cyberware_contract_base.dart'
|
|
518
|
+
as base;
|
|
519
|
+
import '../generated/connect_to_host.dart';
|
|
520
|
+
import '../generated/types.dart';
|
|
521
|
+
|
|
522
|
+
class ConnectToHostCubitOptions {
|
|
523
|
+
const ConnectToHostCubitOptions({required this.methods});
|
|
524
|
+
final RemoteMethodsBase methods;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
class ConnectToHostCubit
|
|
528
|
+
extends base.ConnectToHostCubit<RemoteMethodsBase, HostMethods> {
|
|
529
|
+
ConnectToHostCubit(ConnectToHostCubitOptions options)
|
|
530
|
+
: super(base.ConnectToHostCubitOptions(
|
|
531
|
+
connect: () => connectToHost(options.methods),
|
|
532
|
+
contractVersion: '1.0.0',
|
|
533
|
+
contractVersionRange: '>=1.0.0',
|
|
534
|
+
));
|
|
535
|
+
}
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
Create the Dart barrel file `lib/my_contract.dart`:
|
|
539
|
+
|
|
540
|
+
```dart
|
|
541
|
+
export 'generated/connect_to_host.dart';
|
|
542
|
+
export 'generated/contract.dart';
|
|
543
|
+
export 'generated/types.dart';
|
|
544
|
+
|
|
545
|
+
export 'contract/contract.dart';
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
#### 5. Set up the Flutter remote
|
|
549
|
+
|
|
550
|
+
Add the contract package and base package as dependencies in the Flutter app's `pubspec.yaml`:
|
|
551
|
+
|
|
552
|
+
```yaml
|
|
553
|
+
dependencies:
|
|
554
|
+
flutter:
|
|
555
|
+
sdk: flutter
|
|
556
|
+
bloc: ^9.0.0
|
|
557
|
+
flutter_bloc: ^9.0.0
|
|
558
|
+
my_contract:
|
|
559
|
+
path: <path-to-contract-package>
|
|
560
|
+
leancode_cyberware_contract_base: 1.0.0
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
Add the Penpal bridge script to `web/index.html` **before** the Flutter bootstrap script:
|
|
564
|
+
|
|
565
|
+
```html
|
|
566
|
+
<head>
|
|
567
|
+
<!-- ... -->
|
|
568
|
+
<script src="assets/packages/leancode_cyberware_contract_base/assets/connect_to_host.js"></script>
|
|
569
|
+
</head>
|
|
570
|
+
<body>
|
|
571
|
+
<script src="flutter_bootstrap.js" async></script>
|
|
572
|
+
</body>
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
Create class implementing the generated `RemoteMethodsBase`. This is where you define how the Flutter app responds to
|
|
576
|
+
calls from the host:
|
|
577
|
+
|
|
578
|
+
```dart
|
|
579
|
+
import 'package:flutter/material.dart';
|
|
580
|
+
import 'package:my_contract/my_contract.dart';
|
|
581
|
+
|
|
582
|
+
class AppCyberwareMethods implements RemoteMethodsBase {
|
|
583
|
+
@override
|
|
584
|
+
Future<void> onRouteChange(RemoteOnRouteChangeParams params) {
|
|
585
|
+
debugPrint('Route changed: ${params.path}');
|
|
586
|
+
return Future.value();
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
@override
|
|
590
|
+
Future<RemoteGetCurrentPathResult> getCurrentPath() {
|
|
591
|
+
return Future.value('/current-path');
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
@override
|
|
595
|
+
Future<RemoteRefreshResult> refresh() {
|
|
596
|
+
return Future.value();
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
```
|
|
600
|
+
|
|
601
|
+
Use `ConnectToHostCubit` with `BlocProvider` and pass the implementation:
|
|
602
|
+
|
|
603
|
+
```dart
|
|
604
|
+
import 'package:flutter/material.dart';
|
|
605
|
+
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
606
|
+
import 'package:my_contract/my_contract.dart';
|
|
607
|
+
|
|
608
|
+
class App extends StatelessWidget {
|
|
609
|
+
const App({super.key});
|
|
610
|
+
|
|
611
|
+
@override
|
|
612
|
+
Widget build(BuildContext context) {
|
|
613
|
+
final params = RemoteUrlParams();
|
|
614
|
+
|
|
615
|
+
return BlocProvider<ConnectToHostCubit>(
|
|
616
|
+
create: (_) => ConnectToHostCubit(
|
|
617
|
+
ConnectToHostCubitOptions(
|
|
618
|
+
methods: AppCyberwareMethods(),
|
|
619
|
+
),
|
|
620
|
+
)..connect(),
|
|
621
|
+
child: MaterialApp(
|
|
622
|
+
home: HomePage(params: params),
|
|
623
|
+
),
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
class HomePage extends StatelessWidget {
|
|
629
|
+
const HomePage({super.key, required this.params});
|
|
630
|
+
|
|
631
|
+
final RemoteUrlParams params;
|
|
632
|
+
|
|
633
|
+
@override
|
|
634
|
+
Widget build(BuildContext context) {
|
|
635
|
+
return Scaffold(
|
|
636
|
+
body: BlocBuilder<ConnectToHostCubit, ConnectToHostState>(
|
|
637
|
+
builder: (context, state) {
|
|
638
|
+
return switch (state) {
|
|
639
|
+
ConnectToHostStateIdle() => const Center(child: Text('Connecting...')),
|
|
640
|
+
ConnectToHostStateConnected(:final host) => Column(
|
|
641
|
+
children: [
|
|
642
|
+
Text('Connected! User: ${params.userId}'),
|
|
643
|
+
ElevatedButton(
|
|
644
|
+
onPressed: () => host.showNotification(
|
|
645
|
+
HostShowNotificationParams(message: 'Hello from Flutter!'),
|
|
646
|
+
),
|
|
647
|
+
child: const Text('Show notification'),
|
|
648
|
+
),
|
|
649
|
+
],
|
|
650
|
+
),
|
|
651
|
+
ConnectToHostStateError(:final error) =>
|
|
652
|
+
Center(child: Text('Error: $error')),
|
|
653
|
+
ConnectToHostStateIncompatible(:final hostVersion, :final remoteVersion) =>
|
|
654
|
+
Center(child: Text('Incompatible: host $hostVersion, remote $remoteVersion')),
|
|
655
|
+
};
|
|
656
|
+
},
|
|
657
|
+
),
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
```
|
|
662
|
+
|
|
663
|
+
#### 6. Embed the Flutter remote in the React host
|
|
664
|
+
|
|
665
|
+
In the React host app, use the contract's `useConnectToRemote` hook to embed the Flutter app in an iframe and implement
|
|
666
|
+
the host methods:
|
|
667
|
+
|
|
668
|
+
```tsx
|
|
669
|
+
import { ConnectStatus } from "@leancodepl/cyberware-contract"
|
|
670
|
+
import { MyContract, MyContractTypes } from "@my-org/my-contract"
|
|
671
|
+
|
|
672
|
+
function FlutterAppPage() {
|
|
673
|
+
const methods: MyContractTypes.HostMethods = useMemo(
|
|
674
|
+
() => ({
|
|
675
|
+
navigateTo: ({ path }) => {
|
|
676
|
+
router.navigate(path)
|
|
677
|
+
return Promise.resolve()
|
|
678
|
+
},
|
|
679
|
+
showNotification: ({ message, type }) => {
|
|
680
|
+
notification.open({ content: message })
|
|
681
|
+
return Promise.resolve()
|
|
682
|
+
},
|
|
683
|
+
getCurrentUserId: async () => currentUser?.id ?? null,
|
|
684
|
+
}),
|
|
685
|
+
[],
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
const connection = MyContract.useConnectToRemote({
|
|
689
|
+
remoteUrl: "http://localhost:4220",
|
|
690
|
+
methods,
|
|
691
|
+
params: { userId: "demo-user", theme: "light" },
|
|
692
|
+
iframeProps: { title: "Flutter App" },
|
|
693
|
+
})
|
|
694
|
+
|
|
695
|
+
return (
|
|
696
|
+
<div>
|
|
697
|
+
{connection.iframe}
|
|
698
|
+
{connection.status === ConnectStatus.CONNECTED && (
|
|
699
|
+
<button onClick={() => connection.remote.refresh()}>Refresh</button>
|
|
700
|
+
)}
|
|
701
|
+
</div>
|
|
702
|
+
)
|
|
703
|
+
}
|
|
704
|
+
```
|
|
705
|
+
|
|
706
|
+
## Features
|
|
707
|
+
|
|
708
|
+
- **Type-safe contracts** - Shared TypeScript types ensure host and remote method signatures stay in sync
|
|
709
|
+
- **Zod support** - Optional Zod schemas via `methodDef`, `InferMethodsFromSchema`, and `InferParamsFromSchema` for
|
|
710
|
+
inferred types and reusable validation
|
|
711
|
+
- **Contract version checking** - Required `contractVersion` and `contractVersionRange` in `createContract`; remote
|
|
712
|
+
verifies host version satisfies the range via `semver.satisfies` before connecting
|
|
713
|
+
- **React hooks** - `useConnectToRemote` and `useConnectToHost` for declarative usage; connection state via
|
|
714
|
+
`ConnectStatus` and discriminated state (`status`, `remote`/`host`, `error`, `hostVersion`/`remoteVersion` when
|
|
715
|
+
incompatible)
|
|
716
|
+
- **URL params** - `buildRemoteUrl` and `getUrlParams` pass data from host to remote via query string
|
|
717
|
+
- **Origin validation** - Optional `allowedOrigins` restricts which domains can connect
|
|
718
|
+
- **Penpal-based** - Uses Penpal for reliable `postMessage` communication across iframe boundaries
|
|
719
|
+
- **Flutter remote support** - Generate Dart extension types from the Zod schema with
|
|
720
|
+
`@leancodepl/cyberware-contract-generator-dart`; use `leancode_cyberware_contract_base` for Cubit-based connection
|
|
721
|
+
state management in Flutter web apps
|