feat(core): HttpClient port and byte-exact multipart builder

Refs #5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa
This commit is contained in:
Andrey Epifancev 2026-09-28 00:00:08 +04:00
parent 4059b72178
commit 3f6debc18b
4 changed files with 170 additions and 0 deletions

38
src/core/multipart.ts Normal file
View File

@ -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}` };
}

View File

@ -36,3 +36,23 @@ export interface Env {
random(): number;
readonly deviceId: string;
}
export interface HttpRequest {
url: string;
headers: Record<string, string>;
// Тип тела передаётся отдельно от 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<HttpResponse>;
}

View File

@ -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<HttpResponse> = () => ({
status: 200,
text: '{"text":"ok"}',
}),
) {}
// Не async: если reply бросает синхронно, post тоже бросает синхронно.
post(req: HttpRequest): Promise<HttpResponse> {
this.requests.push(req);
return Promise.resolve(this.reply(req));
}
}
// Разбор multipart для проверок: обычное поле → значение, файл → "filename|Content-Type".
export function formParts(req: HttpRequest): Record<string, string> {
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<string, string> = {};
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;
}

71
tests/multipart.test.ts Normal file
View File

@ -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);
});
});