Skip to content

Commit d8a3698

Browse files
authored
Merge pull request #21 from crup/chore/runtime-hardening-release
feat: harden runtime contract and release flow
2 parents 35c8e84 + 58f2980 commit d8a3698

21 files changed

Lines changed: 490 additions & 133 deletions

.github/workflows/release.yml

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,9 +212,12 @@ jobs:
212212
runs-on: ubuntu-latest
213213
outputs:
214214
package_version: ${{ steps.version.outputs.package_version }}
215+
release_target: ${{ steps.persist.outputs.release_target }}
215216
steps:
216217
- name: Checkout main
217218
uses: actions/checkout@v6
219+
with:
220+
ref: main
218221

219222
- name: Setup pnpm
220223
uses: pnpm/action-setup@v4
@@ -259,13 +262,33 @@ jobs:
259262
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
260263
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
261264

265+
- name: Persist released version to main
266+
id: persist
267+
env:
268+
STABLE_VERSION: ${{ needs.guard.outputs.stable_version }}
269+
run: |
270+
git config user.name "github-actions[bot]"
271+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
272+
273+
if git diff --quiet -- package.json; then
274+
echo "release_target=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
275+
exit 0
276+
fi
277+
278+
git add package.json
279+
git commit -m "chore(release): v$STABLE_VERSION"
280+
git push origin HEAD:main
281+
echo "release_target=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
282+
262283
github-release:
263284
name: 4. Create GitHub release
264285
needs: [guard, publish]
265286
runs-on: ubuntu-latest
266287
steps:
267288
- name: Checkout main
268289
uses: actions/checkout@v6
290+
with:
291+
ref: main
269292

270293
- name: Create GitHub release
271294
env:
@@ -288,7 +311,7 @@ jobs:
288311
gh release create "v$STABLE_VERSION" \
289312
--title "v$STABLE_VERSION" \
290313
--notes-file release-notes.md \
291-
--target "${{ github.sha }}"
314+
--target "${{ needs.publish.outputs.release_target }}"
292315
293316
- name: Release summary
294317
run: |

README.md

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@
22

33
[![npm version](https://img.shields.io/npm/v/%40crup%2Fport?color=1f8b4c)](https://www.npmjs.com/package/@crup/port)
44
[![npm downloads](https://img.shields.io/npm/dm/%40crup%2Fport?color=0b7285)](https://www.npmjs.com/package/@crup/port)
5+
[![bundle size](https://deno.bundlejs.com/badge?q=%40crup%2Fport)](https://bundlejs.com/?q=%40crup%2Fport)
56
[![License](https://img.shields.io/github/license/crup/port?color=495057)](https://github.com/crup/port/blob/main/LICENSE)
67
[![CI](https://github.com/crup/port/actions/workflows/ci.yml/badge.svg)](https://github.com/crup/port/actions/workflows/ci.yml)
78
[![Docs](https://github.com/crup/port/actions/workflows/docs.yml/badge.svg)](https://github.com/crup/port/actions/workflows/docs.yml)
89

910
Protocol-first iframe runtime for explicit host/child communication.
1011

11-
`@crup/port` exists for the part of embedded app work that usually rots first: lifecycle, handshake timing, and message discipline. It gives the host page a small runtime for mounting an iframe, opening it inline or in a modal, enforcing origin checks, and exchanging request/response messages without ad hoc `postMessage` glue.
12+
`@crup/port` exists for the part of embedded app work that usually rots first: lifecycle, handshake timing, and message discipline. It gives the host page a small runtime for mounting an iframe, opening it inline or in a modal, pinning exact origins, and exchanging request/response/error messages without ad hoc `postMessage` glue.
1213

1314
Package: https://www.npmjs.com/package/@crup/port
1415

@@ -85,6 +86,11 @@ const child = createChildPort({
8586
child.on('request:system:ping', (message) => {
8687
const request = message as { messageId: string; payload?: unknown };
8788

89+
if (!request.payload) {
90+
child.reject(request.messageId, 'missing ping payload');
91+
return;
92+
}
93+
8894
child.respond(request.messageId, {
8995
ok: true,
9096
receivedAt: Date.now()
@@ -98,11 +104,11 @@ child.resize(document.body.scrollHeight);
98104
## What You Get
99105

100106
- Explicit lifecycle: `idle -> mounting -> mounted -> handshaking -> ready -> open -> closed -> destroyed`
101-
- Strict origin pinning on both host and child
107+
- Explicit origin pinning on both host and child
102108
- Inline and modal host modes
103-
- Event emission plus request/response RPC
109+
- Event emission plus request/response/error RPC
104110
- Child-driven height updates
105-
- Small ESM-first bundle built with `tsup`
111+
- Small ESM-only bundle built with `tsup`
106112

107113
## API Surface
108114

@@ -121,14 +127,15 @@ Host runtime with:
121127
- `update(partialConfig)`
122128
- `getState()`
123129

124-
### `createChildPort(config?)`
130+
### `createChildPort(config)`
125131

126132
Child runtime with:
127133

128134
- `ready()`
129135
- `emit(type, payload?)`
130136
- `on(type, handler)`
131137
- `respond(messageId, payload)`
138+
- `reject(messageId, payload?)`
132139
- `resize(height)`
133140
- `destroy()`
134141

@@ -167,6 +174,17 @@ type PortMessage = {
167174
- Release process: [`docs/releasing.md`](docs/releasing.md)
168175
- Contributing: [`CONTRIBUTING.md`](CONTRIBUTING.md)
169176

177+
## Positioning
178+
179+
`@crup/port` stays intentionally narrow:
180+
181+
- it is a protocol runtime for iframe lifecycle, handshake, resize, and correlated messaging
182+
- it is not a framework adapter layer for React, Vue, or Web Components
183+
- it is not a generic method bridge that reaches into arbitrary child code
184+
- it is not an automatic DOM sync system beyond explicit child-driven `resize()`
185+
186+
That scope is what keeps the package small and predictable.
187+
170188
## Local Development
171189

172190
```bash
@@ -189,12 +207,12 @@ Useful scripts:
189207

190208
- `ci.yml` validates lint, types, tests, package build, demo build, README checks, size output, and package packing.
191209
- `docs.yml` deploys the Vite demo to GitHub Pages at `https://crup.github.io/port/`.
192-
- `release.yml` is a guarded manual stable release workflow modeled on `crup/react-timer-hook`.
210+
- `release.yml` is a guarded manual stable release workflow modeled on `crup/react-timer-hook`, and it persists the published version back to `main`.
193211
- `prerelease.yml` publishes a manual alpha prerelease from the `next` branch.
194212

195213
## Security
196214

197-
This package helps with origin checks, but it cannot secure a weak embed strategy on its own. Always pin `allowedOrigin`, set restrictive iframe attributes, and validate application-level payloads. The practical guidance lives in [`docs/security.md`](docs/security.md).
215+
This package helps enforce the runtime boundary, but it cannot secure a weak embed strategy on its own. Always pin `allowedOrigin`, set restrictive iframe attributes, and validate application-level payloads. The practical guidance lives in [`docs/security.md`](docs/security.md).
198216

199217
## OSS Baseline
200218

demo/index.html

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,8 @@
4141
<p class="eyebrow">Protocol-first iframe runtime</p>
4242
<h1>Design the contract. Stop hand-wiring the window boundary.</h1>
4343
<p class="lede">
44-
`@crup/port` gives embedded apps a real boundary: explicit handshake, strict origin checks,
45-
child-driven resize, event routing, and request-response semantics that stay readable under
44+
`@crup/port` gives embedded apps a real boundary: explicit handshake, exact origin pinning,
45+
child-driven resize, event routing, and request-response-error semantics that stay readable under
4646
production pressure.
4747
</p>
4848
<div class="hero-actions">
@@ -101,15 +101,15 @@ <h1>Design the contract. Stop hand-wiring the window boundary.</h1>
101101
<div class="hero-principles">
102102
<div>
103103
<strong>Handshake before traffic</strong>
104-
<p>The child stays quiet until `port:hello` and origin validation succeed.</p>
104+
<p>The child stays quiet until `port:hello` arrives from the configured exact origin.</p>
105105
</div>
106106
<div>
107107
<strong>Events for telemetry</strong>
108108
<p>Use `emit` and `send` for non-blocking UI and workflow signals.</p>
109109
</div>
110110
<div>
111111
<strong>RPC for decisions</strong>
112-
<p>Use `call` and `respond` when the host needs a concrete answer.</p>
112+
<p>Use `call`, `respond`, and `reject` when the host needs a concrete answer or a real failure.</p>
113113
</div>
114114
</div>
115115
</div>
@@ -281,7 +281,7 @@ <h3>Modal Surface</h3>
281281
</article>
282282
<article>
283283
<h3>Child Responder</h3>
284-
<p>Handle `request:*` messages and reply with `respond(messageId, payload)` to avoid ad hoc reply channels.</p>
284+
<p>Handle `request:*` messages with `respond(messageId, payload)` or `reject(messageId, payload)` instead of ad hoc reply channels.</p>
285285
<a href="https://github.com/crup/port/blob/main/examples/child-basic.ts">Open example</a>
286286
</article>
287287
<article>

docs/api-reference.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ Removes a previously registered handler.
6262

6363
Mutates the in-memory config for future runtime behavior.
6464

65+
Once the port has mounted, only timeout and sizing fields may change. `url`, `allowedOrigin`, `target`, and `mode` are fixed for the lifetime of the mounted session.
66+
6567
#### `getState(): PortState`
6668

6769
Returns the current runtime state.
@@ -85,15 +87,15 @@ Returns the current runtime state.
8587
import { createChildPort } from '@crup/port/child';
8688
```
8789

88-
### `createChildPort(config?)`
90+
### `createChildPort(config)`
8991

9092
Creates the child-side runtime that listens for the host handshake and communicates back to the parent window.
9193

9294
#### Config
9395

9496
| Field | Type | Required | Description |
9597
| --- | --- | --- | --- |
96-
| `allowedOrigin` | `string` | No | Explicit parent origin. If omitted, the child adopts the origin from the first valid `port:hello`. |
98+
| `allowedOrigin` | `string` | Yes | Exact parent origin accepted for the handshake and all later messages. |
9799

98100
### Methods
99101

@@ -113,6 +115,10 @@ Subscribes to host traffic. Requests are surfaced as `request:<type>`.
113115

114116
Resolves a previous host request.
115117

118+
#### `reject(messageId: string, payload?: unknown): void`
119+
120+
Rejects a previous host request. The host receives a `PortError` with code `MESSAGE_REJECTED`.
121+
116122
#### `resize(height: number): void`
117123

118124
Sends `event / port:resize` if the height is finite and non-negative.
@@ -130,7 +136,7 @@ Removes the child-side message listener.
130136
| `IFRAME_LOAD_TIMEOUT` | The iframe never produced `load()` within the allowed time. |
131137
| `HANDSHAKE_TIMEOUT` | The child never responded with `port:ready`. |
132138
| `CALL_TIMEOUT` | A request did not resolve before `callTimeoutMs`. |
133-
| `ORIGIN_MISMATCH` | Reserved for origin failures in application-level handling. |
139+
| `ORIGIN_MISMATCH` | Reserved for application-level contract handling when your own code detects an origin mismatch case. |
134140
| `MESSAGE_REJECTED` | The child replied with `kind: 'error'`. |
135141
| `PORT_DESTROYED` | The port was destroyed while work was in flight. |
136142

docs/events-and-rpc.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ Avoid vague names like:
2222
## Rule Of Thumb
2323

2424
- `send()` / `emit()` for fire-and-forget signals
25-
- `call()` / `respond()` for data the host is blocked on
25+
- `call()` / `respond()` / `reject()` for data the host is blocked on
2626

2727
## Host To Child Events
2828

@@ -109,6 +109,11 @@ Child:
109109
child.on('request:demo:getQuote', (message) => {
110110
const request = message as { messageId: string };
111111

112+
if (!quoteEngineReady()) {
113+
child.reject(request.messageId, 'Quote engine unavailable');
114+
return;
115+
}
116+
112117
child.respond(request.messageId, {
113118
plan: 'Growth',
114119
price: 249,
@@ -147,12 +152,10 @@ Document your own contract in a table like this:
147152

148153
## Error Strategy
149154

150-
You have two choices when child-side work fails:
151-
152-
1. respond with a domain payload that includes failure details
153-
2. emit `kind: 'error'` so the host receives `MESSAGE_REJECTED`
155+
You have two good choices when child-side work fails:
154156

155-
The current child runtime exposes `respond()` only, so if you need richer rejection semantics you can either send a failure-shaped success payload or extend the runtime for explicit error responses.
157+
1. `reject(messageId, payload)` when the host should take a true failure path
158+
2. `respond(messageId, payload)` with a domain-level failure shape when the host still treats it as a normal result
156159

157160
## Good Contract Habits
158161

docs/examples.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ These examples stay intentionally small, but they map directly to the runtime AP
66

77
- `examples/host-inline.ts`: mount an iframe inline and subscribe to child events
88
- `examples/host-modal.ts`: mount a modal port and control open and close explicitly
9-
- `examples/child-basic.ts`: respond to host requests and emit resize signals
9+
- `examples/child-basic.ts`: respond to host requests, reject invalid requests, and emit resize signals
1010

1111
## Live Demo
1212

@@ -40,3 +40,15 @@ export function sendHostContext(port: Port, payload: HostContext) {
4040
```
4141

4242
This keeps the rest of the application from repeating message names everywhere.
43+
44+
On the child side, do the same for both success and failure paths:
45+
46+
```ts
47+
export function replyWithQuote(child: ChildPort, messageId: string, quote: QuoteResponse) {
48+
child.respond(messageId, quote);
49+
}
50+
51+
export function rejectQuote(child: ChildPort, messageId: string, reason: string) {
52+
child.reject(messageId, { reason });
53+
}
54+
```

docs/getting-started.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ const child = createChildPort({
5454
});
5555
```
5656

57-
The child stays idle until it receives a valid `port:hello` message. Once origin validation succeeds, it replies with `port:ready` automatically.
57+
The child stays idle until it receives a valid `port:hello` message from the exact configured `allowedOrigin`. Once origin validation succeeds, it replies with `port:ready` automatically.
5858

5959
## First Working Flow
6060

@@ -100,6 +100,11 @@ const quote = await port.call<{
100100
child.on('request:demo:getQuote', (message) => {
101101
const request = message as { messageId: string };
102102

103+
if (!document.body.dataset.quoteReady) {
104+
child.reject(request.messageId, 'Quote engine is not ready yet');
105+
return;
106+
}
107+
103108
child.respond(request.messageId, {
104109
plan: 'Growth',
105110
price: 249,
@@ -165,15 +170,18 @@ Destroying the port:
165170

166171
- removes the iframe
167172
- clears pending RPC requests
173+
- clears pending handshake state
168174
- rejects outstanding calls with `PORT_DESTROYED`
169175
- removes message listeners
170176

171177
## Production Checklist
172178

173179
- Pin `allowedOrigin` to the exact expected origin.
180+
- Treat `createChildPort({ allowedOrigin })` as required security config, not optional convenience.
174181
- Keep runtime messages generic and put business rules in your own named events and requests.
175182
- Document event names, payloads, and ownership between host and child teams.
176183
- Use `call()` only when the host truly depends on the child response.
184+
- Use `reject()` when the child cannot satisfy a host request and the host should handle a real failure path.
177185
- Add runtime logging around `mount`, handshake, request start, request completion, and destroy.
178186
- Add browser tests for the actual iframe flows your product depends on.
179187

docs/lifecycle.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,8 @@ Look at:
125125
- `allowedOrigin` mismatch
126126
- `handshakeTimeoutMs` too low for the actual child startup path
127127

128+
Failed `mount()` calls now clean up the iframe and reset the runtime to `idle`, so a corrected retry can mount again without creating a poisoned instance.
129+
128130
### Host calls too early
129131

130132
`send()` and `call()` require the runtime to be `ready`, `open`, or `closed`. Calling them earlier throws `INVALID_STATE`.

docs/protocol.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ type PortMessage = {
2222
- Messages are ignored unless `protocol` and `version` match.
2323
- The host accepts messages only from the currently mounted iframe window.
2424
- The host ignores messages from any origin other than `allowedOrigin`.
25-
- The child ignores messages until it has observed a valid `port:hello`.
25+
- The child ignores messages until it has observed a valid `port:hello` from the configured `allowedOrigin`.
2626
- `instanceId` prevents sibling embeds from handling each other’s traffic.
2727
- `replyTo` ties responses and errors to a single outstanding request.
2828

@@ -89,7 +89,12 @@ Child:
8989

9090
```ts
9191
child.on('request:demo:getQuote', (message) => {
92-
const request = message as { messageId: string };
92+
const request = message as { messageId: string };
93+
94+
if (!quoteEngineReady()) {
95+
child.reject(request.messageId, 'Quote engine unavailable');
96+
return;
97+
}
9398

9499
child.respond(request.messageId, {
95100
plan: 'Growth',

docs/releasing.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ This repository now has a release flow similar in shape to `crup/react-timer-hoo
1414
- `release.yml` is a manual workflow dispatch from `main`.
1515
- It uses a guarded release gate, a separate verify job, and a dedicated publish job.
1616
- `NPM_TOKEN` is passed directly to the publish step via `NODE_AUTH_TOKEN` and `NPM_TOKEN`.
17-
- After publish, the workflow creates a GitHub release tag and release notes.
17+
- After publish, the workflow commits the released `package.json` version back to `main`, then creates the GitHub release tag and notes from that release commit.
1818

1919
Required secrets:
2020

0 commit comments

Comments
 (0)