From 3f6debc18b4176d2cd409eb3c825131c6475bf1b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 28 Sep 2026 00:00:08 +0400 Subject: [PATCH] feat(core): HttpClient port and byte-exact multipart builder Refs #5 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa --- src/core/multipart.ts | 38 ++++++++++++++++++++++ src/core/ports.ts | 20 ++++++++++++ tests/fakes.ts | 41 ++++++++++++++++++++++++ tests/multipart.test.ts | 71 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 170 insertions(+) create mode 100644 src/core/multipart.ts create mode 100644 tests/multipart.test.ts diff --git a/src/core/multipart.ts b/src/core/multipart.ts new file mode 100644 index 0000000..178451e --- /dev/null +++ b/src/core/multipart.ts @@ -0,0 +1,38 @@ +// Побайтная сборка multipart/form-data: у requestUrl нет FormData. +// Только Web API (TextEncoder, Uint8Array) — src/core работает и на телефоне. + +export interface MultipartFile { + name: string; + filename: string; + contentType: string; + data: ArrayBuffer; +} + +export function makeBoundary(random: () => number): string { + let hex = ''; + for (let i = 0; i < 24; i++) hex += Math.min(15, Math.floor(random() * 16)).toString(16); + return `----voice-note-${hex}`; +} + +export function buildMultipart( + fields: readonly (readonly [string, string])[], + file: MultipartFile, + boundary: string, +): { body: ArrayBuffer; contentType: string } { + const enc = new TextEncoder(); + let head = ''; + for (const [name, value] of fields) { + head += `--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`; + } + head += + `--${boundary}\r\nContent-Disposition: form-data; name="${file.name}"; filename="${file.filename}"\r\n` + + `Content-Type: ${file.contentType}\r\n\r\n`; + const start = enc.encode(head); + const data = new Uint8Array(file.data); + const end = enc.encode(`\r\n--${boundary}--\r\n`); + const out = new Uint8Array(start.length + data.length + end.length); + out.set(start, 0); + out.set(data, start.length); + out.set(end, start.length + data.length); + return { body: out.buffer, contentType: `multipart/form-data; boundary=${boundary}` }; +} diff --git a/src/core/ports.ts b/src/core/ports.ts index 478f157..f8af15c 100644 --- a/src/core/ports.ts +++ b/src/core/ports.ts @@ -36,3 +36,23 @@ export interface Env { random(): number; readonly deviceId: string; } + +export interface HttpRequest { + url: string; + headers: Record; + // Тип тела передаётся отдельно от headers: адаптер requestUrl кладёт его в + // параметр contentType, иначе boundary может потеряться. + contentType: string; + body: ArrayBuffer; +} + +export interface HttpResponse { + status: number; + text: string; +} + +// Любой HTTP-статус — ответ, не исключение. Исключение — только сетевой сбой. +// Сигнала и таймаута нет: requestUrl их не умеет, это делает провайдер в core. +export interface HttpClient { + post(req: HttpRequest): Promise; +} diff --git a/tests/fakes.ts b/tests/fakes.ts index acbed02..efed3cd 100644 --- a/tests/fakes.ts +++ b/tests/fakes.ts @@ -1,6 +1,9 @@ import { readVoiceFields } from '../src/core/note'; import type { Env, + HttpClient, + HttpRequest, + HttpResponse, ListedNote, NoteStore, RecordingStore, @@ -88,3 +91,41 @@ export class TestEnv implements Env { this.time = new Date(this.time.getTime() + ms); } } + +export class FakeHttp implements HttpClient { + requests: HttpRequest[] = []; + + constructor( + public reply: (req: HttpRequest) => HttpResponse | Promise = () => ({ + status: 200, + text: '{"text":"ok"}', + }), + ) {} + + // Не async: если reply бросает синхронно, post тоже бросает синхронно. + post(req: HttpRequest): Promise { + this.requests.push(req); + return Promise.resolve(this.reply(req)); + } +} + +// Разбор multipart для проверок: обычное поле → значение, файл → "filename|Content-Type". +export function formParts(req: HttpRequest): Record { + const match = /boundary=(.+)$/.exec(req.contentType); + if (!match) throw new Error(`no boundary in ${req.contentType}`); + const text = new TextDecoder().decode(req.body); + const parts: Record = {}; + for (const part of text.split(`--${match[1]}`)) { + const m = /name="([^"]+)"(?:; filename="([^"]+)")?\r\n(?:Content-Type: ([^\r]+)\r\n)?\r\n([\s\S]*)\r\n$/.exec(part); + if (m) parts[m[1]] = m[2] ? `${m[2]}|${m[3]}` : m[4]; + } + return parts; +} + +export function bytesIndexOf(hay: Uint8Array, needle: Uint8Array): number { + outer: for (let i = 0; i + needle.length <= hay.length; i++) { + for (let j = 0; j < needle.length; j++) if (hay[i + j] !== needle[j]) continue outer; + return i; + } + return -1; +} diff --git a/tests/multipart.test.ts b/tests/multipart.test.ts new file mode 100644 index 0000000..9ab9609 --- /dev/null +++ b/tests/multipart.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { buildMultipart, makeBoundary } from '../src/core/multipart'; +import { bytesIndexOf } from './fakes'; + +const enc = new TextEncoder(); + +function concat(...parts: Uint8Array[]): Uint8Array { + const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); + let at = 0; + for (const p of parts) { + out.set(p, at); + at += p.length; + } + return out; +} + +describe('makeBoundary', () => { + it('builds 24 hex chars from random()', () => { + expect(makeBoundary(() => 0)).toBe(`----voice-note-${'0'.repeat(24)}`); + expect(makeBoundary(() => 0.99)).toBe(`----voice-note-${'f'.repeat(24)}`); + }); + + it('clamps random() === 1 to f', () => { + expect(makeBoundary(() => 1)).toBe(`----voice-note-${'f'.repeat(24)}`); + }); +}); + +describe('buildMultipart', () => { + // Байты, которые сломались бы при прогоне через строку: 0x00, >0x7F, CRLF, "--". + const audio = new Uint8Array([0x00, 0xff, 0x80, 0x0d, 0x0a, 0x2d, 0x2d]).buffer; + const file = { name: 'file', filename: 'audio.m4a', contentType: 'audio/mp4', data: audio }; + + it('builds exact bytes with the boundary from contentType', () => { + const { body, contentType } = buildMultipart( + [ + ['model', 'm1'], + ['response_format', 'json'], + ], + file, + 'BND', + ); + expect(contentType).toBe('multipart/form-data; boundary=BND'); + const expected = concat( + enc.encode( + '--BND\r\nContent-Disposition: form-data; name="model"\r\n\r\nm1\r\n' + + '--BND\r\nContent-Disposition: form-data; name="response_format"\r\n\r\njson\r\n' + + '--BND\r\nContent-Disposition: form-data; name="file"; filename="audio.m4a"\r\nContent-Type: audio/mp4\r\n\r\n', + ), + new Uint8Array(audio), + enc.encode('\r\n--BND--\r\n'), + ); + expect(new Uint8Array(body)).toEqual(expected); + }); + + it('keeps binary audio bytes unchanged', () => { + const { body } = buildMultipart([], file, 'BND'); + expect(bytesIndexOf(new Uint8Array(body), new Uint8Array(audio))).toBeGreaterThan(0); + }); + + it('encodes non-ASCII field values as UTF-8', () => { + const { body } = buildMultipart([['language', 'ру']], file, 'BND'); + expect(bytesIndexOf(new Uint8Array(body), enc.encode('\r\n\r\nру\r\n'))).toBeGreaterThan(0); + }); + + it('does not share memory with the input audio', () => { + const data = new Uint8Array([1, 2, 3]); + const { body } = buildMultipart([], { ...file, data: data.buffer }, 'BND'); + data[0] = 9; + expect(bytesIndexOf(new Uint8Array(body), new Uint8Array([1, 2, 3]))).toBeGreaterThan(0); + }); +});