diff --git a/packages/render/package.json b/packages/render/package.json index b5b5475be6..5ee8775366 100644 --- a/packages/render/package.json +++ b/packages/render/package.json @@ -41,6 +41,16 @@ "default": "./dist/browser/index.js" } }, + "edge-light": { + "import": { + "types": "./dist/edge-light/index.d.mts", + "default": "./dist/edge-light/index.mjs" + }, + "require": { + "types": "./dist/edge-light/index.d.ts", + "default": "./dist/edge-light/index.js" + } + }, "browser": { "import": { "types": "./dist/browser/index.d.mts", diff --git a/packages/render/src/browser/render-async.tsx b/packages/render/src/browser/render-async.tsx index 9795b5fcd9..65299dc279 100644 --- a/packages/render/src/browser/render-async.tsx +++ b/packages/render/src/browser/render-async.tsx @@ -3,14 +3,14 @@ import { Suspense } from "react"; import { pretty } from "../shared/utils/pretty"; import { plainTextSelectors } from "../shared/plain-text-selectors"; import type { Options } from "../shared/options"; -import { readStream } from "./read-stream"; +import { readStream } from "../shared/read-stream.browser"; export const renderAsync = async ( element: React.ReactElement, options?: Options, ) => { const suspendedElement = {element}; - const { default: reactDOMServer } = await import("react-dom/server"); + const reactDOMServer = await import("react-dom/server"); let html!: string; if (Object.hasOwn(reactDOMServer, "renderToReadableStream")) { diff --git a/packages/render/src/browser/render.tsx b/packages/render/src/browser/render.tsx index c05f7c242d..4b855b008d 100644 --- a/packages/render/src/browser/render.tsx +++ b/packages/render/src/browser/render.tsx @@ -1,59 +1,16 @@ import { convert } from "html-to-text"; -import type { - PipeableStream, - ReactDOMServerReadableStream, -} from "react-dom/server"; import { Suspense } from "react"; import { pretty } from "../shared/utils/pretty"; import { plainTextSelectors } from "../shared/plain-text-selectors"; import type { Options } from "../shared/options"; - -const decoder = new TextDecoder("utf-8"); - -const readStream = async ( - stream: PipeableStream | ReactDOMServerReadableStream, -) => { - const chunks: Uint8Array[] = []; - - if ("pipeTo" in stream) { - // means it's a readable stream - const writableStream = new WritableStream({ - write(chunk: Uint8Array) { - chunks.push(chunk); - }, - }); - await stream.pipeTo(writableStream); - } else { - throw new Error( - "For some reason, the Node version of `react-dom/server` has been imported instead of the browser one.", - { - cause: { - stream, - }, - }, - ); - } - - let length = 0; - chunks.forEach((item) => { - length += item.length; - }); - const mergedChunks = new Uint8Array(length); - let offset = 0; - chunks.forEach((item) => { - mergedChunks.set(item, offset); - offset += item.length; - }); - - return decoder.decode(mergedChunks); -}; +import { readStream } from "../shared/read-stream.browser"; export const render = async ( element: React.ReactElement, options?: Options, ) => { const suspendedElement = {element}; - const { default: reactDOMServer } = await import("react-dom/server"); + const reactDOMServer = await import("react-dom/server"); let html!: string; if (Object.hasOwn(reactDOMServer, "renderToReadableStream")) { diff --git a/packages/render/src/edge-light/__snapshots__/render-async-edge.spec.tsx.snap b/packages/render/src/edge-light/__snapshots__/render-async-edge.spec.tsx.snap new file mode 100644 index 0000000000..b022d5e92f --- /dev/null +++ b/packages/render/src/edge-light/__snapshots__/render-async-edge.spec.tsx.snap @@ -0,0 +1,3 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`renderAsync on the edge > should handle characters with a higher byte count gracefully in React 18 1`] = `"

Test Normal 情報Ⅰコース担当者様

平素よりお世話になっております。 情報Ⅰサポートチームです。 情報Ⅰ本講座につきまして仕様変更のためご連絡させていただきました。

今後ジクタス上の講座につきましては、8回分の授業をひとまとまりとしてパート分けされた状態で公開されてまいります。

伴いまして、画面上の表示に一部変更がありますのでお知らせいたします。 ご登録いただいた各生徒の受講ペースに応じて公開パートが増えてまいります。 具体的な表示イメージは下記ページをご確認ください。

2024年8月末時点で情報Ⅰ本講座を受講していたアカウントにつきましては、 今まで公開していた第1~9回までの講座一覧に加え、パート分けされた講座が追加で公開されてまりいます。 第1~9回の表示はそのまま引き継がれますが、Webドリルの進捗表示はパート分けの講座には適用されません。ご了承くださいませ。 仕様変更に伴い、現在教室長もしくは講師に生徒アカウントログインをお願いしておりますが、今後は生徒自身にてログインをしていただいて問題ございません。

また、生徒が自宅等にてログインし復習に取り組むことも問題ございませんので、教室にてご指示いただければと存じます。 (実際にご指示いただくかは教室判断に委ねさせていただきます。)

受講ペースが変更したり、増コマが発生したりする場合などは、公開ペースを本部にて調整いたしますので、下記より必ずご連絡くださいませ。 また本件に関して不明な点がございましたら、同フォームよりお問い合わせください。 以上、引き続きよろしくお願い申し上げます。 情報Ⅰサポートチーム

"`; diff --git a/packages/render/src/edge-light/__snapshots__/render-async-node.spec.tsx.snap b/packages/render/src/edge-light/__snapshots__/render-async-node.spec.tsx.snap new file mode 100644 index 0000000000..6228bbef16 --- /dev/null +++ b/packages/render/src/edge-light/__snapshots__/render-async-node.spec.tsx.snap @@ -0,0 +1,53 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`renderAsync on node environments > should handle characters with a higher byte count gracefully in React 18 1`] = `"

Test Normal 情報Ⅰコース担当者様

平素よりお世話になっております。 情報Ⅰサポートチームです。 情報Ⅰ本講座につきまして仕様変更のためご連絡させていただきました。

今後ジクタス上の講座につきましては、8回分の授業をひとまとまりとしてパート分けされた状態で公開されてまいります。

伴いまして、画面上の表示に一部変更がありますのでお知らせいたします。 ご登録いただいた各生徒の受講ペースに応じて公開パートが増えてまいります。 具体的な表示イメージは下記ページをご確認ください。

2024年8月末時点で情報Ⅰ本講座を受講していたアカウントにつきましては、 今まで公開していた第1~9回までの講座一覧に加え、パート分けされた講座が追加で公開されてまりいます。 第1~9回の表示はそのまま引き継がれますが、Webドリルの進捗表示はパート分けの講座には適用されません。ご了承くださいませ。 仕様変更に伴い、現在教室長もしくは講師に生徒アカウントログインをお願いしておりますが、今後は生徒自身にてログインをしていただいて問題ございません。

また、生徒が自宅等にてログインし復習に取り組むことも問題ございませんので、教室にてご指示いただければと存じます。 (実際にご指示いただくかは教室判断に委ねさせていただきます。)

受講ペースが変更したり、増コマが発生したりする場合などは、公開ペースを本部にて調整いたしますので、下記より必ずご連絡くださいませ。 また本件に関して不明な点がございましたら、同フォームよりお問い合わせください。 以上、引き続きよろしくお願い申し上げます。 情報Ⅰサポートチーム

"`; + +exports[`renderAsync on node environments > that it properly waits for Suepsense boundaries to resolve before resolving 1`] = ` +"
+ + + Example Domain + + + + + + + + +
+

Example Domain

+

This domain is for use in illustrative examples in documents. You may use this + domain in literature without prior coordination or asking for permission.

+

More information...

+
+ + +
" +`; diff --git a/packages/render/src/edge-light/__snapshots__/render-async.spec.tsx.snap b/packages/render/src/edge-light/__snapshots__/render-async.spec.tsx.snap new file mode 100644 index 0000000000..b022d5e92f --- /dev/null +++ b/packages/render/src/edge-light/__snapshots__/render-async.spec.tsx.snap @@ -0,0 +1,3 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`renderAsync on the edge > should handle characters with a higher byte count gracefully in React 18 1`] = `"

Test Normal 情報Ⅰコース担当者様

平素よりお世話になっております。 情報Ⅰサポートチームです。 情報Ⅰ本講座につきまして仕様変更のためご連絡させていただきました。

今後ジクタス上の講座につきましては、8回分の授業をひとまとまりとしてパート分けされた状態で公開されてまいります。

伴いまして、画面上の表示に一部変更がありますのでお知らせいたします。 ご登録いただいた各生徒の受講ペースに応じて公開パートが増えてまいります。 具体的な表示イメージは下記ページをご確認ください。

2024年8月末時点で情報Ⅰ本講座を受講していたアカウントにつきましては、 今まで公開していた第1~9回までの講座一覧に加え、パート分けされた講座が追加で公開されてまりいます。 第1~9回の表示はそのまま引き継がれますが、Webドリルの進捗表示はパート分けの講座には適用されません。ご了承くださいませ。 仕様変更に伴い、現在教室長もしくは講師に生徒アカウントログインをお願いしておりますが、今後は生徒自身にてログインをしていただいて問題ございません。

また、生徒が自宅等にてログインし復習に取り組むことも問題ございませんので、教室にてご指示いただければと存じます。 (実際にご指示いただくかは教室判断に委ねさせていただきます。)

受講ペースが変更したり、増コマが発生したりする場合などは、公開ペースを本部にて調整いたしますので、下記より必ずご連絡くださいませ。 また本件に関して不明な点がございましたら、同フォームよりお問い合わせください。 以上、引き続きよろしくお願い申し上げます。 情報Ⅰサポートチーム

"`; diff --git a/packages/render/src/edge-light/__snapshots__/render-edge.spec.tsx.snap b/packages/render/src/edge-light/__snapshots__/render-edge.spec.tsx.snap new file mode 100644 index 0000000000..ff0e2e5f13 --- /dev/null +++ b/packages/render/src/edge-light/__snapshots__/render-edge.spec.tsx.snap @@ -0,0 +1,3 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`render on the edge > should handle characters with a higher byte count gracefully in React 18 1`] = `"

Test Normal 情報Ⅰコース担当者様

平素よりお世話になっております。 情報Ⅰサポートチームです。 情報Ⅰ本講座につきまして仕様変更のためご連絡させていただきました。

今後ジクタス上の講座につきましては、8回分の授業をひとまとまりとしてパート分けされた状態で公開されてまいります。

伴いまして、画面上の表示に一部変更がありますのでお知らせいたします。 ご登録いただいた各生徒の受講ペースに応じて公開パートが増えてまいります。 具体的な表示イメージは下記ページをご確認ください。

2024年8月末時点で情報Ⅰ本講座を受講していたアカウントにつきましては、 今まで公開していた第1~9回までの講座一覧に加え、パート分けされた講座が追加で公開されてまりいます。 第1~9回の表示はそのまま引き継がれますが、Webドリルの進捗表示はパート分けの講座には適用されません。ご了承くださいませ。 仕様変更に伴い、現在教室長もしくは講師に生徒アカウントログインをお願いしておりますが、今後は生徒自身にてログインをしていただいて問題ございません。

また、生徒が自宅等にてログインし復習に取り組むことも問題ございませんので、教室にてご指示いただければと存じます。 (実際にご指示いただくかは教室判断に委ねさせていただきます。)

受講ペースが変更したり、増コマが発生したりする場合などは、公開ペースを本部にて調整いたしますので、下記より必ずご連絡くださいませ。 また本件に関して不明な点がございましたら、同フォームよりお問い合わせください。 以上、引き続きよろしくお願い申し上げます。 情報Ⅰサポートチーム

"`; diff --git a/packages/render/src/edge-light/__snapshots__/render-node.spec.tsx.snap b/packages/render/src/edge-light/__snapshots__/render-node.spec.tsx.snap new file mode 100644 index 0000000000..c97cf084a3 --- /dev/null +++ b/packages/render/src/edge-light/__snapshots__/render-node.spec.tsx.snap @@ -0,0 +1,53 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`render on node environments > should handle characters with a higher byte count gracefully in React 18 1`] = `"

Test Normal 情報Ⅰコース担当者様

平素よりお世話になっております。 情報Ⅰサポートチームです。 情報Ⅰ本講座につきまして仕様変更のためご連絡させていただきました。

今後ジクタス上の講座につきましては、8回分の授業をひとまとまりとしてパート分けされた状態で公開されてまいります。

伴いまして、画面上の表示に一部変更がありますのでお知らせいたします。 ご登録いただいた各生徒の受講ペースに応じて公開パートが増えてまいります。 具体的な表示イメージは下記ページをご確認ください。

2024年8月末時点で情報Ⅰ本講座を受講していたアカウントにつきましては、 今まで公開していた第1~9回までの講座一覧に加え、パート分けされた講座が追加で公開されてまりいます。 第1~9回の表示はそのまま引き継がれますが、Webドリルの進捗表示はパート分けの講座には適用されません。ご了承くださいませ。 仕様変更に伴い、現在教室長もしくは講師に生徒アカウントログインをお願いしておりますが、今後は生徒自身にてログインをしていただいて問題ございません。

また、生徒が自宅等にてログインし復習に取り組むことも問題ございませんので、教室にてご指示いただければと存じます。 (実際にご指示いただくかは教室判断に委ねさせていただきます。)

受講ペースが変更したり、増コマが発生したりする場合などは、公開ペースを本部にて調整いたしますので、下記より必ずご連絡くださいませ。 また本件に関して不明な点がございましたら、同フォームよりお問い合わせください。 以上、引き続きよろしくお願い申し上げます。 情報Ⅰサポートチーム

"`; + +exports[`render on node environments > that it properly waits for Suepsense boundaries to resolve before resolving 1`] = ` +"
+ + + Example Domain + + + + + + + + +
+

Example Domain

+

This domain is for use in illustrative examples in documents. You may use this + domain in literature without prior coordination or asking for permission.

+

More information...

+
+ + +
" +`; diff --git a/packages/render/src/edge-light/__snapshots__/render.spec.tsx.snap b/packages/render/src/edge-light/__snapshots__/render.spec.tsx.snap new file mode 100644 index 0000000000..ff0e2e5f13 --- /dev/null +++ b/packages/render/src/edge-light/__snapshots__/render.spec.tsx.snap @@ -0,0 +1,3 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`render on the edge > should handle characters with a higher byte count gracefully in React 18 1`] = `"

Test Normal 情報Ⅰコース担当者様

平素よりお世話になっております。 情報Ⅰサポートチームです。 情報Ⅰ本講座につきまして仕様変更のためご連絡させていただきました。

今後ジクタス上の講座につきましては、8回分の授業をひとまとまりとしてパート分けされた状態で公開されてまいります。

伴いまして、画面上の表示に一部変更がありますのでお知らせいたします。 ご登録いただいた各生徒の受講ペースに応じて公開パートが増えてまいります。 具体的な表示イメージは下記ページをご確認ください。

2024年8月末時点で情報Ⅰ本講座を受講していたアカウントにつきましては、 今まで公開していた第1~9回までの講座一覧に加え、パート分けされた講座が追加で公開されてまりいます。 第1~9回の表示はそのまま引き継がれますが、Webドリルの進捗表示はパート分けの講座には適用されません。ご了承くださいませ。 仕様変更に伴い、現在教室長もしくは講師に生徒アカウントログインをお願いしておりますが、今後は生徒自身にてログインをしていただいて問題ございません。

また、生徒が自宅等にてログインし復習に取り組むことも問題ございませんので、教室にてご指示いただければと存じます。 (実際にご指示いただくかは教室判断に委ねさせていただきます。)

受講ペースが変更したり、増コマが発生したりする場合などは、公開ペースを本部にて調整いたしますので、下記より必ずご連絡くださいませ。 また本件に関して不明な点がございましたら、同フォームよりお問い合わせください。 以上、引き続きよろしくお願い申し上げます。 情報Ⅰサポートチーム

"`; diff --git a/packages/render/src/edge-light/index.ts b/packages/render/src/edge-light/index.ts new file mode 100644 index 0000000000..2d3b9b5105 --- /dev/null +++ b/packages/render/src/edge-light/index.ts @@ -0,0 +1,5 @@ +export * from "./render"; +export * from "./render-async"; + +export * from "../shared/options"; +export * from "../shared/plain-text-selectors"; diff --git a/packages/render/src/node/render-async-edge.spec.tsx b/packages/render/src/edge-light/render-async.spec.tsx similarity index 93% rename from packages/render/src/node/render-async-edge.spec.tsx rename to packages/render/src/edge-light/render-async.spec.tsx index 25511d0ed1..33492363fd 100644 --- a/packages/render/src/node/render-async-edge.spec.tsx +++ b/packages/render/src/edge-light/render-async.spec.tsx @@ -12,6 +12,16 @@ type Import = typeof import("react-dom/server") & { }; describe("renderAsync on the edge", () => { + beforeAll(() => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-extraneous-class + global.MessageChannel = class { + constructor() { + throw new Error("MessageChannel is not supported"); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + }); + it("converts a React component into HTML with Next 14 error stubs", async () => { vi.mock("react-dom/server", async () => { const ReactDOMServer = await vi.importActual("react-dom/server"); diff --git a/packages/render/src/edge-light/render-async.tsx b/packages/render/src/edge-light/render-async.tsx new file mode 100644 index 0000000000..60ba50eef5 --- /dev/null +++ b/packages/render/src/edge-light/render-async.tsx @@ -0,0 +1,54 @@ +import { convert } from "html-to-text"; +import { Suspense } from "react"; +import { pretty } from "../shared/utils/pretty"; +import { plainTextSelectors } from "../shared/plain-text-selectors"; +import type { Options } from "../shared/options"; +import { readStream } from "../shared/read-stream.browser"; + +/** + * @deprecated use `render` + */ +export const renderAsync = async ( + element: React.ReactElement, + options?: Options, +) => { + const suspendedElement = {element}; + const { default: reactDOMServer } = await import("react-dom/server.edge"); + + let html!: string; + if (Object.hasOwn(reactDOMServer, "renderToReadableStream")) { + html = await readStream( + await reactDOMServer.renderToReadableStream(suspendedElement), + ); + } else { + await new Promise((resolve, reject) => { + const stream = reactDOMServer.renderToPipeableStream(suspendedElement, { + async onAllReady() { + html = await readStream(stream); + resolve(); + }, + onError(error) { + reject(error as Error); + }, + }); + }); + } + + if (options?.plainText) { + return convert(html, { + selectors: plainTextSelectors, + ...options.htmlToTextOptions, + }); + } + + const doctype = + ''; + + const document = `${doctype}${html.replace(//, "")}`; + + if (options?.pretty) { + return pretty(document); + } + + return document; +}; diff --git a/packages/render/src/node/render-edge.spec.tsx b/packages/render/src/edge-light/render.spec.tsx similarity index 94% rename from packages/render/src/node/render-edge.spec.tsx rename to packages/render/src/edge-light/render.spec.tsx index eedcecd9f8..3e1ed358b0 100644 --- a/packages/render/src/node/render-edge.spec.tsx +++ b/packages/render/src/edge-light/render.spec.tsx @@ -12,6 +12,16 @@ type Import = typeof import("react-dom/server") & { }; describe("render on the edge", () => { + beforeAll(() => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-extraneous-class + global.MessageChannel = class { + constructor() { + throw new Error("MessageChannel is not supported"); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + }); + it("converts a React component into HTML with Next 14 error stubs", async () => { vi.mock("react-dom/server", async () => { const ReactDOMServer = await vi.importActual("react-dom/server"); diff --git a/packages/render/src/edge-light/render.tsx b/packages/render/src/edge-light/render.tsx new file mode 100644 index 0000000000..c2edc48979 --- /dev/null +++ b/packages/render/src/edge-light/render.tsx @@ -0,0 +1,51 @@ +import { convert } from "html-to-text"; +import { Suspense } from "react"; +import { pretty } from "../shared/utils/pretty"; +import { plainTextSelectors } from "../shared/plain-text-selectors"; +import type { Options } from "../shared/options"; +import { readStream } from "../shared/read-stream.browser"; + +export const render = async ( + element: React.ReactElement, + options?: Options, +) => { + const suspendedElement = {element}; + const { default: reactDOMServer } = await import("react-dom/server.edge"); + + let html!: string; + if (Object.hasOwn(reactDOMServer, "renderToReadableStream")) { + html = await readStream( + await reactDOMServer.renderToReadableStream(suspendedElement), + ); + } else { + await new Promise((resolve, reject) => { + const stream = reactDOMServer.renderToPipeableStream(suspendedElement, { + async onAllReady() { + html = await readStream(stream); + resolve(); + }, + onError(error) { + reject(error as Error); + }, + }); + }); + } + + if (options?.plainText) { + return convert(html, { + selectors: plainTextSelectors, + ...options.htmlToTextOptions, + }); + } + + const doctype = + ''; + + const document = `${doctype}${html.replace(//, "")}`; + + if (options?.pretty) { + return pretty(document); + } + + return document; +}; diff --git a/packages/render/src/node/read-stream.ts b/packages/render/src/node/read-stream.ts deleted file mode 100644 index 8c24dcfe37..0000000000 --- a/packages/render/src/node/read-stream.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Writable } from "node:stream"; -import { - PipeableStream, - ReactDOMServerReadableStream, -} from "react-dom/server.browser"; - -const decoder = new TextDecoder("utf-8"); - -export const readStream = async ( - stream: PipeableStream | ReactDOMServerReadableStream, -) => { - let result = ""; - - if ("pipeTo" in stream) { - // means it's a readable stream - const writableStream = new WritableStream({ - write(chunk: BufferSource) { - result += decoder.decode(chunk); - }, - }); - await stream.pipeTo(writableStream); - } else { - const writable = new Writable({ - write(chunk: BufferSource, _encoding, callback) { - result += decoder.decode(chunk); - - callback(); - }, - }); - stream.pipe(writable); - - return new Promise((resolve, reject) => { - writable.on("error", reject); - writable.on("close", () => { - resolve(result); - }); - }); - } - - return result; -}; diff --git a/packages/render/src/node/render-async-node.spec.tsx b/packages/render/src/node/render-async-node.spec.tsx index 7a3d0b1c4c..b692ea09a4 100644 --- a/packages/render/src/node/render-async-node.spec.tsx +++ b/packages/render/src/node/render-async-node.spec.tsx @@ -3,7 +3,7 @@ */ import usePromise from "react-promise-suspense"; -import { Suspense } from "react"; +import React, { Suspense } from "react"; import { Template } from "../shared/utils/template"; import { Preview } from "../shared/utils/preview"; import { renderAsync } from "./render-async"; diff --git a/packages/render/src/node/render-async.tsx b/packages/render/src/node/render-async.tsx index a81a6694ae..e2c63e2618 100644 --- a/packages/render/src/node/render-async.tsx +++ b/packages/render/src/node/render-async.tsx @@ -3,7 +3,7 @@ import { Suspense } from "react"; import { pretty } from "../shared/utils/pretty"; import { plainTextSelectors } from "../shared/plain-text-selectors"; import type { Options } from "../shared/options"; -import { readStream } from "./read-stream"; +import { readStream } from "../shared/read-stream"; /** * @deprecated use `render` diff --git a/packages/render/src/node/render.tsx b/packages/render/src/node/render.tsx index 693e4582f1..a72258e7ee 100644 --- a/packages/render/src/node/render.tsx +++ b/packages/render/src/node/render.tsx @@ -3,7 +3,7 @@ import { Suspense } from "react"; import { pretty } from "../shared/utils/pretty"; import { plainTextSelectors } from "../shared/plain-text-selectors"; import type { Options } from "../shared/options"; -import { readStream } from "./read-stream"; +import { readStream } from "../shared/read-stream"; export const render = async ( element: React.ReactElement, diff --git a/packages/render/src/react-internals.d.ts b/packages/render/src/react-internals.d.ts index d15d3ded51..0f0855a206 100644 --- a/packages/render/src/react-internals.d.ts +++ b/packages/render/src/react-internals.d.ts @@ -1,3 +1,6 @@ declare module "react-dom/server.browser" { export * from "react-dom/server"; } +declare module "react-dom/server.edge" { + export * from "react-dom/server"; +} diff --git a/packages/render/src/browser/read-stream.ts b/packages/render/src/shared/read-stream.browser.ts similarity index 100% rename from packages/render/src/browser/read-stream.ts rename to packages/render/src/shared/read-stream.browser.ts diff --git a/packages/render/src/shared/read-stream.ts b/packages/render/src/shared/read-stream.ts new file mode 100644 index 0000000000..d6cb593add --- /dev/null +++ b/packages/render/src/shared/read-stream.ts @@ -0,0 +1,56 @@ +import { Writable } from "node:stream"; +import { + PipeableStream, + ReactDOMServerReadableStream, +} from "react-dom/server.browser"; + +const decoder = new TextDecoder("utf-8"); + +const promisify = (writable: Writable) => { + return new Promise((resolve, reject) => { + writable.on("error", reject); + writable.on("close", () => { + resolve(); + }); + }); +}; + +export const readStream = async ( + stream: PipeableStream | ReactDOMServerReadableStream, +) => { + const chunks: Uint8Array[] = []; + + if ("pipeTo" in stream) { + // means it's a readable stream + const writableStream = new WritableStream({ + write(chunk: Uint8Array) { + chunks.push(chunk); + }, + }); + await stream.pipeTo(writableStream); + } else { + const writable = new Writable({ + write(chunk: Uint8Array, _encoding, callback) { + chunks.push(chunk); + + callback(); + }, + }); + stream.pipe(writable); + + await promisify(writable); + } + + let length = 0; + chunks.forEach((item) => { + length += item.length; + }); + const mergedChunks = new Uint8Array(length); + let offset = 0; + chunks.forEach((item) => { + mergedChunks.set(item, offset); + offset += item.length; + }); + + return decoder.decode(mergedChunks); +}; diff --git a/packages/render/tsup.config.ts b/packages/render/tsup.config.ts index 7d973ea7bc..276b2285de 100644 --- a/packages/render/tsup.config.ts +++ b/packages/render/tsup.config.ts @@ -7,6 +7,12 @@ export default defineConfig([ outDir: "./dist/node", format: ["cjs", "esm"], }, + { + dts: true, + entry: ["./src/edge-light/index.ts"], + outDir: "./dist/edge-light", + format: ["cjs", "esm"], + }, { dts: true, entry: ["./src/browser/index.ts"],