Open History to see the last commit
1import { badRequest, tooLarge } from './errors.ts';
2import type { Limits } from './limits.ts';
3
4export interface FetchRequest {
5 key: Uint8Array;
6 secondary_key: Uint8Array;
7}
8
9export interface StoreMetadata extends FetchRequest {
10 value: Uint8Array;
11}
12
13// This codec only decodes the protocol envelope. Opaque bytes are never decoded.
14// The accepted containers are one map and (possibly chunked) strings: depth <= 2.
15class Decoder {
16 offset = 0;
17 constructor(private data: Uint8Array) {}
18 byte(): number {
19 return this.data[this.offset++] ?? badRequest();
20 }
21 head(major: number): number | null {
22 const first = this.byte();
23 if (first >> 5 !== major) badRequest();
24 const info = first & 31;
25 if (info < 24) return info;
26 if (info === 31) return null;
27 if (info > 27) badRequest();
28 let length = 0;
29 for (let i = 0; i < 2 ** (info - 24); i++) length = length * 256 + this.byte();
30 if (!Number.isSafeInteger(length)) tooLarge();
31 return length;
32 }
33 string(major: number, limit: number): Uint8Array {
34 const length = this.head(major);
35 if (length !== null) return this.take(length, limit);
36 const chunks: Uint8Array[] = [];
37 let size = 0;
38 while (this.data[this.offset] !== 255) {
39 const chunkLength = this.head(major);
40 if (chunkLength === null) badRequest();
41 size += chunkLength;
42 if (size > limit) tooLarge();
43 chunks.push(this.take(chunkLength, limit));
44 // Bound bookkeeping even for a malicious sequence of empty chunks.
45 if (chunks.length > 4096) tooLarge();
46 }
47 this.offset++;
48 return join(chunks, size);
49 }
50 take(size: number, limit: number): Uint8Array {
51 if (size > limit) tooLarge();
52 if (size > this.data.length - this.offset) badRequest();
53 const value = this.data.subarray(this.offset, this.offset + size);
54 this.offset += size;
55 return value;
56 }
57 finished(): boolean {
58 return this.offset === this.data.length;
59 }
60 break(): boolean {
61 if (this.data[this.offset] !== 255) return false;
62 this.offset++;
63 return true;
64 }
65}
66
67export function join(chunks: Uint8Array[], size: number): Uint8Array<ArrayBuffer> {
68 const data = new Uint8Array(size);
69 let offset = 0;
70 for (const chunk of chunks) {
71 data.set(chunk, offset);
72 offset += chunk.length;
73 }
74 return data;
75}
76
77export function decodeEnvelope(data: Uint8Array, store: false, limits: Limits): FetchRequest;
78export function decodeEnvelope(data: Uint8Array, store: true, limits: Limits): StoreMetadata;
79export function decodeEnvelope(
80 data: Uint8Array,
81 store: boolean,
82 limits: Limits,
83): FetchRequest | StoreMetadata {
84 const decoder = new Decoder(data);
85 const count = decoder.head(5);
86 const expected = store ? 3 : 2;
87 if (count !== null && count !== expected) badRequest();
88 const fields = new Map<string, Uint8Array>();
89 for (let i = 0; i < expected; i++) {
90 let name: string;
91 try {
92 name = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(
93 decoder.string(3, 32),
94 );
95 } catch {
96 badRequest();
97 }
98 const allowed = name === 'key' || name === 'secondary_key' || (store && name === 'value');
99 if (!allowed || fields.has(name)) badRequest();
100 fields.set(name, decoder.string(2, name === 'value' ? limits.value : limits.key));
101 }
102 if ((count === null && !decoder.break()) || !decoder.finished()) badRequest();
103 return {
104 key: fields.get('key')!,
105 secondary_key: fields.get('secondary_key')!,
106 ...(store ? { value: fields.get('value')! } : {}),
107 };
108}
109
110function header(major: number, length: number): Uint8Array {
111 if (length < 24) return Uint8Array.of((major << 5) | length);
112 if (length <= 255) return Uint8Array.of((major << 5) | 24, length);
113 if (length <= 65535) return Uint8Array.of((major << 5) | 25, length >> 8, length & 255);
114 return Uint8Array.of(
115 (major << 5) | 26,
116 length >>> 24,
117 (length >>> 16) & 255,
118 (length >>> 8) & 255,
119 length & 255,
120 );
121}
122
123export function encodeEnvelope(
124 fields: Record<string, string | Uint8Array | null>,
125): Uint8Array<ArrayBuffer> {
126 const chunks = [header(5, Object.keys(fields).length)];
127 const string = (value: string | Uint8Array) => {
128 const bytes = typeof value === 'string' ? new TextEncoder().encode(value) : value;
129 chunks.push(header(typeof value === 'string' ? 3 : 2, bytes.length), bytes);
130 };
131 for (const [key, value] of Object.entries(fields)) {
132 string(key);
133 if (value === null) chunks.push(Uint8Array.of(246));
134 else string(value);
135 }
136 return join(
137 chunks,
138 chunks.reduce((sum, chunk) => sum + chunk.length, 0),
139 );
140}
141
142export function cborResponse(fields: Record<string, string | Uint8Array | null>): Response {
143 const body = encodeEnvelope(fields);
144 return new Response(body, {
145 headers: {
146 'Content-Type': 'application/cbor',
147 'Content-Length': String(body.length),
148 'Cache-Control': 'no-store',
149 },
150 });
151}