Skip to Content

Сеть

Страница карточки вообще не может выйти в сеть — её песочница блокирует любой запрос, картинку или шрифт не из пакета. Вместо этого 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, но с несколькими отличиями:

ПараметрЗначение
methodGET (по умолчанию), HEAD, POST, PUT, PATCH, DELETE. Тело без метода означает POST.
headersОбъект или пары [name, value]. Может содержать {{secret:<key>}}.
bodyСтрока (отправляется как есть), байты (Uint8Array, ArrayBuffer) или любое другое значение JSON (сериализуется, с content-type: application/json, если вы не задали свой). До 5 МБ.
responseTypetext (по умолчанию), json (разбирает приложение), base64 (двоичные данные) или stream.
timeoutMs1 000–120 000, по умолчанию 30 000.
signalAbortSignal; отмена прерывает запрос в приложении.

Результат — 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 не поддерживается.

Что проверяет прокси

  1. Схема. Только https, кроме http к этому компьютеру с network.local. Никаких имени пользователя и пароля в URL; #fragment не отправляется.
  2. Хост. Ровно ваши объявленные шаблоны (api.example.com, *.example.com, host.example.com:8443). Всё остальное падает с HOST_NOT_ALLOWED.
  3. Адрес. Приложение само резолвит имя; при выдаче на публичный хост каждый адрес должен быть публичным — частные сети, loopback, link-local и адреса облачных метаданных отклоняются, а запрос идёт на тот адрес, который проверили (никакого DNS rebinding).
  4. Заголовки. Нельзя задавать 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. Редиректы. Их выполняет приложение, не больше 5, и каждый шаг заново проверяется правилами 1–4.
  6. Лимиты. Тело запроса до 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.

Диалог установки говорит пользователям, что всё, что видит ваша карточка, может уйти на перечисленные вами хосты. Держите список коротким и конкретным: маска или хост общего назначения (сервис для вставки текста, сокращатель ссылок) заставит осторожного пользователя отказаться.