|
1 | | -import * as rscHtmlStreamServer from 'rsc-html-stream/server' |
| 1 | +// @ts-nocheck |
| 2 | + |
| 3 | +// import * as rscHtmlStreamServer from 'rsc-html-stream/server' |
| 4 | + |
| 5 | +// export const injectRscStreamToHtml = ( |
| 6 | +// stream: ReadableStream<Uint8Array>, |
| 7 | +// options?: { nonce?: string }, |
| 8 | +// ): TransformStream<Uint8Array, Uint8Array> => |
| 9 | +// rscHtmlStreamServer.injectRSCPayload(stream, options) |
2 | 10 |
|
3 | 11 | export const injectRscStreamToHtml = ( |
4 | 12 | stream: ReadableStream<Uint8Array>, |
5 | 13 | options?: { nonce?: string }, |
6 | | -): TransformStream<Uint8Array, Uint8Array> => |
7 | | - rscHtmlStreamServer.injectRSCPayload(stream, options) |
| 14 | +): TransformStream<Uint8Array, Uint8Array> => injectRSCPayload(stream, options) |
| 15 | + |
| 16 | +const encoder = new TextEncoder() |
| 17 | +const trailer = '</body></html>' |
| 18 | + |
| 19 | +function injectRSCPayload(rscStream, options) { |
| 20 | + let decoder = new TextDecoder() |
| 21 | + let resolveFlightDataPromise |
| 22 | + let flightDataPromise = new Promise( |
| 23 | + (resolve) => (resolveFlightDataPromise = resolve), |
| 24 | + ) |
| 25 | + let startedRSC = false |
| 26 | + let nonce = |
| 27 | + options && typeof options.nonce === 'string' ? options.nonce : undefined |
| 28 | + |
| 29 | + // Buffer all HTML chunks enqueued during the current tick of the event loop (roughly) |
| 30 | + // and write them to the output stream all at once. This ensures that we don't generate |
| 31 | + // invalid HTML by injecting RSC in between two partial chunks of HTML. |
| 32 | + let buffered = [] |
| 33 | + let timeout = null |
| 34 | + function flushBufferedChunks(controller) { |
| 35 | + console.log('[flushBufferedChunks]', buffered.length) |
| 36 | + for (let chunk of buffered) { |
| 37 | + let buf = decoder.decode(chunk, { stream: true }) |
| 38 | + if (buf.endsWith(trailer)) { |
| 39 | + buf = buf.slice(0, -trailer.length) |
| 40 | + } |
| 41 | + controller.enqueue(encoder.encode(buf)) |
| 42 | + } |
| 43 | + |
| 44 | + let remaining = decoder.decode() |
| 45 | + if (remaining.length) { |
| 46 | + if (remaining.endsWith(trailer)) { |
| 47 | + remaining = remaining.slice(0, -trailer.length) |
| 48 | + } |
| 49 | + controller.enqueue(encoder.encode(remaining)) |
| 50 | + } |
| 51 | + |
| 52 | + buffered.length = 0 |
| 53 | + timeout = null |
| 54 | + } |
| 55 | + |
| 56 | + return new TransformStream({ |
| 57 | + transform(chunk, controller) { |
| 58 | + console.log('[TransformStream.transform]') |
| 59 | + |
| 60 | + buffered.push(chunk) |
| 61 | + if (timeout) { |
| 62 | + return |
| 63 | + } |
| 64 | + |
| 65 | + timeout = setTimeout(async () => { |
| 66 | + console.log('[setTimeout]') |
| 67 | + flushBufferedChunks(controller) |
| 68 | + if (!startedRSC) { |
| 69 | + startedRSC = true |
| 70 | + writeRSCStream(rscStream, controller, nonce) |
| 71 | + .catch((err) => controller.error(err)) |
| 72 | + .then(resolveFlightDataPromise) |
| 73 | + } |
| 74 | + }, 0) |
| 75 | + }, |
| 76 | + async flush(controller) { |
| 77 | + console.log('[TransformStream.flush]') |
| 78 | + await flightDataPromise |
| 79 | + console.log('[flightDataPromise.resolved]') |
| 80 | + if (timeout) { |
| 81 | + clearTimeout(timeout) |
| 82 | + flushBufferedChunks(controller) |
| 83 | + } |
| 84 | + controller.enqueue(encoder.encode(trailer)) |
| 85 | + }, |
| 86 | + }) |
| 87 | +} |
| 88 | + |
| 89 | +async function writeRSCStream(rscStream, controller, nonce) { |
| 90 | + let decoder = new TextDecoder('utf-8', { fatal: true }) |
| 91 | + for await (let chunk of rscStream) { |
| 92 | + // Try decoding the chunk to send as a string. |
| 93 | + // If that fails (e.g. binary data that is invalid unicode), write as base64. |
| 94 | + try { |
| 95 | + writeChunk( |
| 96 | + JSON.stringify(decoder.decode(chunk, { stream: true })), |
| 97 | + controller, |
| 98 | + nonce, |
| 99 | + ) |
| 100 | + } catch (err) { |
| 101 | + let base64 = JSON.stringify(btoa(String.fromCodePoint(...chunk))) |
| 102 | + writeChunk( |
| 103 | + `Uint8Array.from(atob(${base64}), m => m.codePointAt(0))`, |
| 104 | + controller, |
| 105 | + nonce, |
| 106 | + ) |
| 107 | + } |
| 108 | + } |
| 109 | + |
| 110 | + let remaining = decoder.decode() |
| 111 | + if (remaining.length) { |
| 112 | + writeChunk(JSON.stringify(remaining), controller, nonce) |
| 113 | + } |
| 114 | +} |
| 115 | + |
| 116 | +function writeChunk(chunk, controller, nonce) { |
| 117 | + controller.enqueue( |
| 118 | + encoder.encode( |
| 119 | + `<script${nonce ? ` nonce="${nonce}"` : ''}>${escapeScript(`(self.__FLIGHT_DATA||=[]).push(${chunk})`)}</script>`, |
| 120 | + ), |
| 121 | + ) |
| 122 | +} |
| 123 | + |
| 124 | +// Escape closing script tags and HTML comments in JS content. |
| 125 | +// https://www.w3.org/TR/html52/semantics-scripting.html#restrictions-for-contents-of-script-elements |
| 126 | +// Avoid replacing </script with <\/script as it would break the following valid JS: 0</script/ (i.e. regexp literal). |
| 127 | +// Instead, escape the s character. |
| 128 | +function escapeScript(script) { |
| 129 | + return script.replace(/<!--/g, '<\\!--').replace(/<\/(script)/gi, '</\\$1') |
| 130 | +} |
0 commit comments