import { getSim, sim, SimConfig } from '@dooboostore/simple-boot';
import { HttpHeaders } from '../codes/HttpHeaders';
import { ApiService } from '@dooboostore/simple-boot';
import { ConstructorType, RandomUtils } from '@dooboostore/core';
import { HttpFetcherTarget } from '@dooboostore/core';
export type TopicProtocolConfig
= {
bypassTransform?: boolean;
transformText?: boolean;
headers?: HeadersInit;
body?: TBody;
endpoint?: HttpFetcherTarget;
type?: TopicProtocolType;
config?: ApiService.ApiServiceConfig;
};
type TopicProtocolType = 'intent' | 'router';
type TopicProtocolRequest = {
type: TopicProtocolType;
target: string;
requestUUID: string;
body?: TBody;
files?: Array<{ id: string }>;
};
type FileEntry = { id: string; file: Blob; filename?: string };
type MixedPart = { headers: Record; body: Uint8Array };
// ── Helpers (module-level, mirrors SymbolIntentApiServiceProxy pattern) ──────
const isFileValue = (value: unknown): value is File | Blob | ArrayBuffer | Uint8Array => {
if (typeof File !== 'undefined' && value instanceof File) return true;
if (typeof Blob !== 'undefined' && value instanceof Blob) return true;
if (typeof ArrayBuffer !== 'undefined' && value instanceof ArrayBuffer) return true;
if (typeof Uint8Array !== 'undefined' && value instanceof Uint8Array) return true;
return false;
};
const toFileEntry = (value: File | Blob | ArrayBuffer | Uint8Array, id: string): FileEntry | undefined => {
if (typeof Blob === 'undefined') return undefined;
if (typeof File !== 'undefined' && value instanceof File) {
return { id, file: new Blob([value], { type: value.type }), filename: value.name };
}
if (value instanceof Blob) return { id, file: value };
const safe = value instanceof Uint8Array ? new Uint8Array(value) : new Uint8Array(value);
return { id, file: new Blob([safe]) };
};
const buildRequest = (target: string, body?: TBody, type: TopicProtocolType = 'intent') => {
const fileEntries: FileEntry[] = [];
let fileIndex = 0;
const registerFile = (value: File | Blob | ArrayBuffer | Uint8Array): string | undefined => {
const id = `f${++fileIndex}`;
const entry = toFileEntry(value, id);
if (entry) { fileEntries.push(entry); return id; }
return undefined;
};
const replaceFiles = (value: unknown): unknown => {
if (isFileValue(value)) {
const id = registerFile(value);
return id ? { $file: id } : value;
}
if (Array.isArray(value)) return value.map(item => replaceFiles(item));
if (value && typeof value === 'object') {
const result: Record = {};
Object.keys(value).forEach(key => {
result[key] = replaceFiles((value as Record)[key]);
});
return result;
}
return value;
};
const payload = replaceFiles(body);
const request: TopicProtocolRequest = {
type,
target,
requestUUID: RandomUtils.uuid4(),
body: payload as TBody,
files: fileEntries.length > 0 ? fileEntries.map(e => ({ id: e.id })) : undefined,
};
return { request, fileEntries };
};
const parseDispositionName = (disposition?: string) => {
const match = disposition?.match(/name=\"?([^\";]+)\"?/i);
return match ? match[1] : undefined;
};
const parseDispositionFilename = (disposition?: string) => {
const match = disposition?.match(/filename=\"?([^\";]+)\"?/i);
return match ? match[1] : undefined;
};
const parseMultipartMixed = async (response: Response) => {
const contentType = response.headers.get('Content-Type') ?? '';
const boundaryMatch = contentType.match(/boundary=([^;]+)/i);
if (!boundaryMatch) return undefined;
const boundary = boundaryMatch[1].replace(/^"|"$/g, '');
const buffer = new Uint8Array(await response.arrayBuffer());
const boundaryBytes = new TextEncoder().encode(`--${boundary}`);
const endBoundaryBytes = new TextEncoder().encode(`--${boundary}--`);
const separator = new TextEncoder().encode('\r\n\r\n');
const indexOf = (source: Uint8Array, needle: Uint8Array, from = 0) => {
for (let i = from; i <= source.length - needle.length; i++) {
let matched = true;
for (let j = 0; j < needle.length; j++) {
if (source[i + j] !== needle[j]) { matched = false; break; }
}
if (matched) return i;
}
return -1;
};
const parts: MixedPart[] = [];
let cursor = indexOf(buffer, boundaryBytes, 0);
while (cursor !== -1) {
const nextBoundary = indexOf(buffer, boundaryBytes, cursor + boundaryBytes.length);
const endBoundary = indexOf(buffer, endBoundaryBytes, cursor + boundaryBytes.length);
const isFinal = endBoundary !== -1 && (endBoundary < nextBoundary || nextBoundary === -1);
const partStart = cursor + boundaryBytes.length;
if (isFinal) break;
let headerStart = partStart;
if (buffer[headerStart] === 13 && buffer[headerStart + 1] === 10) headerStart += 2;
const headerEnd = indexOf(buffer, separator, headerStart);
if (headerEnd === -1) break;
const headerText = new TextDecoder().decode(buffer.slice(headerStart, headerEnd));
const headers: Record = {};
headerText.split('\r\n').forEach(line => {
const idx = line.indexOf(':');
if (idx > -1) headers[line.slice(0, idx).trim().toLowerCase()] = line.slice(idx + 1).trim();
});
const bodyStart = headerEnd + separator.length;
const bodyEnd = nextBoundary === -1 ? buffer.length : nextBoundary - 2;
parts.push({ headers, body: buffer.slice(bodyStart, bodyEnd) });
cursor = nextBoundary;
}
return { boundary, parts };
};
const replaceFileRefs = (
value: unknown,
fileMap: Map,
): unknown => {
if (value && typeof value === 'object' && '$file' in value) {
const fileKey = (value as { $file?: unknown }).$file;
if (typeof fileKey === 'string') {
const file = fileMap.get(fileKey);
if (file) return file;
}
}
if (Array.isArray(value)) return value.map(item => replaceFileRefs(item, fileMap));
if (value && typeof value === 'object') {
const result: Record = {};
Object.keys(value).forEach(key => {
result[key] = replaceFileRefs((value as Record)[key], fileMap);
});
return result;
}
return value;
};
const normalizeResponse = (value: unknown): unknown => {
if (value && typeof value === 'object' && 'state' in value && 'body' in value) {
const typed = value as { state?: string; body?: unknown };
if (typed.state === 'error') {
const message = typeof typed.body === 'object' && typed.body && 'message' in typed.body
? String((typed.body as { message?: unknown }).message)
: 'TopicProtocol error';
throw new Error(message);
}
return typed.body;
}
return value;
};
// ── createHandler (module-level export, same as SymbolIntentApiServiceProxy) ─
export const createTopicHandler = (apiService: ApiService): ProxyHandler => {
return {
get(target: T, prop: string | symbol, receiver: unknown): unknown {
if (prop === '_SimpleBoot_origin') return target;
const simConfig = getSim(target);
const value = Reflect.get(target, prop, receiver);
if (typeof value === 'function' && simConfig?.symbol) {
return function (...args: unknown[]) {
const f = value as Function;
const p = (userConfig?: TopicProtocolConfig) => {
const symbolValue = Array.isArray(simConfig.symbol) ? simConfig.symbol[0] : simConfig.symbol;
const symbolName = (symbolValue as Symbol | undefined)?.description ?? String(symbolValue ?? simConfig.symbol);
const targetPath = `Symbol.for(${symbolName}):/${String(prop)}`;
const { request, fileEntries } = buildRequest(targetPath, userConfig?.body, userConfig?.type ?? 'intent');
const formData = new FormData();
formData.append('body', JSON.stringify(request));
fileEntries.forEach(entry => {
if (entry.filename) formData.append(entry.id, entry.file, entry.filename);
else formData.append(entry.id, entry.file);
});
const headers: Record = {
...(userConfig?.headers as Record ?? {}),
[HttpHeaders.XSimpleBootHttpTopicProtocol]: 'topic',
};
const endpoint = userConfig?.endpoint ?? '/';
return (apiService as any).post({
target: endpoint,
config: {
responseTransform: userConfig?.bypassTransform
? 'response'
: userConfig?.transformText
? 'text'
: async (response: Response) => {
const ct = response.headers.get('Content-Type') ?? '';
if (ct.toLowerCase().includes('multipart/mixed')) {
const parsed = await parseMultipartMixed(response);
if (!parsed) return undefined;
const fileMap = new Map();
let jsonBody: unknown = undefined;
parsed.parts.forEach(part => {
const partType = part.headers['content-type'] ?? '';
const disposition = part.headers['content-disposition'];
const name = parseDispositionName(disposition);
if (partType.includes('application/json')) {
const text = new TextDecoder().decode(part.body);
jsonBody = text ? JSON.parse(text) : undefined;
return;
}
if (name) {
const filename = parseDispositionFilename(disposition);
const safe = new Uint8Array(part.body.byteLength);
safe.set(part.body);
fileMap.set(name, {
name: filename,
mime: partType || 'application/octet-stream',
size: part.body.byteLength,
buffer: safe.buffer,
});
}
});
if (jsonBody) return normalizeResponse(replaceFileRefs(jsonBody, fileMap));
return undefined;
}
const text = await response.text();
const parsed = text ? JSON.parse(text) : undefined;
return normalizeResponse(parsed);
},
transformText: userConfig?.transformText,
config: userConfig?.config,
fetch: {
credentials: 'include',
headers,
body: formData,
},
} as any,
});
};
args.push(p);
return Reflect.apply(f, target, args);
};
}
return Reflect.get(target, prop, receiver);
},
apply(target: T, thisArg: unknown, argArray: unknown[]): unknown {
console.log('TopicProtocolApiServiceProxy apply');
return Reflect.apply(target as any, thisArg, argArray as any[]);
},
};
};
// ── Default factory export (same shape as SymbolIntentApiServiceProxy) ───────
export default (config?: SimConfig): ConstructorType => {
@sim(config)
class TopicProtocolApiServiceProxy implements ProxyHandler {
constructor(private apiService: ApiService) {}
get(target: T, prop: string | symbol, receiver: unknown): unknown {
return createTopicHandler(this.apiService).get!(target, prop, receiver);
}
apply(target: T, thisArg: unknown, argArray: unknown[]): unknown {
return createTopicHandler(this.apiService).apply!(target, thisArg, argArray as any[]);
}
}
return TopicProtocolApiServiceProxy;
};