Сеть
Страница карточки вообще не может выйти в сеть — её песочница блокирует любой запрос, картинку или
шрифт не из пакета. Вместо этого card.net.fetch идёт через прокси в приложении, который
пропускает только то, что разрешил пользователь:
networkсhosts: запросыhttpsк этим хостам;network.local:httpилиhttpsкlocalhost,127.0.0.1и::1— кроме собственных локальных серверов NeuroSquad (его MCP-сервера, удалённого доступа, подключения папок для разработчиков, портов отладки Chrome браузерных карточек): до них не достучаться.
Запрос
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>}}. |
body | Строка (отправляется как есть), байты (Uint8Array, ArrayBuffer) или любое другое значение JSON (сериализуется, с content-type: application/json, если вы не задали свой). До 5 МБ. |
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.
// 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' даёт null из res.json().
Секреты в заголовках
Никогда не кладите API-ключ в код или хранилище карточки. Объявите настройку
типа secret, пусть пользователь введёт ключ в форме настроек приложения, и сошлитесь на него в
значении заголовка:
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.
Потоковые ответы
Для server-sent events, NDJSON или долгих загрузок запросите поток. Промис разрешается, как только пришли заголовки; тело приходит кусками (до 64 КБ каждый):
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 МБ. WebSocket в версии 1 не поддерживается.
Что проверяет прокси
- Схема. Только
https, кромеhttpк этому компьютеру сnetwork.local. Никаких имени пользователя и пароля в URL;#fragmentне отправляется. - Хост. Ровно ваши объявленные шаблоны (
api.example.com,*.example.com,host.example.com:8443). Всё остальное падает сHOST_NOT_ALLOWED. - Адрес. Приложение само резолвит имя; при выдаче на публичный хост каждый адрес должен быть публичным — частные сети, loopback, link-local и адреса облачных метаданных отклоняются, а запрос идёт на тот адрес, который проверили (никакого DNS rebinding).
- Заголовки. Нельзя задавать
host,cookie,origin,referer,user-agent,content-length,connection,proxy-*,sec-*,x-forwarded-*и подобные. Приложение отправляетUser-Agent: NeuroSquad-Card/<app version> (<your card name>). Cookies никогда не сохраняются и не отправляются, аset-cookieвам никогда не возвращается. - Редиректы. Их выполняет приложение, не больше 5, и каждый шаг заново проверяется правилами 1–4.
- Лимиты. Тело запроса до 5 МБ; ответ до 10 МБ (более длинное тело 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.
Диалог установки говорит пользователям, что всё, что видит ваша карточка, может уйти на перечисленные вами хосты. Держите список коротким и конкретным: маска или хост общего назначения (сервис для вставки текста, сокращатель ссылок) заставит осторожного пользователя отказаться.