Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -35,18 +35,22 @@ export async function renderHTML(
? undefined
: bootstrapScriptContent,
nonce: options?.nonce,
// signal: undefined,
// no types
...{ formState: options?.formState },
})

let responseStream: ReadableStream<Uint8Array> = htmlStream
if (!options?.debugNojs) {
console.log('[responseStream.pipeThrough:before]')
// initial RSC stream is injected in HTML stream as <script>...FLIGHT_DATA...</script>
responseStream = responseStream.pipeThrough(
injectRscStreamToHtml(rscStream2, {
nonce: options?.nonce,
}),
)
console.log('[responseStream.pipeThrough:after]')
// responseStream.cancel()
}

return responseStream
Expand Down
15 changes: 15 additions & 0 deletions packages/plugin-rsc/examples/starter/src/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import viteLogo from '/vite.svg'
import { getServerCounter, updateServerCounter } from './action.tsx'
import reactLogo from './assets/react.svg'
import { ClientCounter } from './client.tsx'
import React from 'react'

export function Root() {
return (
Expand Down Expand Up @@ -43,6 +44,7 @@ function App() {
<button>Server Counter: {getServerCounter()}</button>
</form>
</div>
<TestSuspence />
<ul className="read-the-docs">
<li>
Edit <code>src/client.tsx</code> to test client HMR.
Expand All @@ -68,3 +70,16 @@ function App() {
</div>
)
}

function TestSuspence() {
async function Inner() {
await new Promise((resolve) => setTimeout(resolve, 1000))
return <div>[suspense-resolved]</div>
}

return (
<React.Suspense fallback={<div>[suspense-fallback]</div>}>
<Inner />
</React.Suspense>
)
}
1 change: 1 addition & 0 deletions packages/plugin-rsc/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
},
"devDependencies": {
"@hiogawa/utils": "^1.7.0",
"@hono/node-server": "^1.16.0",
"@playwright/test": "^1.53.2",
"@tsconfig/strictest": "^2.0.5",
"@types/estree": "^1.0.8",
Expand Down
66 changes: 66 additions & 0 deletions packages/plugin-rsc/repro.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { injectRscStreamToHtml } from './src/rsc-html-stream/ssr.ts'

async function main() {
let htmlStream = new ReadableStream({
async start(controller) {
controller.enqueue(
new TextEncoder().encode('<html><body>Hello World</body></html>'),
)
controller.close()
},
})

let rscStream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('[rsc]'))
controller.close()
},
})

htmlStream = htmlStream.pipeThrough(injectRscStreamToHtml(rscStream))

const decoder = new TextDecoder()
const reader = htmlStream.getReader()
let result
result = await reader.read()
console.log(result)
console.log({ decoded: decoder.decode(result.value) })
// await reader.cancel("boom!").catch((e) => {
// console.error("[reader.cancel]", e)
// });
reader.releaseLock()
await htmlStream.cancel('boom!').catch((e) => {
console.error('[htmlStream.cancel]', e)
})

// try {
// console.log(await reader.cancel("boom!"));
// } catch (e) {
// console.error("Error while canceling the reader:", e);
// }

// result = await reader.read();
// console.log(result, decoder.decode(result.value));
// result = await reader.read();
// console.log(result, decoder.decode(result.value));
// result = await reader.read();
// console.log(result, decoder.decode(result.value));
// while (true) {
// const result = await reader.read();
// if (result.done) {
// break;
// }
// console.log(result);
// const decoded = decoder.decode(result.value);
// console.log({ decoded });
// }
// reader.releaseLock();
// try {
// await reader.cancel("boom");
// } catch (e) {
// console.error("", e);
// }
}

main()
// node --experimental-strip-types packages/plugin-rsc/repro.js
4 changes: 4 additions & 0 deletions packages/plugin-rsc/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,10 @@ export default function vitePluginRsc(
`[vite-rsc] failed to resolve server handler '${source}'`,
)
const mod = await environment.runner.import(resolved.id)

// const { getRequestListener } = await import('@hono/node-server')
// await getRequestListener(mod.default)(req, res)

// ensure catching rejected promise
// https://github.com/mjackson/remix-the-web/blob/b5aa2ae24558f5d926af576482caf6e9b35461dc/packages/node-fetch-server/src/lib/request-listener.ts#L87
await createRequestListener(mod.default)(req, res)
Expand Down
134 changes: 131 additions & 3 deletions packages/plugin-rsc/src/rsc-html-stream/ssr.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,135 @@
import * as rscHtmlStreamServer from 'rsc-html-stream/server'
// @ts-nocheck

// import * as rscHtmlStreamServer from 'rsc-html-stream/server'

// export const injectRscStreamToHtml = (
// stream: ReadableStream<Uint8Array>,
// options?: { nonce?: string },
// ): TransformStream<Uint8Array, Uint8Array> =>
// rscHtmlStreamServer.injectRSCPayload(stream, options)

export const injectRscStreamToHtml = (
stream: ReadableStream<Uint8Array>,
options?: { nonce?: string },
): TransformStream<Uint8Array, Uint8Array> =>
rscHtmlStreamServer.injectRSCPayload(stream, options)
): TransformStream<Uint8Array, Uint8Array> => injectRSCPayload(stream, options)

const encoder = new TextEncoder()
const trailer = '</body></html>'

function injectRSCPayload(rscStream, options) {
let decoder = new TextDecoder()
let resolveFlightDataPromise
let flightDataPromise = new Promise(
(resolve) => (resolveFlightDataPromise = resolve),
)
let startedRSC = false
let nonce =
options && typeof options.nonce === 'string' ? options.nonce : undefined

// Buffer all HTML chunks enqueued during the current tick of the event loop (roughly)
// and write them to the output stream all at once. This ensures that we don't generate
// invalid HTML by injecting RSC in between two partial chunks of HTML.
let buffered = []
let timeout = null
function flushBufferedChunks(controller) {
console.log('[flushBufferedChunks]', buffered.length)
for (let chunk of buffered) {
let buf = decoder.decode(chunk, { stream: true })
if (buf.endsWith(trailer)) {
buf = buf.slice(0, -trailer.length)
}
controller.enqueue(encoder.encode(buf))
}

let remaining = decoder.decode()
if (remaining.length) {
if (remaining.endsWith(trailer)) {
remaining = remaining.slice(0, -trailer.length)
}
controller.enqueue(encoder.encode(remaining))
}

buffered.length = 0
timeout = null
}

return new TransformStream({
transform(chunk, controller) {
console.log('[TransformStream.transform]')

buffered.push(chunk)
if (timeout) {
return
}

timeout = setTimeout(async () => {
console.log('[setTimeout]')
flushBufferedChunks(controller)
if (!startedRSC) {
startedRSC = true
writeRSCStream(rscStream, controller, nonce)
.catch((err) => controller.error(err))
.then(resolveFlightDataPromise)
}
}, 0)
},
async flush(controller) {
console.log('[TransformStream.flush]')
await flightDataPromise
console.log('[flightDataPromise.resolved]')
if (timeout) {
clearTimeout(timeout)
flushBufferedChunks(controller)
// this would crash '@mjackson/node-fetch-server'
// but not '@hono/node-server'
// likely because it swallows `reader.cancel()` error
// https://github.com/honojs/node-server/blob/cb52c36d1d5d5b68416c807ce4b231c8bc549e29/src/utils.ts#L21
if (1) throw new Error('test')
}
controller.enqueue(encoder.encode(trailer))
},
})
}

async function writeRSCStream(rscStream, controller, nonce) {
let decoder = new TextDecoder('utf-8', { fatal: true })
for await (let chunk of rscStream) {
// Try decoding the chunk to send as a string.
// If that fails (e.g. binary data that is invalid unicode), write as base64.
try {
writeChunk(
JSON.stringify(decoder.decode(chunk, { stream: true })),
controller,
nonce,
)
} catch (err) {
let base64 = JSON.stringify(btoa(String.fromCodePoint(...chunk)))
writeChunk(
`Uint8Array.from(atob(${base64}), m => m.codePointAt(0))`,
controller,
nonce,
)
}
}

let remaining = decoder.decode()
if (remaining.length) {
writeChunk(JSON.stringify(remaining), controller, nonce)
}
}

function writeChunk(chunk, controller, nonce) {
controller.enqueue(
encoder.encode(
`<script${nonce ? ` nonce="${nonce}"` : ''}>${escapeScript(`(self.__FLIGHT_DATA||=[]).push(${chunk})`)}</script>`,
),
)
}

// Escape closing script tags and HTML comments in JS content.
// https://www.w3.org/TR/html52/semantics-scripting.html#restrictions-for-contents-of-script-elements
// Avoid replacing </script with <\/script as it would break the following valid JS: 0</script/ (i.e. regexp literal).
// Instead, escape the s character.
function escapeScript(script) {
return script.replace(/<!--/g, '<\\!--').replace(/<\/(script)/gi, '</\\$1')
}
11 changes: 11 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading