Skip to content
File

Blob: src/cloudflare/internal/streaming-base64.ts

typescript196 lines
1// Copyright (c) 2026 Cloudflare, Inc.
2// Licensed under the Apache 2.0 license found in the LICENSE file or at:
3// https://opensource.org/licenses/Apache-2.0
4 
5import base64 from 'cloudflare-internal:base64';
6 
7function base64Error(cause: unknown): Error {
8 if (Error.isError(cause)) {
9 return new Error(`base64 error: ${cause.message}`, { cause });
10 } else {
11 return new Error('unknown base64 error');
12 }
13}
14 
15function toBase64(input: Uint8Array): Uint8Array {
16 return new Uint8Array(base64.encodeArray(input));
17}
18 
19function fromBase64(input: Uint8Array): Uint8Array {
20 return new Uint8Array(base64.decodeArray(input));
21}
22 
23function combineArrays(a: Uint8Array, b: Uint8Array): Uint8Array {
24 const combined = new Uint8Array(a.length + b.length);
25 combined.set(a, 0);
26 combined.set(b, a.length);
27 return combined;
28}
29 
30function buildChunkFromLeftover(
31 leftover: Uint8Array,
32 chunk: Uint8Array,
33 minimumChunkSize: number
34): { firstChunk: Uint8Array; remainder: Uint8Array } {
35 const offset = minimumChunkSize - leftover.length;
36 const firstChunk = new Uint8Array(minimumChunkSize);
37 firstChunk.set(leftover, 0);
38 firstChunk.set(chunk.subarray(0, offset), leftover.length);
39 return { firstChunk, remainder: chunk.subarray(offset) };
40}
41 
42function getProcessableChunk(
43 chunk: Uint8Array,
44 segmentSize: number
45): { processable: Uint8Array; leftover: Uint8Array | null } {
46 const processableLength =
47 Math.trunc(chunk.length / segmentSize) * segmentSize;
48 
49 const processable =
50 processableLength > 0
51 ? chunk.subarray(0, processableLength)
52 : new Uint8Array();
53 const leftover =
54 processableLength !== chunk.length
55 ? chunk.subarray(processableLength)
56 : null;
57 
58 return { processable, leftover };
59}
60 
61const PADDING_CHAR_CODE = 61; // '='
62function isPaddedBase64Chunk(chunk: Uint8Array): boolean {
63 return chunk.length > 0 && chunk[chunk.length - 1] === PADDING_CHAR_CODE;
64}
65 
66export function createBase64EncoderTransformStream(
67 maxEncodeChunkSize: number = 32 * 1024 + 1
68): TransformStream<Uint8Array, Uint8Array> {
69 let leftover: Uint8Array | null = null;
70 
71 if (maxEncodeChunkSize % 3 != 0) {
72 // Try to minimize padding
73 throw new Error('maxChunkSize must be a multiple of 3');
74 }
75 
76 return new TransformStream<Uint8Array, Uint8Array>({
77 transform(chunk, controller): void {
78 if (leftover != null) {
79 const requiredBytes = 3 - leftover.length;
80 
81 if (chunk.length < requiredBytes) {
82 // We don't have enough bytes in the chunk to encode, update leftovers
83 leftover = combineArrays(leftover, chunk);
84 // encode when we get more chars
85 return;
86 }
87 
88 const { firstChunk, remainder } = buildChunkFromLeftover(
89 leftover,
90 chunk,
91 3
92 );
93 controller.enqueue(toBase64(firstChunk));
94 leftover = null;
95 chunk = remainder;
96 }
97 
98 while (chunk.length >= maxEncodeChunkSize) {
99 controller.enqueue(toBase64(chunk.subarray(0, maxEncodeChunkSize)));
100 chunk = chunk.subarray(maxEncodeChunkSize);
101 }
102 
103 // Encode what's encodable in what's left of the chunk
104 const { processable, leftover: nextLeftover } = getProcessableChunk(
105 chunk,
106 3
107 );
108 
109 if (processable.length > 0) {
110 controller.enqueue(toBase64(processable));
111 }
112 
113 leftover = nextLeftover;
114 },
115 
116 flush(controller): void {
117 if (leftover != null) {
118 controller.enqueue(toBase64(leftover));
119 }
120 },
121 });
122}
123 
124export function createBase64DecoderTransformStream(
125 maxChunkSize: number = 32 * 1024
126): TransformStream<Uint8Array, Uint8Array> {
127 if (maxChunkSize % 4 !== 0 || maxChunkSize <= 0) {
128 throw new Error('maxChunkSize must be a positive multiple of 4.');
129 }
130 
131 let leftover: Uint8Array | null = null;
132 let paddingSeen = false;
133 
134 return new TransformStream<Uint8Array, Uint8Array>({
135 transform(chunk, controller): void {
136 try {
137 // If we see a chunk with padding, we aren't allowed to see any more data, as padding is allowed only at the end
138 if (paddingSeen && chunk.length > 0) {
139 throw new Error('Padding already seen, no further chunks allowed');
140 } else if (isPaddedBase64Chunk(chunk)) {
141 paddingSeen = true;
142 }
143 
144 if (leftover != null) {
145 // We have leftovers - decode 4 bytes consisting of the leftovers + part of the chunk
146 const requiredBytes = 4 - leftover.length;
147 
148 if (chunk.length < requiredBytes) {
149 // We don't have enough bytes in the chunk to decode, update leftovers
150 leftover = combineArrays(leftover, chunk);
151 // Decode when we get more chars
152 return;
153 }
154 
155 const { firstChunk, remainder } = buildChunkFromLeftover(
156 leftover,
157 chunk,
158 4
159 );
160 controller.enqueue(fromBase64(firstChunk));
161 leftover = null;
162 chunk = remainder;
163 }
164 
165 while (chunk.length >= maxChunkSize) {
166 controller.enqueue(fromBase64(chunk.subarray(0, maxChunkSize)));
167 chunk = chunk.subarray(maxChunkSize);
168 }
169 
170 // Decode what's decodable in what's left of the chunk
171 const { processable, leftover: nextLeftover } = getProcessableChunk(
172 chunk,
173 4
174 );
175 
176 if (processable.length > 0) {
177 controller.enqueue(fromBase64(processable));
178 }
179 
180 leftover = nextLeftover;
181 } catch (e) {
182 controller.error(base64Error(e));
183 }
184 },
185 
186 flush(controller): void {
187 if (leftover != null) {
188 // We have leftovers, but they aren't decodable
189 controller.error(
190 base64Error(new Error('Bytes left over when flushing controller'))
191 );
192 }
193 },
194 });
195}