| 1 | /** |
| 2 | * Request-body gunzip (DESIGN §4, §7). |
| 3 | * |
| 4 | * git's remote-curl **gzips POST bodies** (`Content-Encoding: gzip`) whenever |
| 5 | * the buffered body is under `http.postBuffer` (default 1 MiB) — i.e. |
| 6 | * essentially every fetch negotiation request. Workers do not auto-decompress |
| 7 | * request bodies, so the server must branch on `Content-Encoding: gzip` and |
| 8 | * pipe through `DecompressionStream("gzip")`, sniffing the `1f 8b` magic |
| 9 | * defensively (a raw pkt-line body always starts with an ASCII hex digit, so |
| 10 | * the sniff can never false-positive). |
| 11 | * |
| 12 | * This module is the only place `DecompressionStream` is used; the pack-entry |
| 13 | * zlib boundary lives in `Zlib.ts`. |
| 14 | */ |
| 15 | import * as Effect from "effect/Effect"; |
| 16 | import * as Pull from "effect/Pull"; |
| 17 | import * as Schema from "effect/Schema"; |
| 18 | import * as Stream from "effect/Stream"; |
| 19 | import { concatBytes } from "./ObjectCodec.ts"; |
| 20 | |
| 21 | /** |
| 22 | * Error raised when a request body declared (or sniffed) as gzip fails to |
| 23 | * decompress, or when the upstream body stream fails while being piped |
| 24 | * through the decompressor. |
| 25 | */ |
| 26 | export class GzipBodyError extends Schema.TaggedError<GzipBodyError>()( |
| 27 | "GzipBodyError", |
| 28 | { reason: Schema.String }, |
| 29 | ) {} |
| 30 | |
| 31 | /** |
| 32 | * Returns `true` when the bytes start with the gzip magic `1f 8b`. |
| 33 | */ |
| 34 | export const sniffGzip = (bytes: Uint8Array): boolean => |
| 35 | bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b; |
| 36 | |
| 37 | /** |
| 38 | * Returns `true` when a `Content-Encoding` header value declares gzip |
| 39 | * (handles `gzip`, `x-gzip`, and comma-separated lists). |
| 40 | */ |
| 41 | export const isGzipContentEncoding = (value: string | undefined): boolean => |
| 42 | value !== undefined && |
| 43 | value.split(",").some((token) => { |
| 44 | const t = token.trim().toLowerCase(); |
| 45 | return t === "gzip" || t === "x-gzip"; |
| 46 | }); |
| 47 | |
| 48 | const errorReason = (error: unknown): string => |
| 49 | error instanceof Error ? error.message : String(error); |
| 50 | |
| 51 | /** |
| 52 | * Pipes a byte stream through `DecompressionStream("gzip")`. |
| 53 | * |
| 54 | * Upstream failures propagate through the web-stream pipe and surface as |
| 55 | * {@link GzipBodyError} (the typed upstream error is flattened — acceptable |
| 56 | * because the caller treats any body failure the same way). |
| 57 | */ |
| 58 | export const gunzipStream = <E>( |
| 59 | input: Stream.Stream<Uint8Array, E>, |
| 60 | ): Stream.Stream<Uint8Array, GzipBodyError> => |
| 61 | Stream.fromReadableStream({ |
| 62 | evaluate: () => |
| 63 | Stream.toReadableStream(input).pipeThrough( |
| 64 | // dom.d.ts types DecompressionStream's writable as BufferSource; the |
| 65 | // runtime accepts Uint8Array chunks — bridge the variance mismatch. |
| 66 | new DecompressionStream("gzip") as unknown as ReadableWritablePair< |
| 67 | Uint8Array, |
| 68 | Uint8Array |
| 69 | >, |
| 70 | ) as ReadableStream<Uint8Array>, |
| 71 | onError: (error) => |
| 72 | new GzipBodyError({ reason: `gunzip failed: ${errorReason(error)}` }), |
| 73 | }); |
| 74 | |
| 75 | /** |
| 76 | * One-shot gunzip of a fully buffered body. |
| 77 | */ |
| 78 | export const gunzipBuffer = ( |
| 79 | data: Uint8Array, |
| 80 | ): Effect.Effect<Uint8Array, GzipBodyError> => |
| 81 | gunzipStream(Stream.succeed(data)).pipe( |
| 82 | Stream.runCollect, |
| 83 | Effect.map((chunks) => concatBytes(chunks)), |
| 84 | ); |
| 85 | |
| 86 | /** |
| 87 | * Decodes a fully buffered git request body: gunzips when `Content-Encoding` |
| 88 | * says gzip **or** the body carries the `1f 8b` magic (defensive sniff — |
| 89 | * pkt-line bodies always start with an ASCII hex digit, so this can never |
| 90 | * misfire); otherwise returns the bytes unchanged. |
| 91 | */ |
| 92 | export const decodeGitBodyBuffer = ( |
| 93 | data: Uint8Array, |
| 94 | contentEncoding: string | undefined, |
| 95 | ): Effect.Effect<Uint8Array, GzipBodyError> => |
| 96 | isGzipContentEncoding(contentEncoding) || sniffGzip(data) |
| 97 | ? gunzipBuffer(data) |
| 98 | : Effect.succeed(data); |
| 99 | |
| 100 | /** |
| 101 | * Decodes a streaming git request body: when `Content-Encoding` declares |
| 102 | * gzip the whole stream pipes through the decompressor; otherwise the first |
| 103 | * bytes are peeked and the `1f 8b` sniff decides. Pass-through preserves the |
| 104 | * upstream error type; the gunzip path surfaces failures as |
| 105 | * {@link GzipBodyError}. |
| 106 | */ |
| 107 | export const decodeGitBodyStream = <E>( |
| 108 | input: Stream.Stream<Uint8Array, E>, |
| 109 | contentEncoding: string | undefined, |
| 110 | ): Stream.Stream<Uint8Array, E | GzipBodyError> => { |
| 111 | if (isGzipContentEncoding(contentEncoding)) { |
| 112 | return gunzipStream(input); |
| 113 | } |
| 114 | return Stream.unwrap( |
| 115 | Effect.gen(function* () { |
| 116 | const pull = yield* Stream.toPull(input); |
| 117 | const head: Array<Uint8Array> = []; |
| 118 | let length = 0; |
| 119 | let ended = false; |
| 120 | while (length < 2 && !ended) { |
| 121 | yield* pull.pipe( |
| 122 | Effect.map((chunks) => { |
| 123 | for (const chunk of chunks) { |
| 124 | head.push(chunk); |
| 125 | length += chunk.length; |
| 126 | } |
| 127 | }), |
| 128 | Pull.catchDone(() => |
| 129 | Effect.sync(() => { |
| 130 | ended = true; |
| 131 | }), |
| 132 | ), |
| 133 | ); |
| 134 | } |
| 135 | const headBytes = concatBytes(head, length); |
| 136 | const remainder: Stream.Stream<Uint8Array, E> = ended |
| 137 | ? Stream.empty |
| 138 | : // `toPull`'s error channel is already Done-free, so ExcludeDone<E> = E; |
| 139 | // TS cannot reduce the conditional for a generic E, hence the cast. |
| 140 | (Stream.fromPull(Effect.succeed(pull)) as Stream.Stream< |
| 141 | Uint8Array, |
| 142 | E |
| 143 | >); |
| 144 | const full: Stream.Stream<Uint8Array, E> = |
| 145 | length === 0 |
| 146 | ? remainder |
| 147 | : Stream.concat(Stream.succeed(headBytes), remainder); |
| 148 | return sniffGzip(headBytes) |
| 149 | ? (gunzipStream(full) as Stream.Stream<Uint8Array, E | GzipBodyError>) |
| 150 | : full; |
| 151 | }), |
| 152 | ); |
| 153 | }; |