@leancodepl/cyberware-contract
v10.2.1
Published
Creates type-safe contracts between a host app and a remote iframe app (e.g., Replit embed). Uses `postMessage` via Penpal for secure cross-origin communication.
Readme
@leancodepl/cyberware-contract
Creates type-safe contracts between a host app and a remote iframe app (e.g., Replit embed). Uses postMessage via
Penpal for secure cross-origin communication. Supports Zod for defining method params/returns and URL params with
runtime validation and inferred TypeScript types.
Installation
npm install @leancodepl/cyberware-contractyarn add @leancodepl/cyberware-contractAPI
createContract(options)
Creates a type-safe contract shared between host and remote. Use the same contract type on both sides to ensure method signatures match.
Options:
contractVersion- Required. Semver version of the contract (e.g."1.0.0"). Host passes it via URL params; remote verifies before connecting. When using the contract, this value is auto-injected intouseConnectToRemote,useConnectToHost, andConnectToHostProvider—you don't pass it explicitly.contractVersionRange- Required. Semver range the host contract version must satisfy (e.g.">=1.0.0 <2.0.0","^2.0.0","~2.1.0"). Remote checkssemver.satisfies(hostVersion, contractVersionRange)before connecting.
Returns: Object with useConnectToRemote, useConnectToHost, ConnectToHostProvider, useConnectToHostContext,
and getUrlParams
ConnectStatus
Enum for connection state: ConnectStatus.IDLE, ConnectStatus.CONNECTED, ConnectStatus.ERROR,
ConnectStatus.INCOMPATIBLE. Used by useConnectToRemote and useConnectToHost (and context) return values. Check
status === ConnectStatus.CONNECTED before using remote or host; when status === ConnectStatus.ERROR, error is
set; when status === ConnectStatus.INCOMPATIBLE (host connect only), hostVersion and remoteVersion are set.
connectToRemote(iframe, options)
Connects host (parent) to remote (child iframe). Call from the host app with the iframe element.
Parameters:
iframe-HTMLIFrameElement- The iframe element to connect tooptions-ConnectToRemoteOptions<THost>- Connection options
Returns: Penpal connection object with promise resolving to remote proxy and destroy method
connectToHost(options)
Connects remote (child iframe) to host (parent window). Call from the remote app.
Parameters:
options-ConnectToHostOptions<TRemote>- Connection options
Returns: Penpal connection object with promise resolving to host proxy and destroy method
useConnectToRemote(options)
Connects host (parent) to remote (child iframe) and renders the iframe. Call from the host app.
Parameters:
options-UseConnectToRemoteOptions<THost, TParams>- Connection options includingremoteUrl,iframeProps(with requiredtitle),methods, optionalparams,allowedOrigins.contractVersionis auto-injected from the contract.
Returns: UseConnectToRemoteResult<TRemote> - Object with iframe element and connection state: status
(ConnectStatus), and when connected remote proxy, when error error
useConnectToHost(options)
Connects remote (child iframe) to host (parent window). Call from the remote app. Does nothing when not embedded in an iframe.
Parameters:
options-UseConnectToHostOptions<TRemote>- Connection options includingmethods, optionalallowedOrigins.contractVersionandcontractVersionRangeare auto-injected from the contract.
Returns: UseConnectToHostResult<THost> - Connection state: status (ConnectStatus), and when connected host
proxy, when error error, when incompatible hostVersion and remoteVersion
createConnectToHostProvider() / ConnectToHostProvider
createConnectToHostProvider creates a typed ConnectToHostProvider and useConnectToHostContext pair. Each contract
calls this internally; use contract.ConnectToHostProvider and contract.useConnectToHostContext.
ConnectToHostProvider props: methods, optional allowedOrigins, children. contractVersion and
contractVersionRange are auto-injected from the contract.
buildRemoteUrl(baseUrl, params)
Builds remote URL with query parameters. Merges params into the URL, preserving existing search params.
Parameters:
baseUrl-string- Base URL without paramsparams-RemoteParamsWithContractVersion(optional) - Params to merge (must includecontractVersion)
Returns: string - Full URL with query string
getUrlParams()
Reads URL search params from the current location as a typed object. No validation is performed. Call from the remote
(iframe) to read params passed by the host. Always uses location.search in the browser.
Returns: TParams - Typed object of params (includes contractVersion when using a contract)
Zod helpers (optional)
When using Zod, you get inferred types and optional runtime validation:
methodDef(def?)- Defines a method schema.defcan haveparams(Zod object) and/orreturns(Zod type). Use for host and remote method signatures.InferMethodsFromSchema<T>- InfersHostMethodsorRemoteMethodsfrom a record of method-def schemas.InferParamsFromSchema<T>- InfersRemoteParamsfrom a record of param schemas (e.g.{ userId: z.string() }).MethodType<S>,MethodParamsType<S>,MethodReturnType<S>- Per-method inferred types from a method-def schema.mkZodContractSchema({ hostMethods, remoteMethods, remoteParams })- Builds a Zod schema for the full contract (e.g. for validation or tooling). Types are still passed tocreateContractvia the inferred types.
Usage Examples
Shared contract definition (TypeScript only)
Define the contract in a shared package or file used by both host and remote:
import { createContract } from "@leancodepl/cyberware-contract"
type HostMethods = {
navigateTo: (path: string) => Promise<void>
showNotification: (msg: string, type: "success" | "error") => Promise<void>
}
type RemoteMethods = {
getCurrentPath: () => Promise<string>
refresh: () => Promise<void>
}
type RemoteParams = { userId?: string; tenantId?: string }
export const contract = createContract<HostMethods, RemoteMethods, RemoteParams>({
contractVersion: "1.0.0",
contractVersionRange: ">=1.0.0 <2.0.0",
})Shared contract definition (with Zod)
Define method params/returns and URL params with Zod; types are inferred and you can reuse schemas for validation:
import { z } from "zod"
import {
createContract,
methodDef,
InferMethodsFromSchema,
InferParamsFromSchema,
type HostMethodsSchemaBase,
type RemoteMethodsSchemaBase,
type RemoteParamsSchemaBase,
} from "@leancodepl/cyberware-contract"
const NotificationTypeSchema = z.enum(["success", "error", "info"])
const HostMethods = {
navigateTo: methodDef({ params: z.object({ path: z.string() }) }),
showNotification: methodDef({
params: z.object({ message: z.string(), type: NotificationTypeSchema }),
}),
} satisfies HostMethodsSchemaBase
const RemoteMethods = {
getCurrentPath: methodDef({ returns: z.string() }),
refresh: methodDef(),
} satisfies RemoteMethodsSchemaBase
const RemoteParams = {
userId: z.string(),
tenantId: z.string().optional(),
} satisfies RemoteParamsSchemaBase
export type HostMethodsType = InferMethodsFromSchema<typeof HostMethods>
export type RemoteMethodsType = InferMethodsFromSchema<typeof RemoteMethods>
export type RemoteParamsType = InferParamsFromSchema<typeof RemoteParams>
export const contract = createContract<HostMethodsType, RemoteMethodsType, RemoteParamsType>({
contractVersion: "1.0.0",
contractVersionRange: ">=1.0.0 <2.0.0",
})Host app: embed remote iframe with React hook
import { ConnectStatus } from "@leancodepl/cyberware-contract"
import { contract } from "./contract"
function HostApp() {
const connection = contract.useConnectToRemote({
remoteUrl: "https://replit.example.com/app",
iframeProps: { title: "Remote app" },
methods: {
navigateTo: path => router.navigate(path),
showNotification: (msg, type) => messageApi[type](msg),
},
params: { userId: "123", tenantId: "acme" },
})
const handleSync = async () => {
if (connection.status === ConnectStatus.CONNECTED) await connection.remote.refresh()
}
return (
<div>
{connection.iframe}
{connection.status === ConnectStatus.CONNECTED && <button onClick={handleSync}>Sync</button>}
</div>
)
}Contract version checking
contractVersion and contractVersionRange are required in createContract. The host passes its version via URL
params to the iframe. The remote verifies compatibility with semver.satisfies(hostVersion, contractVersionRange)
before connecting. If versions are incompatible, connection state is ConnectStatus.INCOMPATIBLE with hostVersion and
remoteVersion. When using the contract, version values are auto-injected—no need to pass them to hooks or the
provider.
Remote app: using ConnectToHostProvider (Recommended)
Wrap the remote app with ConnectToHostProvider and use useConnectToHostContext in child components to access the
host connection without prop drilling:
import { ConnectStatus } from "@leancodepl/cyberware-contract"
import { contract } from "./contract"
function RemoteAppRoot() {
return (
<contract.ConnectToHostProvider
methods={{
getCurrentPath: () => Promise.resolve(location.pathname),
refresh: () => refetch(),
}}>
<RemoteApp />
</contract.ConnectToHostProvider>
)
}
function RemoteApp() {
const params = contract.getUrlParams()
const connection = contract.useConnectToHostContext()
const handleSave = async () => {
if (connection.status === ConnectStatus.CONNECTED)
await connection.host.showNotification("Settings saved", "success")
}
return (
<div>
<p>User: {params.userId}</p>
{connection.status === ConnectStatus.INCOMPATIBLE && (
<p>
Version mismatch: host {connection.hostVersion}, remote {connection.remoteVersion}
</p>
)}
{connection.status === ConnectStatus.CONNECTED && <button onClick={handleSave}>Save</button>}
</div>
)
}Remote app: connect to host and read params
import { ConnectStatus } from "@leancodepl/cyberware-contract"
import { contract } from "./contract"
function RemoteApp() {
const params = contract.getUrlParams()
const connection = contract.useConnectToHost({
methods: {
getCurrentPath: () => Promise.resolve(location.pathname),
refresh: () => refetch(),
},
})
const handleSave = async () => {
if (connection.status === ConnectStatus.CONNECTED)
await connection.host.showNotification("Settings saved", "success")
}
return (
<div>
<p>User: {params.userId}</p>
{connection.status === ConnectStatus.INCOMPATIBLE && (
<p>
Version mismatch: host {connection.hostVersion}, remote {connection.remoteVersion}
</p>
)}
{connection.status === ConnectStatus.CONNECTED && <button onClick={handleSave}>Save</button>}
</div>
)
}Imperative connection (without React)
Host side:
import { connectToRemote, buildRemoteUrl } from "@leancodepl/cyberware-contract"
const iframe = document.getElementById("remote") as HTMLIFrameElement
iframe.src = buildRemoteUrl("https://replit.example.com/app", { contractVersion: "1.0.0", userId: "123" })
const connection = connectToRemote(iframe, {
methods: {
navigateTo: path => router.navigate(path),
},
})
const remote = await connection.promise
await remote.refresh()Remote side:
import { connectToHost, getUrlParams } from "@leancodepl/cyberware-contract"
const params = getUrlParams<{ userId?: string }>()
const connection = connectToHost({
methods: {
getCurrentPath: () => Promise.resolve(location.pathname),
},
})
const host = await connection.promise
await host.showNotification("Ready", "success")Restricting allowed origins
connectToHost({
methods: { getCurrentPath: () => Promise.resolve(location.pathname) },
allowedOrigins: ["https://my-host-app.com", "https://localhost:3000"],
})React Host ↔ Flutter Remote
The contract can connect a React host to a Flutter web remote running inside an iframe. The Zod contract schema
is the single source of truth: TypeScript types are inferred for the React Host side, and
@leancodepl/cyberware-contract-generator-dart generates Dart extension types
for the Flutter Remote side. The Flutter app uses
leancode_cyberware_contract_base
for Cubit-based connection state management.
Example step-by-step setup
1. Create the contract package
The contract package is both an npm package (for the React host) and a Dart package (for the Flutter remote). It
has both a package.json and a pubspec.yaml.
Directory structure:
packages/my-contract/
├── package.json
├── pubspec.yaml
├── cyberware-contract-generator-dart.config.js
├── src/
│ └── lib/
│ ├── contract-schema.ts # Zod schema (source of truth)
│ ├── contract.ts # createContract call
│ └── types.ts # Re-exports inferred TS types
└── lib/
├── my_contract.dart # Dart library barrel file
├── generated/ # Generated Dart files (do not edit)
│ ├── contract.dart
│ ├── types.dart
│ └── connect_to_host.dart
└── contract/ # Hand-written Dart glue code
└── contract.dart # ConnectToHostCubit wrapperpubspec.yaml — declare the package as a Dart package that depends on leancode_cyberware_contract_base:
name: my_contract
publish_to: "none"
version: 1.0.0+1
environment:
sdk: ">=3.11.0 <4.0.0"
dependencies:
flutter:
sdk: flutter
bloc: ^9.0.0
flutter_bloc: ^9.0.0
leancode_cyberware_contract_base:
path: <path-to-leancode_cyberware_contract_base>
pub_semver: ^2.1.4
web: ^1.1.0package.json — include dev dependencies on @leancodepl/cyberware-contract,
@leancodepl/cyberware-contract-generator-dart, and zod:
{
"devDependencies": {
"@leancodepl/cyberware-contract": "*",
"@leancodepl/cyberware-contract-generator-dart": "*",
"zod": "^4.1.0"
}
}2. Define the contract schema
Create the Zod schema in contract-schema.ts. This is the single source of truth for both TypeScript and Dart types:
import { z } from "zod"
import {
methodDef,
mkZodContractSchema,
type InferMethodsFromSchema,
type InferParamsFromSchema,
} from "@leancodepl/cyberware-contract"
const RemoteParamsSchema = {
userId: z.string(),
theme: z.enum(["light", "dark"]),
}
const HostMethodsSchema = {
navigateTo: methodDef({ params: z.object({ path: z.string() }) }),
showNotification: methodDef({
params: z.object({ message: z.string(), type: z.string().optional() }),
}),
getCurrentUserId: methodDef({ returns: z.string().nullable() }),
}
const RemoteMethodsSchema = {
onRouteChange: methodDef({ params: z.object({ path: z.string() }) }),
getCurrentPath: methodDef({ returns: z.string() }),
refresh: methodDef(),
}
export type HostMethods = InferMethodsFromSchema<typeof HostMethodsSchema>
export type RemoteMethods = InferMethodsFromSchema<typeof RemoteMethodsSchema>
export type RemoteParams = InferParamsFromSchema<typeof RemoteParamsSchema>
export const ContractSchema = mkZodContractSchema({
hostMethods: HostMethodsSchema,
remoteMethods: RemoteMethodsSchema,
remoteParams: RemoteParamsSchema,
})Create the contract instance in contract.ts:
import type { HostMethods, RemoteMethods, RemoteParams } from "./contract-schema"
import { createContract } from "@leancodepl/cyberware-contract"
export const { useConnectToRemote } = createContract<HostMethods, RemoteMethods, RemoteParams>({
contractVersion: "1.0.0",
contractVersionRange: ">=1.0.0",
})3. Generate Dart types
Create cyberware-contract-generator-dart.config.js in the contract package root:
import { ContractSchema } from "./src/lib/contract-schema.ts"
/** @type {import("@leancodepl/cyberware-contract-generator-dart").CyberwareContractGeneratorDartConfig} */
const config = {
schema: ContractSchema,
outputDir: "./lib/generated",
}
export default configRun the generator:
npx cyberware-contract-generator-dartThis produces files in lib/generated/.
4. Write Dart glue code
Create a ConnectToHostCubit wrapper that passes the contract version and wires the generated connectToHost:
// lib/contract/contract.dart
import 'package:leancode_cyberware_contract_base/leancode_cyberware_contract_base.dart'
as base;
import '../generated/connect_to_host.dart';
import '../generated/types.dart';
class ConnectToHostCubitOptions {
const ConnectToHostCubitOptions({required this.methods});
final RemoteMethodsBase methods;
}
class ConnectToHostCubit
extends base.ConnectToHostCubit<RemoteMethodsBase, HostMethods> {
ConnectToHostCubit(ConnectToHostCubitOptions options)
: super(base.ConnectToHostCubitOptions(
connect: () => connectToHost(options.methods),
contractVersion: '1.0.0',
contractVersionRange: '>=1.0.0',
));
}Create the Dart barrel file lib/my_contract.dart:
export 'generated/connect_to_host.dart';
export 'generated/contract.dart';
export 'generated/types.dart';
export 'contract/contract.dart';5. Set up the Flutter remote
Add the contract package and base package as dependencies in the Flutter app's pubspec.yaml:
dependencies:
flutter:
sdk: flutter
bloc: ^9.0.0
flutter_bloc: ^9.0.0
my_contract:
path: <path-to-contract-package>
leancode_cyberware_contract_base: 1.0.0Add the Penpal bridge script to web/index.html before the Flutter bootstrap script:
<head>
<!-- ... -->
<script src="assets/packages/leancode_cyberware_contract_base/assets/connect_to_host.js"></script>
</head>
<body>
<script src="flutter_bootstrap.js" async></script>
</body>Create class implementing the generated RemoteMethodsBase. This is where you define how the Flutter app responds to
calls from the host:
import 'package:flutter/material.dart';
import 'package:my_contract/my_contract.dart';
class AppCyberwareMethods implements RemoteMethodsBase {
@override
Future<void> onRouteChange(RemoteOnRouteChangeParams params) {
debugPrint('Route changed: ${params.path}');
return Future.value();
}
@override
Future<RemoteGetCurrentPathResult> getCurrentPath() {
return Future.value('/current-path');
}
@override
Future<RemoteRefreshResult> refresh() {
return Future.value();
}
}Use ConnectToHostCubit with BlocProvider and pass the implementation:
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:my_contract/my_contract.dart';
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) {
final params = RemoteUrlParams();
return BlocProvider<ConnectToHostCubit>(
create: (_) => ConnectToHostCubit(
ConnectToHostCubitOptions(
methods: AppCyberwareMethods(),
),
)..connect(),
child: MaterialApp(
home: HomePage(params: params),
),
);
}
}
class HomePage extends StatelessWidget {
const HomePage({super.key, required this.params});
final RemoteUrlParams params;
@override
Widget build(BuildContext context) {
return Scaffold(
body: BlocBuilder<ConnectToHostCubit, ConnectToHostState>(
builder: (context, state) {
return switch (state) {
ConnectToHostStateIdle() => const Center(child: Text('Connecting...')),
ConnectToHostStateConnected(:final host) => Column(
children: [
Text('Connected! User: ${params.userId}'),
ElevatedButton(
onPressed: () => host.showNotification(
HostShowNotificationParams(message: 'Hello from Flutter!'),
),
child: const Text('Show notification'),
),
],
),
ConnectToHostStateError(:final error) =>
Center(child: Text('Error: $error')),
ConnectToHostStateIncompatible(:final hostVersion, :final remoteVersion) =>
Center(child: Text('Incompatible: host $hostVersion, remote $remoteVersion')),
};
},
),
);
}
}6. Embed the Flutter remote in the React host
In the React host app, use the contract's useConnectToRemote hook to embed the Flutter app in an iframe and implement
the host methods:
import { ConnectStatus } from "@leancodepl/cyberware-contract"
import { MyContract, MyContractTypes } from "@my-org/my-contract"
function FlutterAppPage() {
const methods: MyContractTypes.HostMethods = useMemo(
() => ({
navigateTo: ({ path }) => {
router.navigate(path)
return Promise.resolve()
},
showNotification: ({ message, type }) => {
notification.open({ content: message })
return Promise.resolve()
},
getCurrentUserId: async () => currentUser?.id ?? null,
}),
[],
)
const connection = MyContract.useConnectToRemote({
remoteUrl: "http://localhost:4220",
methods,
params: { userId: "demo-user", theme: "light" },
iframeProps: { title: "Flutter App" },
})
return (
<div>
{connection.iframe}
{connection.status === ConnectStatus.CONNECTED && (
<button onClick={() => connection.remote.refresh()}>Refresh</button>
)}
</div>
)
}Features
- Type-safe contracts - Shared TypeScript types ensure host and remote method signatures stay in sync
- Zod support - Optional Zod schemas via
methodDef,InferMethodsFromSchema, andInferParamsFromSchemafor inferred types and reusable validation - Contract version checking - Required
contractVersionandcontractVersionRangeincreateContract; remote verifies host version satisfies the range viasemver.satisfiesbefore connecting - React hooks -
useConnectToRemoteanduseConnectToHostfor declarative usage; connection state viaConnectStatusand discriminated state (status,remote/host,error,hostVersion/remoteVersionwhen incompatible) - URL params -
buildRemoteUrlandgetUrlParamspass data from host to remote via query string - Origin validation - Optional
allowedOriginsrestricts which domains can connect - Penpal-based - Uses Penpal for reliable
postMessagecommunication across iframe boundaries - Flutter remote support - Generate Dart extension types from the Zod schema with
@leancodepl/cyberware-contract-generator-dart; useleancode_cyberware_contract_basefor Cubit-based connection state management in Flutter web apps
