request-iframe 0.1.0 → 0.1.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/README.CN.md +174 -22
- package/README.md +166 -22
- package/esm/api/client.js +6 -5
- package/esm/api/server.js +3 -1
- package/esm/constants/index.js +38 -6
- package/esm/constants/messages.js +3 -1
- package/esm/core/client-server.js +8 -43
- package/esm/core/client.js +7 -7
- package/esm/core/response.js +16 -8
- package/esm/core/server.js +18 -9
- package/esm/message/channel.js +2 -1
- package/esm/message/dispatcher.js +63 -25
- package/esm/stream/readable-stream.js +57 -18
- package/esm/stream/stream-core.js +115 -2
- package/esm/stream/writable-stream.js +281 -27
- package/esm/utils/ack.js +36 -0
- package/esm/utils/debug.js +1 -1
- package/esm/utils/index.js +5 -3
- package/esm/utils/origin.js +3 -1
- package/library/api/client.d.ts.map +1 -1
- package/library/api/client.js +5 -4
- package/library/api/server.d.ts.map +1 -1
- package/library/api/server.js +3 -1
- package/library/constants/index.d.ts +36 -5
- package/library/constants/index.d.ts.map +1 -1
- package/library/constants/index.js +41 -8
- package/library/constants/messages.d.ts +3 -1
- package/library/constants/messages.d.ts.map +1 -1
- package/library/constants/messages.js +3 -1
- package/library/core/client-server.d.ts +4 -13
- package/library/core/client-server.d.ts.map +1 -1
- package/library/core/client-server.js +7 -42
- package/library/core/client.d.ts.map +1 -1
- package/library/core/client.js +6 -6
- package/library/core/response.d.ts +2 -2
- package/library/core/response.d.ts.map +1 -1
- package/library/core/response.js +15 -7
- package/library/core/server.d.ts +6 -2
- package/library/core/server.d.ts.map +1 -1
- package/library/core/server.js +18 -9
- package/library/message/channel.d.ts +1 -1
- package/library/message/channel.d.ts.map +1 -1
- package/library/message/channel.js +2 -1
- package/library/message/dispatcher.d.ts +10 -0
- package/library/message/dispatcher.d.ts.map +1 -1
- package/library/message/dispatcher.js +62 -24
- package/library/stream/readable-stream.d.ts.map +1 -1
- package/library/stream/readable-stream.js +56 -17
- package/library/stream/stream-core.d.ts +22 -1
- package/library/stream/stream-core.d.ts.map +1 -1
- package/library/stream/stream-core.js +114 -1
- package/library/stream/types.d.ts +115 -2
- package/library/stream/types.d.ts.map +1 -1
- package/library/stream/writable-stream.d.ts +20 -2
- package/library/stream/writable-stream.d.ts.map +1 -1
- package/library/stream/writable-stream.js +277 -23
- package/library/types/index.d.ts +3 -4
- package/library/types/index.d.ts.map +1 -1
- package/library/utils/ack.d.ts +2 -0
- package/library/utils/ack.d.ts.map +1 -0
- package/library/utils/ack.js +44 -0
- package/library/utils/debug.js +1 -1
- package/library/utils/index.d.ts.map +1 -1
- package/library/utils/index.js +4 -3
- package/library/utils/origin.d.ts.map +1 -1
- package/library/utils/origin.js +2 -1
- package/package.json +1 -1
- package/esm/utils/ack-meta.js +0 -53
- package/library/utils/ack-meta.d.ts +0 -2
- package/library/utils/ack-meta.d.ts.map +0 -1
- package/library/utils/ack-meta.js +0 -59
package/README.CN.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# request-iframe
|
|
2
2
|
|
|
3
|
-
像发送 HTTP 请求一样与 iframe 通信!基于 `postMessage`
|
|
3
|
+
像发送 HTTP 请求一样与 iframe / Window 通信!基于 `postMessage` 实现的浏览器跨页面通信库。
|
|
4
4
|
|
|
5
5
|
> 🌐 **Languages**: [English](./README.md) | [中文](./README.CN.md)
|
|
6
6
|
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
<img src="https://img.shields.io/badge/TypeScript-Ready-blue" alt="TypeScript Ready">
|
|
9
9
|
<img src="https://img.shields.io/badge/API-Express%20Like-green" alt="Express Like API">
|
|
10
10
|
<img src="https://img.shields.io/badge/License-MIT-yellow" alt="MIT License">
|
|
11
|
-
<img src="https://
|
|
11
|
+
<img src="https://coveralls.io/repos/github/gxlmyacc/request-iframe/badge.svg?branch=main" alt="Coverage Status">
|
|
12
12
|
</p>
|
|
13
13
|
|
|
14
14
|
## 📑 目录
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
|
|
49
49
|
## 为什么选择 request-iframe?
|
|
50
50
|
|
|
51
|
-
在微前端、iframe
|
|
51
|
+
在微前端、iframe 嵌套、弹窗(window.open)等场景下,跨页面通信是常见需求。传统的 `postMessage` 通信存在以下痛点:
|
|
52
52
|
|
|
53
53
|
| 痛点 | 传统方式 | request-iframe |
|
|
54
54
|
|------|----------|----------------|
|
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
- ⏱️ **智能超时** - 三阶段超时(连接/同步/异步),自动识别长任务
|
|
75
75
|
- 📦 **TypeScript** - 完整的类型定义和智能提示
|
|
76
76
|
- 🔒 **消息隔离** - secretKey 机制避免多实例消息串线
|
|
77
|
-
- 📁 **文件传输** - 支持文件通过流方式传输(Client
|
|
77
|
+
- 📁 **文件传输** - 支持文件通过流方式传输(Client↔Server)
|
|
78
78
|
- 🌊 **流式传输** - 支持大文件分块传输,支持异步迭代器
|
|
79
79
|
- 🌍 **多语言** - 错误消息可自定义,便于国际化
|
|
80
80
|
- ✅ **协议版本** - 内置版本控制,便于升级兼容
|
|
@@ -151,7 +151,7 @@ request-iframe 在 `postMessage` 基础上实现了一套类 HTTP 的通信协
|
|
|
151
151
|
│ │
|
|
152
152
|
│ <──── RESPONSE ──────────────────────── │ 返回结果
|
|
153
153
|
│ │
|
|
154
|
-
│ ────
|
|
154
|
+
│ ──── ACK (可选) ───────────────────────> │ 确认收到响应
|
|
155
155
|
│ │
|
|
156
156
|
```
|
|
157
157
|
|
|
@@ -160,11 +160,10 @@ request-iframe 在 `postMessage` 基础上实现了一套类 HTTP 的通信协
|
|
|
160
160
|
| 类型 | 方向 | 说明 |
|
|
161
161
|
|------|------|------|
|
|
162
162
|
| `request` | Client → Server | 客户端发起请求 |
|
|
163
|
-
| `ack` |
|
|
163
|
+
| `ack` | 接收方 → 发送方 | 回执确认(当消息 `requireAck: true` 且被接管/处理时发送;ACK-only) |
|
|
164
164
|
| `async` | Server → Client | 通知客户端这是异步任务(handler 返回 Promise 时发送) |
|
|
165
165
|
| `response` | Server → Client | 返回响应数据 |
|
|
166
166
|
| `error` | Server → Client | 返回错误信息 |
|
|
167
|
-
| `received` | Client → Server | 客户端确认收到响应/错误(可选,由响应的 `requireAck` 控制) |
|
|
168
167
|
| `ping` | Client → Server | 连接检测(`isConnect()`;可使用 `requireAck` 确认投递) |
|
|
169
168
|
| `pong` | Server → Client | 连接检测响应 |
|
|
170
169
|
|
|
@@ -286,6 +285,29 @@ server.on('/event', (req, res) => {
|
|
|
286
285
|
});
|
|
287
286
|
```
|
|
288
287
|
|
|
288
|
+
### 弹窗 / 新标签页(Window 通信)
|
|
289
|
+
|
|
290
|
+
`request-iframe` 不仅可以与 iframe 通信,也可以把 `target` 直接传 `Window`(例如弹窗/新标签页)。
|
|
291
|
+
|
|
292
|
+
**重要前提**:你必须拿到对方页面的 `Window` 引用(例如 `window.open()` 的返回值,或通过 `window.opener` / `MessageEvent.source` 获取)。**无法**通过 URL 给“任意标签页”发消息。
|
|
293
|
+
|
|
294
|
+
```typescript
|
|
295
|
+
// 父页面:打开新标签页/弹窗
|
|
296
|
+
const child = window.open('https://child.example.com/page.html', '_blank');
|
|
297
|
+
if (!child) throw new Error('弹窗被拦截');
|
|
298
|
+
|
|
299
|
+
// 父 -> 子
|
|
300
|
+
const client = requestIframeClient(child, {
|
|
301
|
+
secretKey: 'popup-demo',
|
|
302
|
+
targetOrigin: 'https://child.example.com' // 强烈建议不要用 '*'
|
|
303
|
+
});
|
|
304
|
+
await client.send('/api/ping', { from: 'parent' });
|
|
305
|
+
|
|
306
|
+
// 子页面:创建 server
|
|
307
|
+
const server = requestIframeServer({ secretKey: 'popup-demo' });
|
|
308
|
+
server.on('/api/ping', (req, res) => res.send({ ok: true, echo: req.body }));
|
|
309
|
+
```
|
|
310
|
+
|
|
289
311
|
### 跨域数据获取
|
|
290
312
|
|
|
291
313
|
当 iframe 与父页面不同域时,使用 request-iframe 安全地获取数据:
|
|
@@ -583,6 +605,8 @@ server.on('/api/data', (req, res) => {
|
|
|
583
605
|
|
|
584
606
|
### 文件传输
|
|
585
607
|
|
|
608
|
+
> 说明:文件传输(无论 Client→Server 还是 Server→Client)底层都会通过 stream 协议承载;你只需要使用 `client.sendFile()` / `res.sendFile()` 这一层 API 即可。
|
|
609
|
+
|
|
586
610
|
```typescript
|
|
587
611
|
// Server 端发送文件
|
|
588
612
|
server.on('/api/download', async (req, res) => {
|
|
@@ -615,7 +639,7 @@ if (response.data instanceof File || response.data instanceof Blob) {
|
|
|
615
639
|
|
|
616
640
|
#### Client → Server(Client 向 Server 发送文件)
|
|
617
641
|
|
|
618
|
-
Client
|
|
642
|
+
Client 端发送文件使用 `sendFile()`(或直接 `send(path, file)`);Server 端在 `autoResolve: true`(默认)时会把文件自动解析成 `File/Blob` 放到 `req.body`,当 `autoResolve: false` 时则通过 `req.stream` / `req.body` 暴露为 `IframeFileReadableStream`。
|
|
619
643
|
|
|
620
644
|
```typescript
|
|
621
645
|
// Client 端:发送文件(stream,autoResolve 默认 true)
|
|
@@ -639,14 +663,74 @@ server.on('/api/upload', async (req, res) => {
|
|
|
639
663
|
});
|
|
640
664
|
```
|
|
641
665
|
|
|
642
|
-
**提示**:当 `client.send()` 的 `body` 是 `File/Blob` 时,会自动分发到 `client.sendFile()
|
|
666
|
+
**提示**:当 `client.send()` 的 `body` 是 `File/Blob` 时,会自动分发到 `client.sendFile()`。`autoResolve` 为 true(默认)时 Server 拿到 `req.body`(File/Blob),为 false 时拿到 `req.stream` / `req.body`(`IframeFileReadableStream`)。
|
|
643
667
|
|
|
644
668
|
### 流式传输(Stream)
|
|
645
669
|
|
|
646
|
-
|
|
670
|
+
Stream 除了用于大文件/分块传输,也可以用于“长连接 / 订阅式交互”(类似 SSE / WebSocket,但基于 `postMessage`)。常见用法有两类:
|
|
671
|
+
|
|
672
|
+
- **长连接/订阅**:Client 发起一次请求拿到 `response.stream`,然后用 `for await` 持续消费事件;需要结束时调用 `stream.cancel()`。
|
|
673
|
+
- **分块/文件流**:按 chunk 传输数据或文件(下方示例)。
|
|
674
|
+
|
|
675
|
+
> 长连接注意事项:
|
|
676
|
+
> - `IframeWritableStream` 默认会使用 `asyncTimeout` 作为 `expireTimeout`(避免泄露)。如果你的订阅需要更久,请显式设置更大的 `expireTimeout`,或设置 `expireTimeout: 0` 关闭自动过期(建议配合业务侧取消/重连,避免泄露)。
|
|
677
|
+
> - Server 端的 `res.sendStream(stream)` 会一直等待到流结束;如果你需要在后续主动 `write()` 推送数据,请不要直接 `await` 它,可以用 `void res.sendStream(stream)` 或保存返回的 Promise。
|
|
678
|
+
> - 如果启用了 `maxConcurrentRequestsPerClient`,一个长连接 stream 会占用一个并发槽,需要按业务场景调整。
|
|
679
|
+
> - **事件订阅**:stream 支持 `stream.on(event, listener)`(返回取消订阅函数),可用于埋点/进度/调试(如监听 `start/data/read/write/cancel/end/error/timeout/expired`)。主消费仍建议使用 `for await`。
|
|
680
|
+
|
|
681
|
+
#### 长连接 / 订阅式交互(push 模式示例)
|
|
647
682
|
|
|
648
683
|
```typescript
|
|
649
|
-
|
|
684
|
+
/**
|
|
685
|
+
* Server 端:订阅(长连接)
|
|
686
|
+
* - mode: 'push':由写侧主动 write()
|
|
687
|
+
* - expireTimeout: 0:关闭自动过期(谨慎使用,建议结合业务取消/重连)
|
|
688
|
+
*/
|
|
689
|
+
server.on('/api/subscribe', (req, res) => {
|
|
690
|
+
const stream = new IframeWritableStream({
|
|
691
|
+
type: 'data',
|
|
692
|
+
chunked: true,
|
|
693
|
+
mode: 'push',
|
|
694
|
+
expireTimeout: 0,
|
|
695
|
+
/** 可选:写侧空闲检测(等待 pull/ack 太久会做心跳并失败) */
|
|
696
|
+
streamTimeout: 15000
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
/** 注意:不要 await,否则会一直等到流结束 */
|
|
700
|
+
void res.sendStream(stream);
|
|
701
|
+
|
|
702
|
+
const timer = setInterval(() => {
|
|
703
|
+
try {
|
|
704
|
+
stream.write({ type: 'tick', ts: Date.now() });
|
|
705
|
+
} catch {
|
|
706
|
+
clearInterval(timer);
|
|
707
|
+
}
|
|
708
|
+
}, 1000);
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Client 端:持续读取(长连接建议用 for await,而不是 readAll())
|
|
713
|
+
*/
|
|
714
|
+
const resp = await client.send('/api/subscribe', {});
|
|
715
|
+
if (isIframeReadableStream(resp.stream)) {
|
|
716
|
+
/** 事件订阅示例(可选) */
|
|
717
|
+
const off = resp.stream.on(StreamEvent.ERROR, ({ error }) => {
|
|
718
|
+
console.error('stream error:', error);
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
for await (const evt of resp.stream) {
|
|
722
|
+
console.log('event:', evt);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
off();
|
|
726
|
+
}
|
|
727
|
+
```
|
|
728
|
+
|
|
729
|
+
#### 分块 / 文件流示例
|
|
730
|
+
|
|
731
|
+
```typescript
|
|
732
|
+
import {
|
|
733
|
+
StreamEvent,
|
|
650
734
|
IframeWritableStream,
|
|
651
735
|
IframeFileWritableStream,
|
|
652
736
|
isIframeReadableStream,
|
|
@@ -757,10 +841,10 @@ if (isIframeFileReadableStream(fileResponse.stream)) {
|
|
|
757
841
|
|
|
758
842
|
| 类型 | 说明 |
|
|
759
843
|
|------|------|
|
|
760
|
-
| `IframeWritableStream` |
|
|
761
|
-
| `IframeFileWritableStream` |
|
|
762
|
-
| `IframeReadableStream` |
|
|
763
|
-
| `IframeFileReadableStream` |
|
|
844
|
+
| `IframeWritableStream` | 写侧(生产者)流:**谁要发送 stream,谁就创建它**;可用于 Server→Client 的响应流,也可用于 Client→Server 的请求流 |
|
|
845
|
+
| `IframeFileWritableStream` | 文件写侧(生产者)流:用于发送文件(底层会做 Base64 编码) |
|
|
846
|
+
| `IframeReadableStream` | 读侧(消费者)流:用于接收普通数据(无论来自 Server 还是 Client) |
|
|
847
|
+
| `IframeFileReadableStream` | 文件读侧(消费者)流:用于接收文件(底层会做 Base64 解码) |
|
|
764
848
|
|
|
765
849
|
> **注意**:文件流内部会进行 Base64 编/解码。Base64 会带来约 33% 的体积膨胀,并且在超大文件场景下可能会有较高的内存/CPU 开销。大文件建议使用 **分块** 文件流(`chunked: true`),并控制 chunk 大小(例如 256KB–1MB)。
|
|
766
850
|
|
|
@@ -775,18 +859,22 @@ interface WritableStreamOptions {
|
|
|
775
859
|
streamTimeout?: number; // 写侧空闲超时(ms,可选):长时间未收到对端 pull/ack 时会做心跳确认并失败
|
|
776
860
|
iterator?: () => AsyncGenerator; // 数据生成迭代器
|
|
777
861
|
next?: () => Promise<{ data: any; done: boolean }>; // 数据生成函数
|
|
862
|
+
maxPendingChunks?: number; // 写侧待发送缓冲上限(可选;push/长连接场景建议配置,避免 pendingQueue 无限增长)
|
|
863
|
+
maxPendingBytes?: number; // 写侧待发送字节上限(可选;避免单次 write 超大 chunk 导致内存暴涨)
|
|
778
864
|
metadata?: Record<string, any>; // 自定义元数据
|
|
779
865
|
}
|
|
780
866
|
```
|
|
781
867
|
|
|
782
868
|
**流超时/保活:**
|
|
783
|
-
- `streamTimeout`(请求参数):读侧空闲超时(ms,可选)。消费 `response.stream` 时超过该时间未收到新的 chunk
|
|
784
|
-
- `streamTimeout`(流参数):写侧空闲超时(ms,可选)。写侧在 pull
|
|
869
|
+
- `streamTimeout`(请求参数):读侧空闲超时(ms,可选)。消费 `response.stream` 时超过该时间未收到新的 chunk,会先做一次心跳确认(默认使用 `client.isConnect()`),失败则认为流已断开并报错。
|
|
870
|
+
- `streamTimeout`(流参数):写侧空闲超时(ms,可选)。写侧在 pull 协议下,若长时间未收到对端 `pull`,会做心跳确认并失败(避免长时间无效占用)。
|
|
785
871
|
- `expireTimeout`(流参数):写侧有效期;过期后会发送 `stream_error`,读侧会收到明确的“已过期”错误。
|
|
872
|
+
- `maxPendingChunks`(流参数):写侧待发送缓冲上限(可选)。对 `push` / 长连接场景很重要:当对端停止 pull 时,继续 `write()` 会在写侧积压,建议设置上限防止内存无限增长。
|
|
873
|
+
- `maxPendingBytes`(流参数):写侧待发送字节上限(可选)。用于防止单次写入超大 chunk(例如大字符串/大 Blob 包装)导致内存占用过高。
|
|
786
874
|
|
|
787
875
|
**pull/ack 协议(新增,默认启用):**
|
|
788
|
-
- 读侧会自动发送 `stream_pull` 请求更多 chunk
|
|
789
|
-
-
|
|
876
|
+
- 读侧会自动发送 `stream_pull` 请求更多 chunk;写侧只会在收到 `stream_pull` 后才继续发送 `stream_data`,实现真正的背压(按需拉取)。
|
|
877
|
+
- 断连检测不依赖 `stream_ack`,而是通过 `streamTimeout + 心跳(isConnect)` 来实现。
|
|
790
878
|
|
|
791
879
|
**consume 默认行为(变更):**
|
|
792
880
|
- `for await (const chunk of stream)` 默认会 **消费并丢弃已迭代过的 chunk**(`consume: true`),避免长流场景内存无限增长。
|
|
@@ -811,9 +899,9 @@ Server 可以要求 Client 确认收到响应:
|
|
|
811
899
|
```typescript
|
|
812
900
|
server.on('/api/important', async (req, res) => {
|
|
813
901
|
// requireAck: true 表示需要客户端确认
|
|
814
|
-
const
|
|
902
|
+
const acked = await res.send(data, { requireAck: true });
|
|
815
903
|
|
|
816
|
-
if (
|
|
904
|
+
if (acked) {
|
|
817
905
|
console.log('客户端已确认收到');
|
|
818
906
|
} else {
|
|
819
907
|
console.log('客户端未确认(超时)');
|
|
@@ -821,7 +909,7 @@ server.on('/api/important', async (req, res) => {
|
|
|
821
909
|
});
|
|
822
910
|
```
|
|
823
911
|
|
|
824
|
-
> **说明**:当响应/错误被客户端“接管”(即存在对应的 pending request)时,库会自动发送 `
|
|
912
|
+
> **说明**:当响应/错误被客户端“接管”(即存在对应的 pending request)时,库会自动发送 `ack`,无需业务侧手动发送。
|
|
825
913
|
|
|
826
914
|
### 追踪模式
|
|
827
915
|
|
|
@@ -886,6 +974,70 @@ setMessages({
|
|
|
886
974
|
|
|
887
975
|
**返回值:** `RequestIframeClient`
|
|
888
976
|
|
|
977
|
+
**关于 `target: Window` 的说明:**
|
|
978
|
+
- **必须持有对方页面的 `Window` 引用**(例如 `window.open()` 返回值、`window.opener`、或 `MessageEvent.source`)。
|
|
979
|
+
- **无法**通过 URL 给“任意标签页”发消息。
|
|
980
|
+
- 安全起见,建议显式设置 `targetOrigin`,并配置 `allowedOrigins` / `validateOrigin`。
|
|
981
|
+
|
|
982
|
+
**生产环境推荐配置(模板):**
|
|
983
|
+
|
|
984
|
+
```typescript
|
|
985
|
+
import { requestIframeClient, requestIframeServer } from 'request-iframe';
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* 建议:明确限定 3 件事
|
|
989
|
+
* - secretKey:隔离不同业务/不同实例,避免消息串线
|
|
990
|
+
* - targetOrigin:发送时的 targetOrigin(Window 场景强烈建议不要用 '*')
|
|
991
|
+
* - allowedOrigins / validateOrigin:接收时的 origin 白名单校验
|
|
992
|
+
*/
|
|
993
|
+
const secretKey = 'my-app';
|
|
994
|
+
const targetOrigin = 'https://child.example.com';
|
|
995
|
+
const allowedOrigins = [targetOrigin];
|
|
996
|
+
|
|
997
|
+
// Client(父页)
|
|
998
|
+
const client = requestIframeClient(window.open(targetOrigin)!, {
|
|
999
|
+
secretKey,
|
|
1000
|
+
targetOrigin,
|
|
1001
|
+
allowedOrigins
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
// Server(子页/iframe 内)
|
|
1005
|
+
const server = requestIframeServer({
|
|
1006
|
+
secretKey,
|
|
1007
|
+
allowedOrigins,
|
|
1008
|
+
// 防止异常/攻击导致消息爆炸(按需设置)
|
|
1009
|
+
maxConcurrentRequestsPerClient: 50
|
|
1010
|
+
});
|
|
1011
|
+
```
|
|
1012
|
+
|
|
1013
|
+
**生产环境推荐配置(iframe 场景模板):**
|
|
1014
|
+
|
|
1015
|
+
```typescript
|
|
1016
|
+
import { requestIframeClient, requestIframeServer } from 'request-iframe';
|
|
1017
|
+
|
|
1018
|
+
/**
|
|
1019
|
+
* iframe 场景通常可以从 iframe.src 推导 targetOrigin,并用它作为 allowedOrigins 白名单。
|
|
1020
|
+
*/
|
|
1021
|
+
const iframe = document.querySelector('iframe')!;
|
|
1022
|
+
const targetOrigin = new URL(iframe.src).origin;
|
|
1023
|
+
const secretKey = 'my-app';
|
|
1024
|
+
|
|
1025
|
+
// Client(父页)
|
|
1026
|
+
const client = requestIframeClient(iframe, {
|
|
1027
|
+
secretKey,
|
|
1028
|
+
// 可显式写出来(即使库内部也会默认推导),便于审计/避免误用 '*'
|
|
1029
|
+
targetOrigin,
|
|
1030
|
+
allowedOrigins: [targetOrigin]
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
// Server(iframe 内)
|
|
1034
|
+
const server = requestIframeServer({
|
|
1035
|
+
secretKey,
|
|
1036
|
+
allowedOrigins: [targetOrigin],
|
|
1037
|
+
maxConcurrentRequestsPerClient: 50
|
|
1038
|
+
});
|
|
1039
|
+
```
|
|
1040
|
+
|
|
889
1041
|
**示例:**
|
|
890
1042
|
|
|
891
1043
|
```typescript
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# request-iframe
|
|
2
2
|
|
|
3
|
-
Communicate with iframes like sending HTTP requests! A cross-origin
|
|
3
|
+
Communicate with iframes/windows like sending HTTP requests! A cross-origin browser communication library based on `postMessage`.
|
|
4
4
|
|
|
5
5
|
> 🌐 **Languages**: [English](./README.md) | [中文](./README.CN.md)
|
|
6
6
|
|
|
@@ -8,7 +8,7 @@ Communicate with iframes like sending HTTP requests! A cross-origin iframe commu
|
|
|
8
8
|
<img src="https://img.shields.io/badge/TypeScript-Ready-blue" alt="TypeScript Ready">
|
|
9
9
|
<img src="https://img.shields.io/badge/API-Express%20Like-green" alt="Express Like API">
|
|
10
10
|
<img src="https://img.shields.io/badge/License-MIT-yellow" alt="MIT License">
|
|
11
|
-
<img src="https://
|
|
11
|
+
<img src="https://coveralls.io/repos/github/gxlmyacc/request-iframe/badge.svg?branch=main" alt="Coverage Status">
|
|
12
12
|
</p>
|
|
13
13
|
|
|
14
14
|
## 📑 Table of Contents
|
|
@@ -48,7 +48,7 @@ Communicate with iframes like sending HTTP requests! A cross-origin iframe commu
|
|
|
48
48
|
|
|
49
49
|
## Why request-iframe?
|
|
50
50
|
|
|
51
|
-
In micro-frontend
|
|
51
|
+
In micro-frontend, iframe nesting, and popup window scenarios, cross-page communication is a common requirement. Traditional `postMessage` communication has the following pain points:
|
|
52
52
|
|
|
53
53
|
| Pain Point | Traditional Way | request-iframe |
|
|
54
54
|
|------------|----------------|----------------|
|
|
@@ -74,7 +74,7 @@ In micro-frontend and iframe nesting scenarios, parent-child page communication
|
|
|
74
74
|
- ⏱️ **Smart Timeout** - Three-stage timeout (connection/sync/async), automatically detects long tasks
|
|
75
75
|
- 📦 **TypeScript** - Complete type definitions and IntelliSense
|
|
76
76
|
- 🔒 **Message Isolation** - secretKey mechanism prevents message cross-talk between multiple instances
|
|
77
|
-
- 📁 **File Transfer** -
|
|
77
|
+
- 📁 **File Transfer** - File transfer via streams (client↔server)
|
|
78
78
|
- 🌊 **Streaming** - Support for large file chunked transfer, supports async iterators
|
|
79
79
|
- 🌍 **Internationalization** - Error messages can be customized for i18n
|
|
80
80
|
- ✅ **Protocol Versioning** - Built-in version control for upgrade compatibility
|
|
@@ -172,6 +172,29 @@ server.on('/event', (req, res) => {
|
|
|
172
172
|
});
|
|
173
173
|
```
|
|
174
174
|
|
|
175
|
+
### Popup / New Window (Window Communication)
|
|
176
|
+
|
|
177
|
+
`request-iframe` also works with a `Window` target (not only an iframe).
|
|
178
|
+
|
|
179
|
+
**Important**: you must have a real `Window` reference (e.g. returned by `window.open()`, or available via `window.opener` / `event.source`). You cannot send to an arbitrary browser tab by URL.
|
|
180
|
+
|
|
181
|
+
```typescript
|
|
182
|
+
// Parent page: open a new tab/window
|
|
183
|
+
const child = window.open('https://child.example.com/page.html', '_blank');
|
|
184
|
+
if (!child) throw new Error('Popup blocked');
|
|
185
|
+
|
|
186
|
+
// Parent -> child
|
|
187
|
+
const client = requestIframeClient(child, {
|
|
188
|
+
secretKey: 'popup-demo',
|
|
189
|
+
targetOrigin: 'https://child.example.com' // strongly recommended (avoid '*')
|
|
190
|
+
});
|
|
191
|
+
await client.send('/api/ping', { from: 'parent' });
|
|
192
|
+
|
|
193
|
+
// Child page: create server
|
|
194
|
+
const server = requestIframeServer({ secretKey: 'popup-demo' });
|
|
195
|
+
server.on('/api/ping', (req, res) => res.send({ ok: true, echo: req.body }));
|
|
196
|
+
```
|
|
197
|
+
|
|
175
198
|
### Cross-Origin Data Fetching
|
|
176
199
|
|
|
177
200
|
When iframe and parent page are on different origins, use request-iframe to securely fetch data:
|
|
@@ -237,7 +260,7 @@ request-iframe implements an HTTP-like communication protocol on top of `postMes
|
|
|
237
260
|
│ │
|
|
238
261
|
│ <──── RESPONSE ────────────────────── │ Return result
|
|
239
262
|
│ │
|
|
240
|
-
│ ────
|
|
263
|
+
│ ──── ACK (optional) ─────────────────> │ Acknowledge receipt of response/error (ACK-only requireAck)
|
|
241
264
|
│ │
|
|
242
265
|
```
|
|
243
266
|
|
|
@@ -246,15 +269,13 @@ request-iframe implements an HTTP-like communication protocol on top of `postMes
|
|
|
246
269
|
| Type | Direction | Description |
|
|
247
270
|
|------|-----------|-------------|
|
|
248
271
|
| `request` | Client → Server | Client initiates request |
|
|
249
|
-
| `ack` |
|
|
272
|
+
| `ack` | Receiver → Sender | Acknowledgment when `requireAck: true` and the message is accepted/handled (ACK-only) |
|
|
250
273
|
| `async` | Server → Client | Notifies client this is an async task (sent when handler returns Promise) |
|
|
251
274
|
| `response` | Server → Client | Returns response data |
|
|
252
275
|
| `error` | Server → Client | Returns error information |
|
|
253
|
-
| `received` | Client → Server | Client acknowledges receipt of response/error (optional, controlled by response `requireAck`) |
|
|
254
276
|
| `ping` | Client → Server | Connection detection (`isConnect()` method, may use `requireAck` to confirm delivery) |
|
|
255
277
|
| `pong` | Server → Client | Connection detection response |
|
|
256
278
|
| `stream_pull` | Receiver → Sender | Stream pull: receiver requests next chunks (pull/ack protocol) |
|
|
257
|
-
| `stream_ack` | Receiver → Sender | Stream ack: receiver acknowledges a chunk (pull/ack protocol) |
|
|
258
279
|
|
|
259
280
|
### Timeout Mechanism
|
|
260
281
|
|
|
@@ -558,6 +579,8 @@ server.on('/api/data', (req, res) => {
|
|
|
558
579
|
|
|
559
580
|
### File Transfer
|
|
560
581
|
|
|
582
|
+
> Note: File transfer (both Client→Server and Server→Client) is carried by the stream protocol under the hood. You normally only need to use `client.sendFile()` / `res.sendFile()`.
|
|
583
|
+
|
|
561
584
|
#### Server → Client (Server sends file to client)
|
|
562
585
|
|
|
563
586
|
```typescript
|
|
@@ -592,7 +615,7 @@ if (response.data instanceof File || response.data instanceof Blob) {
|
|
|
592
615
|
|
|
593
616
|
#### Client → Server (Client sends file to server)
|
|
594
617
|
|
|
595
|
-
Client sends file
|
|
618
|
+
Client sends file using `sendFile()` (or `send(path, file)`); server receives either `req.body` as File/Blob when `autoResolve: true` (default), or `req.stream` as `IframeFileReadableStream` when `autoResolve: false`.
|
|
596
619
|
|
|
597
620
|
```typescript
|
|
598
621
|
// Client side: Send file (stream, autoResolve defaults to true)
|
|
@@ -616,16 +639,71 @@ server.on('/api/upload', async (req, res) => {
|
|
|
616
639
|
});
|
|
617
640
|
```
|
|
618
641
|
|
|
619
|
-
**Note:** When using `client.send()` with a `File` or `Blob`, it automatically dispatches to `client.sendFile()
|
|
642
|
+
**Note:** When using `client.send()` with a `File` or `Blob`, it automatically dispatches to `client.sendFile()`. Server gets `req.body` as File/Blob when `autoResolve` is true (default), or `req.stream` / `req.body` as `IframeFileReadableStream` when `autoResolve` is false.
|
|
620
643
|
|
|
621
644
|
### Streaming
|
|
622
645
|
|
|
623
|
-
|
|
646
|
+
Streaming is not only for large/chunked transfers, but also works well for **long-lived subscription-style interactions** (similar to SSE/WebSocket, but built on top of `postMessage`).
|
|
647
|
+
|
|
648
|
+
#### Long-lived subscription (push mode)
|
|
649
|
+
|
|
650
|
+
> Notes:
|
|
651
|
+
> - `IframeWritableStream` defaults `expireTimeout` to `asyncTimeout` to avoid leaking long-lived streams. For real subscriptions, set a larger `expireTimeout`, or set `expireTimeout: 0` to disable auto-expire (use with care and pair with cancel/reconnect).
|
|
652
|
+
> - `res.sendStream(stream)` waits until the stream ends. If you want to keep pushing via `write()`, **do not** `await` it; use `void res.sendStream(stream)` or keep the returned Promise.
|
|
653
|
+
> - If `maxConcurrentRequestsPerClient` is enabled, a long-lived stream occupies one in-flight request slot.
|
|
654
|
+
> - **Event subscription**: streams support `stream.on(event, listener)` (returns an unsubscribe function) for observability (e.g. `start/data/read/write/cancel/end/error/timeout/expired`). For consuming data, prefer `for await`.
|
|
655
|
+
|
|
656
|
+
```typescript
|
|
657
|
+
/**
|
|
658
|
+
* Server side: subscribe (long-lived)
|
|
659
|
+
* - mode: 'push': writer calls write()
|
|
660
|
+
* - expireTimeout: 0: disable auto-expire (use with care)
|
|
661
|
+
*/
|
|
662
|
+
server.on('/api/subscribe', (req, res) => {
|
|
663
|
+
const stream = new IframeWritableStream({
|
|
664
|
+
type: 'data',
|
|
665
|
+
chunked: true,
|
|
666
|
+
mode: 'push',
|
|
667
|
+
expireTimeout: 0,
|
|
668
|
+
/** optional: writer-side idle timeout while waiting for pull/ack */
|
|
669
|
+
streamTimeout: 15000
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
/** do not await, otherwise it blocks until stream ends */
|
|
673
|
+
void res.sendStream(stream);
|
|
674
|
+
|
|
675
|
+
const timer = setInterval(() => {
|
|
676
|
+
try {
|
|
677
|
+
stream.write({ type: 'tick', ts: Date.now() });
|
|
678
|
+
} catch {
|
|
679
|
+
clearInterval(timer);
|
|
680
|
+
}
|
|
681
|
+
}, 1000);
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Client side: consume continuously (prefer for-await for long-lived streams)
|
|
686
|
+
*/
|
|
687
|
+
const resp = await client.send('/api/subscribe', {});
|
|
688
|
+
if (isIframeReadableStream(resp.stream)) {
|
|
689
|
+
/** Optional: observe events */
|
|
690
|
+
const off = resp.stream.on(StreamEvent.ERROR, ({ error }) => {
|
|
691
|
+
console.error('stream error:', error);
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
for await (const evt of resp.stream) {
|
|
695
|
+
console.log('event:', evt);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
off();
|
|
699
|
+
}
|
|
700
|
+
```
|
|
624
701
|
|
|
625
702
|
#### Server → Client (Server sends stream to client)
|
|
626
703
|
|
|
627
704
|
```typescript
|
|
628
|
-
import {
|
|
705
|
+
import {
|
|
706
|
+
StreamEvent,
|
|
629
707
|
IframeWritableStream,
|
|
630
708
|
IframeFileWritableStream,
|
|
631
709
|
isIframeReadableStream,
|
|
@@ -740,21 +818,23 @@ server.on('/api/uploadStream', async (req, res) => {
|
|
|
740
818
|
|
|
741
819
|
| Type | Description |
|
|
742
820
|
|------|-------------|
|
|
743
|
-
| `IframeWritableStream` |
|
|
744
|
-
| `IframeFileWritableStream` |
|
|
745
|
-
| `IframeReadableStream` |
|
|
746
|
-
| `IframeFileReadableStream` |
|
|
821
|
+
| `IframeWritableStream` | Writer/producer stream: **created by whichever side is sending the stream** (server→client response stream, or client→server request stream) |
|
|
822
|
+
| `IframeFileWritableStream` | File writer/producer stream (base64-encodes internally) |
|
|
823
|
+
| `IframeReadableStream` | Reader/consumer stream for receiving regular data (regardless of which side sent it) |
|
|
824
|
+
| `IframeFileReadableStream` | File reader/consumer stream (base64-decodes internally) |
|
|
747
825
|
|
|
748
826
|
> **Note**: File streams are base64-encoded internally. Base64 introduces ~33% size overhead and can be memory/CPU heavy for very large files. For large files, prefer **chunked** file streams (`chunked: true`) and keep chunk sizes moderate (e.g. 256KB–1MB).
|
|
749
827
|
|
|
750
828
|
**Stream timeouts:**
|
|
751
|
-
- `options.streamTimeout` (request option): client-side stream idle timeout while consuming `response.stream` (data/file streams). When triggered, the client
|
|
829
|
+
- `options.streamTimeout` (request option): client-side stream idle timeout while consuming `response.stream` (data/file streams). When triggered, the client performs a heartbeat check (by default `client.isConnect()`); if not alive, the stream fails as disconnected.
|
|
752
830
|
- `expireTimeout` (writable stream option): writer-side stream lifetime. When expired, the writer sends `stream_error` and the reader will fail the stream with `STREAM_EXPIRED`.
|
|
753
|
-
- `streamTimeout` (writable stream option): writer-side idle timeout. If the writer does not receive `stream_pull
|
|
831
|
+
- `streamTimeout` (writable stream option): writer-side idle timeout. If the writer does not receive `stream_pull` for a long time, it will heartbeat-check and fail to avoid wasting resources.
|
|
832
|
+
- `maxPendingChunks` (writable stream option): max number of pending (unsent) chunks kept in memory on the writer side (optional). Important for long-lived `push` streams: if the receiver stops pulling, continued `write()` calls will accumulate in the writer queue.
|
|
833
|
+
- `maxPendingBytes` (writable stream option): max bytes of pending (unsent) chunks kept in memory on the writer side (optional). Useful when a single `write()` may enqueue a very large chunk.
|
|
754
834
|
|
|
755
835
|
**Pull/Ack protocol (default):**
|
|
756
|
-
- Reader automatically sends `stream_pull` to request chunks and sends `stream_ack` for each received chunk.
|
|
757
836
|
- Writer only sends `stream_data` when it has received `stream_pull`, enabling real backpressure.
|
|
837
|
+
- Disconnect detection does not rely on `stream_ack`, but uses `streamTimeout + heartbeat(isConnect)`.
|
|
758
838
|
|
|
759
839
|
**consume default change:**
|
|
760
840
|
- `for await (const chunk of response.stream)` defaults to **consume and drop** already iterated chunks (`consume: true`) to prevent unbounded memory growth for long streams.
|
|
@@ -778,9 +858,9 @@ Server can require Client to acknowledge receipt of response:
|
|
|
778
858
|
```typescript
|
|
779
859
|
server.on('/api/important', async (req, res) => {
|
|
780
860
|
// requireAck: true means client needs to acknowledge
|
|
781
|
-
const
|
|
861
|
+
const acked = await res.send(data, { requireAck: true });
|
|
782
862
|
|
|
783
|
-
if (
|
|
863
|
+
if (acked) {
|
|
784
864
|
console.log('Client acknowledged receipt');
|
|
785
865
|
} else {
|
|
786
866
|
console.log('Client did not acknowledge (timeout)');
|
|
@@ -788,7 +868,7 @@ server.on('/api/important', async (req, res) => {
|
|
|
788
868
|
});
|
|
789
869
|
```
|
|
790
870
|
|
|
791
|
-
> **Note**: Client acknowledgment (`
|
|
871
|
+
> **Note**: Client acknowledgment (`ack`) is sent automatically by the library when the response/error is accepted by the client (i.e., there is a matching pending request). You don't need to manually send `ack`.
|
|
792
872
|
|
|
793
873
|
### Trace Mode
|
|
794
874
|
|
|
@@ -853,6 +933,70 @@ Create a Client instance.
|
|
|
853
933
|
|
|
854
934
|
**Returns:** `RequestIframeClient`
|
|
855
935
|
|
|
936
|
+
**Notes about `target: Window`:**
|
|
937
|
+
- **You must have a `Window` reference** (e.g. from `window.open()`, `window.opener`, or `MessageEvent.source`).
|
|
938
|
+
- You **cannot** communicate with an arbitrary browser tab by URL.
|
|
939
|
+
- For security, prefer setting a strict `targetOrigin` and configure `allowedOrigins` / `validateOrigin`.
|
|
940
|
+
|
|
941
|
+
**Production configuration template:**
|
|
942
|
+
|
|
943
|
+
```typescript
|
|
944
|
+
import { requestIframeClient, requestIframeServer } from 'request-iframe';
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* Recommended: explicitly constrain 3 things
|
|
948
|
+
* - secretKey: isolate different apps/instances (avoid cross-talk)
|
|
949
|
+
* - targetOrigin: postMessage targetOrigin (strongly avoid '*' for Window targets)
|
|
950
|
+
* - allowedOrigins / validateOrigin: incoming origin allowlist validation
|
|
951
|
+
*/
|
|
952
|
+
const secretKey = 'my-app';
|
|
953
|
+
const targetOrigin = 'https://child.example.com';
|
|
954
|
+
const allowedOrigins = [targetOrigin];
|
|
955
|
+
|
|
956
|
+
// Client (parent)
|
|
957
|
+
const client = requestIframeClient(window.open(targetOrigin)!, {
|
|
958
|
+
secretKey,
|
|
959
|
+
targetOrigin,
|
|
960
|
+
allowedOrigins
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
// Server (child/iframe)
|
|
964
|
+
const server = requestIframeServer({
|
|
965
|
+
secretKey,
|
|
966
|
+
allowedOrigins,
|
|
967
|
+
// Mitigate message explosion (tune as needed)
|
|
968
|
+
maxConcurrentRequestsPerClient: 50
|
|
969
|
+
});
|
|
970
|
+
```
|
|
971
|
+
|
|
972
|
+
**Production configuration template (iframe target):**
|
|
973
|
+
|
|
974
|
+
```typescript
|
|
975
|
+
import { requestIframeClient, requestIframeServer } from 'request-iframe';
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* For iframe targets, you can derive targetOrigin from iframe.src, and use it as the allowedOrigins allowlist.
|
|
979
|
+
*/
|
|
980
|
+
const iframe = document.querySelector('iframe')!;
|
|
981
|
+
const targetOrigin = new URL(iframe.src).origin;
|
|
982
|
+
const secretKey = 'my-app';
|
|
983
|
+
|
|
984
|
+
// Client (parent)
|
|
985
|
+
const client = requestIframeClient(iframe, {
|
|
986
|
+
secretKey,
|
|
987
|
+
// Explicitly set it (even though the library can derive it) to avoid accidentally using '*'
|
|
988
|
+
targetOrigin,
|
|
989
|
+
allowedOrigins: [targetOrigin]
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
// Server (inside iframe)
|
|
993
|
+
const server = requestIframeServer({
|
|
994
|
+
secretKey,
|
|
995
|
+
allowedOrigins: [targetOrigin],
|
|
996
|
+
maxConcurrentRequestsPerClient: 50
|
|
997
|
+
});
|
|
998
|
+
```
|
|
999
|
+
|
|
856
1000
|
### requestIframeServer(options?)
|
|
857
1001
|
|
|
858
1002
|
Create a Server instance.
|
package/esm/api/client.js
CHANGED
|
@@ -2,7 +2,7 @@ import { getIframeTargetOrigin, generateInstanceId } from '../utils';
|
|
|
2
2
|
import { RequestIframeClientServer } from '../core/client-server';
|
|
3
3
|
import { RequestIframeClientImpl } from '../core/client';
|
|
4
4
|
import { setupClientDebugInterceptors } from '../utils/debug';
|
|
5
|
-
import { Messages, ErrorCode } from '../constants';
|
|
5
|
+
import { Messages, ErrorCode, OriginConstant } from '../constants';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Create a client (for sending requests)
|
|
@@ -14,7 +14,7 @@ import { Messages, ErrorCode } from '../constants';
|
|
|
14
14
|
*/
|
|
15
15
|
export function requestIframeClient(target, options) {
|
|
16
16
|
var targetWindow = null;
|
|
17
|
-
var targetOrigin =
|
|
17
|
+
var targetOrigin = OriginConstant.ANY;
|
|
18
18
|
if (target.tagName === 'IFRAME') {
|
|
19
19
|
var iframe = target;
|
|
20
20
|
targetWindow = iframe.contentWindow;
|
|
@@ -27,7 +27,7 @@ export function requestIframeClient(target, options) {
|
|
|
27
27
|
}
|
|
28
28
|
} else {
|
|
29
29
|
targetWindow = target;
|
|
30
|
-
targetOrigin =
|
|
30
|
+
targetOrigin = OriginConstant.ANY;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
// Allow user to override targetOrigin explicitly
|
|
@@ -44,8 +44,9 @@ export function requestIframeClient(target, options) {
|
|
|
44
44
|
// Create ClientServer (internally obtains or creates a shared MessageChannel)
|
|
45
45
|
var server = new RequestIframeClientServer({
|
|
46
46
|
secretKey,
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
autoOpen: options === null || options === void 0 ? void 0 : options.autoOpen,
|
|
48
|
+
autoAckMaxMetaLength: options === null || options === void 0 ? void 0 : options.autoAckMaxMetaLength,
|
|
49
|
+
autoAckMaxIdLength: options === null || options === void 0 ? void 0 : options.autoAckMaxIdLength
|
|
49
50
|
}, instanceId);
|
|
50
51
|
|
|
51
52
|
// Create client instance
|