# 网络

> card.net.fetch——经由 NeuroSquad 代理访问卡片声明过的主机：JSON、二进制和流式响应、请求头中的密钥、重定向与限制。

Source: https://docs.neurosquad.ai/zh/card-sdk/api/network

卡片页面本身完全无法访问网络——它的沙箱会拦截包外的每一个请求、图片和字体。`card.net.fetch` 改为经由应用中的代理，只放行用户授权的内容：

- 带 `hosts` 的 [`network`](https://docs.neurosquad.ai/zh/card-sdk/permissions#network)：对这些主机的 `https` 请求；
- [`network.local`](https://docs.neurosquad.ai/zh/card-sdk/permissions#network-local)：对 `localhost`、`127.0.0.1` 和 `::1` 的 `http` 或 `https` 请求——NeuroSquad 自己的本地服务器
（它的 MCP 服务器、远程访问、开发者链接、浏览器卡片中 Chrome 的调试端口）除外，它们无法访问。

## 发起请求

```ts
const res = await card.net.fetch('https://api.github.com/repos/acme/app/issues?state=open', {
  headers: { accept: 'application/vnd.github+json' },
  responseType: 'json'
})
if (!res.ok) throw new Error(`GitHub said ${res.status} ${res.statusText}`)
const issues = await res.json<{ number: number; title: string }[]>()
render(issues.map((i) => `#${i.number} ${i.title}`))
```

它的用法和 `fetch` 很像，只有几处不同：

| 选项 | 含义 |
| --- | --- |
| `method` | `GET`（默认）、`HEAD`、`POST`、`PUT`、`PATCH`、`DELETE`。有请求体但没指定方法时为 `POST`。 |
| `headers` | 对象，或 `[name, value]` 对。可以包含 [`{{secret:<key>}}`](#secrets)。 |
| `body` | 字符串（原样发送）、字节（`Uint8Array`、`ArrayBuffer`），或其他任意 JSON 值（序列化后发送，除非你自己设置，否则带 `content-type: application/json`）。≤ 5 MB。 |
| `responseType` | `text`（默认）、`json`（由应用解析）、`base64`（二进制）或 `stream`。 |
| `timeoutMs` | 1 000–120 000，默认 30 000。 |
| `signal` | 一个 `AbortSignal`；中止时会在应用中取消该请求。 |

结果是一个 `CardResponse`：`ok`、`status`、`statusText`、`url`（重定向之后）、`headers`（`get`、`has`、可迭代；名称为小写）、`truncated`，
以及读取正文的方法 `text()`、`json()`、`bytes()`、`arrayBuffer()`、`chunks()`、`lines()`——和 `Response` 一样，正文只能读取一次。

```ts
// POST JSON, read binary.
const upload = await card.net.fetch('https://api.example.com/v1/render', {
  method: 'POST',
  body: { chart: 'coverage', width: 640 },
  responseType: 'base64'
})
const png = await upload.bytes()
render(png.byteLength)
```

**条件请求与空响应体。** `If-None-Match`、`If-Modified-Since` 等条件请求头会被转发，除 `set-cookie` 外的所有响应头都会返回
（所以 `etag` 和限流相关的响应头都在）。空响应体——`304 Not Modified` 或 `204 No Content`——在 `responseType: 'json'` 时，`res.json()` 返回 `null`。

## 请求头中的密钥

永远不要把 API 密钥放进卡片代码或存储里。声明一个 `secret` [设置](https://docs.neurosquad.ai/zh/card-sdk/manifest#settings)，让用户在应用的设置表单中输入，然后在**请求头的值**中引用它：

```ts
if (!card.settings.hasSecret('apiKey')) {
  await card.settings.open()
} else {
  const res = await card.net.fetch('https://api.example.com/v1/me', {
    headers: { authorization: 'Bearer {{secret:apiKey}}' },
    responseType: 'json'
  })
  render(await res.json())
}
```

应用会在请求发出时把 `{{secret:apiKey}}` 替换为保存的密钥（先找这张卡片的，再找包级的）——只针对发往已授权**互联网**主机的请求，
永远不会针对本机（`network.local`）。你的卡片永远看不到这个值。
占位符只在请求头的值中有效——不能用于 URL 或请求体，因为它们会出现在服务器日志中——而且如果重定向指向另一个主机，带密钥的请求头会被**丢弃**。
引用用户尚未设置的密钥会以 `INVALID_PARAMS` 失败。

## 流式响应

对于服务器发送事件（SSE）、NDJSON 或大文件下载，请请求流式响应。收到响应头后 Promise 就会返回；正文以分块形式到达（每块 ≤ 64 KB）：

```ts
const controller = new AbortController()
const stream = await card.net.fetch('https://api.example.com/v1/events', {
  headers: { accept: 'text/event-stream' },
  responseType: 'stream',
  signal: controller.signal
})
for await (const line of stream.lines()) {
  if (line.startsWith('data: ')) render(JSON.parse(line.slice(6)))
}
// controller.abort() ends it early.
```

`chunks()` 对文本分块返回字符串，对二进制分块返回 `Uint8Array`；`lines()` 按换行拆分；`for await (const chunk of response)` 也可以用。
流失败时会抛出 `NETWORK_ERROR`。同时最多打开 4 个流；流在 5 分钟没有数据或达到 256 MB 后会被关闭。第 1 版不支持 WebSocket。

## 代理会强制执行的规则

1. **协议。** 只允许 `https`，唯一例外是在有 `network.local` 时对本机使用 `http`。URL 中不能带用户名或密码；`#fragment` 不会被发送。
2. **主机。** 严格匹配你声明的模式（`api.example.com`、`*.example.com`、`host.example.com:8443`）。其他任何主机都会以 `HOST_NOT_ALLOWED` 失败。
3. **地址。** 应用自己解析域名；对于公网主机授权，每个地址都必须是公网地址——私有网络、回环、链路本地和云元数据地址都会被拒绝，
并且请求会发往经过检查的那个地址（不会发生 DNS 重绑定）。
4. **请求头。** 你不能设置 `host`、`cookie`、`origin`、`referer`、`user-agent`、`content-length`、`connection`、`proxy-*`、`sec-*`、
`x-forwarded-*` 等。应用会发送 `User-Agent: NeuroSquad-Card/<app version> (<your card name>)`。Cookie 永远不会被保存或发送，
`set-cookie` 也永远不会返回给你。
5. **重定向。** 由应用跟随，最多 5 次，每一跳都会按规则 1–4 重新检查。
6. **限制。** 请求体 ≤ 5 MB；响应 ≤ 10 MB（更长的 text 或 base64 正文会被截断并带有 `truncated: true`；更长的 JSON 正文以 `TOO_LARGE` 失败）；
同时 6 个请求；每分钟 120 个；超时最长 120 秒——而且超时涵盖读取整个正文，而不只是响应头。

错误：`HOST_NOT_ALLOWED`（主机或地址未授权）、`NETWORK_ERROR`（DNS、连接、TLS）、`TIMEOUT`、`TOO_LARGE`、`RATE_LIMITED`、
`PERMISSION_DENIED`（完全没有网络权限）。HTTP 错误状态码**不是**异常——请检查 `res.ok`。

> 安装对话框会告诉用户：你的卡片能看到的任何内容都可能被发送到你列出的主机。请让这个列表简短而具体：
> 通配符或通用主机（粘贴板服务、短链接服务）会让谨慎的用户拒绝安装。
