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:
parent
4059b72178
commit
3f6debc18b
38
src/core/multipart.ts
Normal file
38
src/core/multipart.ts
Normal 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}` };
|
||||
}
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
@ -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
71
tests/multipart.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user