From 28534c12ef0573e4a6291a3cee32191bf7368715 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Mon, 20 Jan 2025 15:32:26 -0500 Subject: [PATCH 1/2] Add RSC wrapper library to simplify server and client --- gulpfile.js | 30 ++++- .../react-server-components/package.json | 5 +- .../react-server-components/src/bootstrap.js | 85 ++++--------- .../react-server-components/src/server.js | 101 ++------------- .../react-static/components/client.tsx | 47 ++----- packages/examples/react-static/package.json | 5 +- packages/transformers/js/src/JSTransformer.js | 34 ++--- .../templates/react-server/package.json | 5 +- .../templates/react-server/src/client.tsx | 82 ++++-------- .../templates/react-server/src/server.tsx | 88 ++----------- .../templates/react-server/types.d.ts | 23 ---- .../templates/react-static/package.json | 5 +- .../templates/react-static/src/client.tsx | 47 ++----- .../templates/react-static/types.d.ts | 12 -- packages/utils/rsc/package.json | 31 +++++ packages/utils/rsc/src/client.tsx | 64 ++++++++++ packages/utils/rsc/src/node.tsx | 48 +++++++ packages/utils/rsc/src/server.tsx | 117 ++++++++++++++++++ packages/utils/rsc/tsconfig.json | 31 +++++ packages/utils/rsc/types.d.ts | 81 ++++++++++++ yarn.lock | 43 +++++-- 21 files changed, 555 insertions(+), 429 deletions(-) delete mode 100644 packages/utils/create-parcel/templates/react-server/types.d.ts delete mode 100644 packages/utils/create-parcel/templates/react-static/types.d.ts create mode 100644 packages/utils/rsc/package.json create mode 100644 packages/utils/rsc/src/client.tsx create mode 100644 packages/utils/rsc/src/node.tsx create mode 100644 packages/utils/rsc/src/server.tsx create mode 100644 packages/utils/rsc/tsconfig.json create mode 100644 packages/utils/rsc/types.d.ts diff --git a/gulpfile.js b/gulpfile.js index 9261be380b3..864434d9085 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -3,6 +3,7 @@ const babel = require('gulp-babel'); const gulp = require('gulp'); const path = require('path'); const {rimraf} = require('rimraf'); +const swc = require('@swc/core'); const babelConfig = require('./babel.config.json'); const IGNORED_PACKAGES = [ @@ -67,7 +68,7 @@ exports.clean = function clean(cb) { }; exports.default = exports.build = gulp.series( - gulp.parallel(buildBabel, copyOthers), + gulp.parallel(buildBabel, buildRSC, copyOthers), // Babel reads from package.json so update these after babel has run paths.packageJson.map( packageJsonPath => @@ -92,6 +93,33 @@ function copyOthers() { .pipe(gulp.dest(paths.packages)); } +function buildRSC() { + return gulp + .src('packages/utils/rsc/src/*.tsx') + .pipe( + new TapStream(vinyl => { + let result = swc.transformSync(vinyl.contents.toString(), { + filename: vinyl.path, + jsc: { + parser: { + syntax: 'typescript', + tsx: true, + }, + target: 'esnext', + experimental: { + emitIsolatedDts: true, + }, + }, + }); + + let output = JSON.parse(result.output); + vinyl.contents = Buffer.from(output.__swc_isolated_declarations__); + }), + ) + .pipe(renameStream(relative => relative.replace('.tsx', '.d.ts'))) + .pipe(gulp.dest('packages/utils/rsc/lib')); +} + function _updatePackageJson(file) { return gulp .src(file) diff --git a/packages/examples/react-server-components/package.json b/packages/examples/react-server-components/package.json index b243fbb0f0a..51fb106b94e 100644 --- a/packages/examples/react-server-components/package.json +++ b/packages/examples/react-server-components/package.json @@ -18,10 +18,9 @@ "start": "node dist/server.js" }, "dependencies": { + "@parcel/rsc": "*", "express": "^4.18.2", "react": "^19", - "react-dom": "^19", - "react-server-dom-parcel": "canary", - "rsc-html-stream": "^0.0.4" + "react-dom": "^19" } } diff --git a/packages/examples/react-server-components/src/bootstrap.js b/packages/examples/react-server-components/src/bootstrap.js index 20f0b91228b..417977aad90 100644 --- a/packages/examples/react-server-components/src/bootstrap.js +++ b/packages/examples/react-server-components/src/bootstrap.js @@ -1,67 +1,34 @@ 'use client-entry'; -import {useState, use, startTransition, useInsertionEffect} from 'react'; -import ReactDOM from 'react-dom/client'; -import { - createFromReadableStream, - createFromFetch, - encodeReply, - setServerCallback, -} from 'react-server-dom-parcel/client'; -import {rscStream} from 'rsc-html-stream/client'; - -// Stream in initial RSC payload embedded in the HTML. -let data = createFromReadableStream(rscStream); -let updateRoot; - -// Setup a callback to perform server actions. -// This sends a POST request to the server, and updates the page with the response. -setServerCallback(async function (id, args) { - console.log(id, args); - const response = fetch('/', { - method: 'POST', - headers: { - Accept: 'text/x-component', - 'rsc-action-id': id, - }, - body: await encodeReply(args), - }); - const {result, root} = await createFromFetch(response); - startTransition(() => updateRoot(root)); - return result; -}); - -function Content() { - // Store the current root element in state, along with a callback - // to call once rendering is complete. - let [[root, cb], setRoot] = useState([use(data), null]); - updateRoot = (root, cb) => setRoot([root, cb]); - useInsertionEffect(() => cb?.()); - return root; -} - -// Hydrate initial page content. -startTransition(() => { - ReactDOM.hydrateRoot(document, ); +import {hydrate, fetchRSC} from '@parcel/rsc/client'; + +let updateRoot = hydrate({ + async handleServerAction(id, args) { + console.log(id, args); + const {result, root} = await fetchRSC('/', { + method: 'POST', + headers: { + 'rsc-action-id': id, + }, + body: args, + }); + updateRoot(root); + return result; + }, + onHmrReload() { + navigate(location.pathname); + }, }); // A very simple router. When we navigate, we'll fetch a new RSC payload from the server, // and in a React transition, stream in the new page. Once complete, we'll pushState to // update the URL in the browser. async function navigate(pathname, push) { - let res = fetch(pathname, { - headers: { - Accept: 'text/x-component', - }, - }); - let root = await createFromFetch(res); - startTransition(() => { - updateRoot(root, () => { - if (push) { - history.pushState(null, '', pathname); - push = false; - } - }); + let root = await fetchRSC(pathname); + updateRoot(root, () => { + if (push) { + history.pushState(null, '', pathname); + } }); } @@ -91,9 +58,3 @@ document.addEventListener('click', e => { window.addEventListener('popstate', e => { navigate(location.pathname); }); - -// Intercept HMR window reloads, and do it with RSC instead. -window.addEventListener('parcelhmrreload', e => { - e.preventDefault(); - navigate(location.pathname); -}); diff --git a/packages/examples/react-server-components/src/server.js b/packages/examples/react-server-components/src/server.js index 2e6b3966943..cc2d0efe090 100644 --- a/packages/examples/react-server-components/src/server.js +++ b/packages/examples/react-server-components/src/server.js @@ -1,14 +1,5 @@ -// Server dependencies. import express from 'express'; -import {Readable} from 'node:stream'; -import {renderToReadableStream, loadServerAction, decodeReply, decodeAction} from 'react-server-dom-parcel/server.edge'; -import {injectRSCPayload} from 'rsc-html-stream/server'; - -// Client dependencies, used for SSR. -// These must run in the same environment as client components (e.g. same instance of React). -import {createFromReadableStream} from 'react-server-dom-parcel/client.edge' with {env: 'react-client'}; -import {renderToReadableStream as renderHTMLToReadableStream} from 'react-dom/server.edge' with {env: 'react-client'}; -import ReactClient from 'react' with {env: 'react-client'}; +import {renderRequest, callAction} from '@parcel/rsc/node'; // Page components. These must have "use server-entry" so they are treated as code splitting entry points. import App from './App'; @@ -16,97 +7,29 @@ import FilePage from './FilePage'; const app = express(); -app.options('/', function (req, res) { - res.setHeader('Allow', 'Allow: GET,HEAD,POST'); - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Access-Control-Allow-Headers', 'rsc-action'); - res.end(); -}); - app.use(express.static('dist')); app.get('/', async (req, res) => { - await render(req, res, , App.bootstrapScript); + await renderRequest(req, res, , {component: App}); }); app.get('/files/*', async (req, res) => { - await render(req, res, , FilePage.bootstrapScript); + await renderRequest(req, res, , {component: FilePage}); }); app.post('/', async (req, res) => { let id = req.get('rsc-action-id'); - let request = new Request('http://localhost' + req.url, { - method: 'POST', - headers: req.headers, - body: Readable.toWeb(req), - duplex: 'half' - }); - - if (id) { - let action = await loadServerAction(id); - let body = req.is('multipart/form-data') ? await request.formData() : await request.text(); - let args = await decodeReply(body); - let result = action.apply(null, args); - try { - // Wait for any mutations - await result; - } catch (x) { - // We handle the error on the client + try { + let {result} = await callAction(req, id); + let root = ; + if (id) { + root = {result, root}; } - - await render(req, res, , App.bootstrapScript, result); - } else { - // Form submitted by browser (progressive enhancement). - let formData = await request.formData(); - let action = await decodeAction(formData); - try { - // Wait for any mutations - await action(); - } catch (err) { - // TODO render error page? - } - await render(req, res, , App.bootstrapScript); + await renderRequest(req, res, root, {component: App}); + } catch (err) { + await renderRequest(req, res,

{err.toString()}

); } }); -async function render(req, res, component, bootstrapScript, actionResult) { - // Render RSC payload. - let root = component; - if (actionResult) { - root = {result: actionResult, root}; - } - let stream = renderToReadableStream(root); - if (req.accepts('text/html')) { - res.setHeader('Content-Type', 'text/html'); - - // Use client react to render the RSC payload to HTML. - let [s1, s2] = stream.tee(); - let data; - function Content() { - // Important: this must be constructed inside a component for preinit scripts to be inserted. - data ??= createFromReadableStream(s1); - return ReactClient.use(data); - } - - let htmlStream = await renderHTMLToReadableStream(, { - bootstrapScriptContent: bootstrapScript, - }); - let response = htmlStream.pipeThrough(injectRSCPayload(s2)); - Readable.fromWeb(response).pipe(res); - } else { - res.set('Content-Type', 'text/x-component'); - Readable.fromWeb(stream).pipe(res); - } -} - -let server = app.listen(3001); +app.listen(3001); console.log('Server listening on port 3001'); -console.log(import.meta.distDir, import.meta.publicUrl) - -if (module.hot) { - module.hot.dispose(() => { - server.close(); - }); - - module.hot.accept(); -} diff --git a/packages/examples/react-static/components/client.tsx b/packages/examples/react-static/components/client.tsx index b084d089203..e0a8a5c138f 100644 --- a/packages/examples/react-static/components/client.tsx +++ b/packages/examples/react-static/components/client.tsx @@ -1,41 +1,24 @@ "use client-entry"; -import {useState, use, startTransition, useInsertionEffect, ReactElement} from 'react'; -import ReactDOM from 'react-dom/client'; -import {createFromReadableStream, createFromFetch} from 'react-server-dom-parcel/client'; -import {rscStream} from 'rsc-html-stream/client'; +import type { ReactNode } from 'react'; +import {hydrate, fetchRSC} from '@parcel/rsc/client'; -// Stream in initial RSC payload embedded in the HTML. -let initialRSCPayload = createFromReadableStream(rscStream); -let updateRoot: ((root: ReactElement, cb?: (() => void) | null) => void) | null = null; - -function Content() { - // Store the current root element in state, along with a callback - // to call once rendering is complete. - let [[root, cb], setRoot] = useState<[ReactElement, (() => void) | null]>([use(initialRSCPayload), null]); - updateRoot = (root, cb) => setRoot([root, cb ?? null]); - useInsertionEffect(() => cb?.()); - return root; -} - -// Hydrate initial page content. -startTransition(() => { - ReactDOM.hydrateRoot(document, ); +let updateRoot = hydrate({ + // Intercept HMR window reloads, and do it with RSC instead. + onHmrReload() { + navigate(location.pathname); + } }); // A very simple router. When we navigate, we'll fetch a new RSC payload from the server, // and in a React transition, stream in the new page. Once complete, we'll pushState to // update the URL in the browser. async function navigate(pathname: string, push = false) { - let res = fetch(pathname.replace(/\.html$/, '.rsc')); - let root = await createFromFetch(res); - startTransition(() => { - updateRoot!(root, () => { - if (push) { - history.pushState(null, '', pathname); - push = false; - } - }); + let root = await fetchRSC(pathname.replace(/\.html$/, '.rsc')); + updateRoot(root, () => { + if (push) { + history.pushState(null, '', pathname); + } }); } @@ -65,9 +48,3 @@ document.addEventListener('click', e => { window.addEventListener('popstate', e => { navigate(location.pathname); }); - -// Intercept HMR window reloads, and do it with RSC instead. -window.addEventListener('parcelhmrreload', e => { - e.preventDefault(); - navigate(location.pathname); -}); diff --git a/packages/examples/react-static/package.json b/packages/examples/react-static/package.json index 484a68d857c..ca8c4f03b63 100644 --- a/packages/examples/react-static/package.json +++ b/packages/examples/react-static/package.json @@ -14,9 +14,8 @@ "build": "parcel build" }, "dependencies": { + "@parcel/rsc": "*", "react": "^19", - "react-dom": "^19", - "react-server-dom-parcel": "canary", - "rsc-html-stream": "^0.0.4" + "react-dom": "^19" } } diff --git a/packages/transformers/js/src/JSTransformer.js b/packages/transformers/js/src/JSTransformer.js index 7a0aba01790..53ae169ee04 100644 --- a/packages/transformers/js/src/JSTransformer.js +++ b/packages/transformers/js/src/JSTransformer.js @@ -233,26 +233,30 @@ export default (new Transformer({ pkg?.alias && pkg.alias['react'] === 'preact/compat' ? 'preact' : reactLib; - let automaticVersion = JSX_PRAGMA[effectiveReactLib]?.automatic; let reactLibVersion = pkg?.dependencies?.[effectiveReactLib] || pkg?.devDependencies?.[effectiveReactLib] || pkg?.peerDependencies?.[effectiveReactLib]; - reactLibVersion = reactLibVersion - ? semver.validRange(reactLibVersion) - : null; - let minReactLibVersion = - reactLibVersion !== null && reactLibVersion !== '*' - ? semver.minVersion(reactLibVersion)?.toString() + if (effectiveReactLib === 'react' && reactLibVersion === 'canary') { + automaticJSXRuntime = true; + } else { + let automaticVersion = JSX_PRAGMA[effectiveReactLib]?.automatic; + reactLibVersion = reactLibVersion + ? semver.validRange(reactLibVersion) : null; - - automaticJSXRuntime = - automaticVersion && - !compilerOptions?.jsxFactory && - minReactLibVersion != null && - semver.satisfies(minReactLibVersion, automaticVersion, { - includePrerelease: true, - }); + let minReactLibVersion = + reactLibVersion !== null && reactLibVersion !== '*' + ? semver.minVersion(reactLibVersion)?.toString() + : null; + + automaticJSXRuntime = + automaticVersion && + !compilerOptions?.jsxFactory && + minReactLibVersion != null && + semver.satisfies(minReactLibVersion, automaticVersion, { + includePrerelease: true, + }); + } if (automaticJSXRuntime) { jsxImportSource = reactLib; diff --git a/packages/utils/create-parcel/templates/react-server/package.json b/packages/utils/create-parcel/templates/react-server/package.json index 565ca9d46ea..78d485b900e 100644 --- a/packages/utils/create-parcel/templates/react-server/package.json +++ b/packages/utils/create-parcel/templates/react-server/package.json @@ -17,11 +17,10 @@ "build": "parcel build" }, "dependencies": { + "@parcel/rsc": "canary", "express": "^4.21.2", "react": "canary", - "react-dom": "canary", - "react-server-dom-parcel": "canary", - "rsc-html-stream": "^0.0.4" + "react-dom": "canary" }, "devDependencies": { "@types/express": "^4", diff --git a/packages/utils/create-parcel/templates/react-server/src/client.tsx b/packages/utils/create-parcel/templates/react-server/src/client.tsx index 2cc883d7123..34c2e73de5c 100644 --- a/packages/utils/create-parcel/templates/react-server/src/client.tsx +++ b/packages/utils/create-parcel/templates/react-server/src/client.tsx @@ -1,46 +1,38 @@ "use client-entry"; -import {useState, startTransition, useInsertionEffect, ReactElement, use} from 'react'; -import {hydrateRoot} from 'react-dom/client'; -import {createFromReadableStream, createFromFetch, encodeReply, setServerCallback} from 'react-server-dom-parcel/client'; -import {rscStream} from 'rsc-html-stream/client'; - -// Stream in initial RSC payload embedded in the HTML. -let initialRSCPayload = createFromReadableStream(rscStream); -let updateRoot: ((root: ReactElement, cb?: (() => void) | null) => void) | null = null; - -function Content() { - // Store the current root element in state, along with a callback - // to call once rendering is complete. - let [[root, cb], setRoot] = useState<[ReactElement, (() => void) | null]>([use(initialRSCPayload), null]); - updateRoot = (root, cb) => setRoot([root, cb ?? null]); - useInsertionEffect(() => cb?.()); - return root; -} - -// Hydrate initial page content. -startTransition(() => { - hydrateRoot(document, ); -}); +import type {ReactNode} from 'react'; +import {hydrate, fetchRSC} from '@parcel/rsc/client'; + +let updateRoot = hydrate({ + // Setup a callback to perform server actions. + // This sends a POST request to the server, and updates the page with the response. + async handleServerAction(id, args) { + let {result, root} = await fetchRSC<{root: ReactNode, result: any}>(location.pathname, { + method: 'POST', + headers: { + 'rsc-action-id': id, + }, + body: args, + }); + updateRoot(root); + return result; + }, + // Intercept HMR window reloads, and do it with RSC instead. + onHmrReload() { + navigate(location.pathname); + }, +}) // A very simple router. When we navigate, we'll fetch a new RSC payload from the server, // and in a React transition, stream in the new page. Once complete, we'll pushState to // update the URL in the browser. async function navigate(pathname: string, push = false) { - let res = fetch(pathname, { - headers: { - Accept: 'text/x-component' + let root = await fetchRSC(pathname); + updateRoot(root, () => { + if (push) { + history.pushState(null, '', pathname); } }); - let root = await createFromFetch(res); - startTransition(() => { - updateRoot!(root, () => { - if (push) { - history.pushState(null, '', pathname); - push = false; - } - }); - }); } // Intercept link clicks to perform RSC navigation. @@ -69,25 +61,3 @@ document.addEventListener('click', e => { window.addEventListener('popstate', e => { navigate(location.pathname); }); - -// Intercept HMR window reloads, and do it with RSC instead. -window.addEventListener('parcelhmrreload', e => { - e.preventDefault(); - navigate(location.pathname); -}); - -// Setup a callback to perform server actions. -// This sends a POST request to the server, and updates the page with the response. -setServerCallback(async function(id: string, args: any[]) { - const response = fetch(location.pathname, { - method: 'POST', - headers: { - Accept: 'text/x-component', - 'rsc-action-id': id, - }, - body: await encodeReply(args), - }); - const {result, root} = await createFromFetch<{root: ReactElement, result: any}>(response); - startTransition(() => updateRoot!(root)); - return result; -}); diff --git a/packages/utils/create-parcel/templates/react-server/src/server.tsx b/packages/utils/create-parcel/templates/react-server/src/server.tsx index 116648e9312..e311a08c706 100644 --- a/packages/utils/create-parcel/templates/react-server/src/server.tsx +++ b/packages/utils/create-parcel/templates/react-server/src/server.tsx @@ -1,15 +1,5 @@ -// Server dependencies. -import express, {type Request as ExpressRequest, type Response as ExpressResponse} from 'express'; -import {Readable} from 'stream'; -import type { ReadableStream as NodeReadableStream } from 'stream/web'; -import {renderToReadableStream, loadServerAction, decodeReply, decodeAction} from 'react-server-dom-parcel/server.edge'; -import {injectRSCPayload} from 'rsc-html-stream/server'; - -// Client dependencies, used for SSR. -// These must run in the same environment as client components (e.g. same instance of React). -import {createFromReadableStream} from 'react-server-dom-parcel/client.edge' with {env: 'react-client'}; -import {renderToReadableStream as renderHTMLToReadableStream} from 'react-dom/server.edge' with {env: 'react-client'}; -import ReactClient, {ReactElement} from 'react' with {env: 'react-client'}; +import express from 'express'; +import {renderRequest, callAction} from '@parcel/rsc/node'; // Page components. These must have "use server-entry" so they are treated as code splitting entry points. import {Page} from './Page'; @@ -19,80 +9,18 @@ const app = express(); app.use(express.static('dist')); app.get('/', async (req, res) => { - await render(req, res, ); + await renderRequest(req, res, , {component: Page}); }); app.post('/', async (req, res) => { - await handleAction(req, res, ); -}); - -async function render(req: ExpressRequest, res: ExpressResponse, component: ReactElement, actionResult?: any) { - // Render RSC payload. - let root: any = component; - if (arguments.length > 3) { - root = {result: actionResult, root}; - } - let stream = renderToReadableStream(root); - if (req.accepts('text/html')) { - res.setHeader('Content-Type', 'text/html'); - - // Use client react to render the RSC payload to HTML. - let [s1, s2] = stream.tee(); - let data - function Content() { - data ??= createFromReadableStream(s1); - return ReactClient.use(data); - } - - let htmlStream = await renderHTMLToReadableStream(, { - bootstrapScriptContent: (component.type as any).bootstrapScript - }); - - let response = htmlStream.pipeThrough(injectRSCPayload(s2)); - Readable.fromWeb(response as NodeReadableStream).pipe(res); - } else { - res.set('Content-Type', 'text/x-component'); - Readable.fromWeb(stream as NodeReadableStream).pipe(res); - } -} - -// Handle server actions. -async function handleAction(req: ExpressRequest, res: ExpressResponse, component: ReactElement) { let id = req.get('rsc-action-id'); - let request = new Request('http://localhost' + req.url, { - method: 'POST', - headers: req.headers as any, - body: Readable.toWeb(req) as ReadableStream, - // @ts-ignore - duplex: 'half' - }); - + let result = await callAction(req, id); + let root: any = ; if (id) { - let action = await loadServerAction(id); - let body = req.is('multipart/form-data') ? await request.formData() : await request.text(); - let args = await decodeReply(body); - let result = action.apply(null, args); - try { - // Wait for any mutations - await result; - } catch (x) { - // We handle the error on the client - } - - await render(req, res, component, result); - } else { - // Form submitted by browser (progressive enhancement). - let formData = await request.formData(); - let action = await decodeAction(formData); - try { - // Wait for any mutations - await action(); - } catch (err) { - // TODO render error page? - } - await render(req, res, component); + root = {result, root}; } -} + await renderRequest(req, res, root, {component: Page}); +}); app.listen(3001); console.log('Server listening on port 3001'); diff --git a/packages/utils/create-parcel/templates/react-server/types.d.ts b/packages/utils/create-parcel/templates/react-server/types.d.ts deleted file mode 100644 index 302d1f3d679..00000000000 --- a/packages/utils/create-parcel/templates/react-server/types.d.ts +++ /dev/null @@ -1,23 +0,0 @@ -declare module 'react-server-dom-parcel/client' { - export function createFromFetch(res: Promise): Promise; - export function createFromReadableStream( - stream: ReadableStream, - ): Promise; - export function encodeReply( - value: any, - ): Promise; - - type CallServerCallback = (id: string, args: any[]) => Promise; - export function setServerCallback(cb: CallServerCallback): void; -} - -declare module 'react-server-dom-parcel/server.edge' { - export function renderToReadableStream(value: any): ReadableStream; - export function loadServerAction( - id: string, - ): Promise<(...args: any[]) => any>; - export function decodeReply(body: string | FormData): Promise; - export function decodeAction( - body: FormData, - ): Promise<(...args: any[]) => any>; -} diff --git a/packages/utils/create-parcel/templates/react-static/package.json b/packages/utils/create-parcel/templates/react-static/package.json index 4f4755f6536..455c7c459f0 100644 --- a/packages/utils/create-parcel/templates/react-static/package.json +++ b/packages/utils/create-parcel/templates/react-static/package.json @@ -14,10 +14,9 @@ "build": "parcel build" }, "dependencies": { + "@parcel/rsc": "canary", "react": "canary", - "react-dom": "canary", - "react-server-dom-parcel": "canary", - "rsc-html-stream": "^0.0.4" + "react-dom": "canary" }, "devDependencies": { "@parcel/config-react-static": "canary", diff --git a/packages/utils/create-parcel/templates/react-static/src/client.tsx b/packages/utils/create-parcel/templates/react-static/src/client.tsx index bacdcb3e55d..9168cb7604c 100644 --- a/packages/utils/create-parcel/templates/react-static/src/client.tsx +++ b/packages/utils/create-parcel/templates/react-static/src/client.tsx @@ -1,41 +1,24 @@ "use client-entry"; -import {useState, use, startTransition, useInsertionEffect, ReactElement} from 'react'; -import {hydrateRoot} from 'react-dom/client'; -import {createFromReadableStream, createFromFetch} from 'react-server-dom-parcel/client'; -import {rscStream} from 'rsc-html-stream/client'; +import type { ReactNode } from 'react'; +import {hydrate, fetchRSC} from '@parcel/rsc/client'; -// Stream in initial RSC payload embedded in the HTML. -let initialRSCPayload = createFromReadableStream(rscStream); -let updateRoot: ((root: ReactElement, cb?: () => void) => void) | null = null; - -function Content() { - // Store the current root element in state, along with a callback - // to call once rendering is complete. - let [[root, cb], setRoot] = useState<[ReactElement, (() => void) | null]>([use(initialRSCPayload), null]); - updateRoot = (root, cb) => setRoot([root, cb ?? null]); - useInsertionEffect(() => cb?.()); - return root; -} - -// Hydrate initial page content. -startTransition(() => { - hydrateRoot(document, ); +let updateRoot = hydrate({ + // Intercept HMR window reloads, and do it with RSC instead. + onHmrReload() { + navigate(location.pathname); + } }); // A very simple router. When we navigate, we'll fetch a new RSC payload, // and in a React transition, stream in the new page. Once complete, we'll // pushState to update the URL in the browser. async function navigate(pathname: string, push = false) { - let res = fetch(pathname.replace(/\.html$/, '.rsc')); - let root = await createFromFetch(res); - startTransition(() => { - updateRoot!(root, () => { - if (push) { - history.pushState(null, '', pathname); - push = false; - } - }); + let root = await fetchRSC(pathname.replace(/\.html$/, '.rsc')); + updateRoot(root, () => { + if (push) { + history.pushState(null, '', pathname); + } }); } @@ -65,9 +48,3 @@ document.addEventListener('click', e => { window.addEventListener('popstate', e => { navigate(location.pathname); }); - -// Intercept HMR window reloads, and do it with RSC instead. -window.addEventListener('parcelhmrreload', e => { - e.preventDefault(); - navigate(location.pathname); -}); diff --git a/packages/utils/create-parcel/templates/react-static/types.d.ts b/packages/utils/create-parcel/templates/react-static/types.d.ts deleted file mode 100644 index 9adcdb6974a..00000000000 --- a/packages/utils/create-parcel/templates/react-static/types.d.ts +++ /dev/null @@ -1,12 +0,0 @@ -declare module 'react-server-dom-parcel/client' { - export function createFromFetch(res: Promise): Promise; - export function createFromReadableStream( - stream: ReadableStream, - ): Promise; - export function encodeReply( - value: any, - ): Promise; - - type CallServerCallback = (id: string, args: any[]) => Promise; - export function setServerCallback(cb: CallServerCallback): void; -} diff --git a/packages/utils/rsc/package.json b/packages/utils/rsc/package.json new file mode 100644 index 00000000000..676966d8e0e --- /dev/null +++ b/packages/utils/rsc/package.json @@ -0,0 +1,31 @@ +{ + "name": "@parcel/rsc", + "version": "2.13.0", + "exports": { + "./client": { + "types": "./lib/client.d.ts", + "default": "./src/client.tsx" + }, + "./server": { + "types": "./lib/server.d.ts", + "default": "./src/server.tsx" + }, + "./node": { + "types": "./lib/node.d.ts", + "default": "./src/node.tsx" + } + }, + "dependencies": { + "react-server-dom-parcel": "canary", + "rsc-html-stream": "^0.0.4" + }, + "devDependencies": { + "@types/node": ">= 18", + "@types/react": "^19", + "@types/react-dom": "^19" + }, + "peerDependencies": { + "react": "^19", + "react-dom": "^19" + } +} diff --git a/packages/utils/rsc/src/client.tsx b/packages/utils/rsc/src/client.tsx new file mode 100644 index 00000000000..0871de2559b --- /dev/null +++ b/packages/utils/rsc/src/client.tsx @@ -0,0 +1,64 @@ +import { ReactNode, startTransition, useInsertionEffect } from 'react'; +import {createFromReadableStream, createFromFetch, encodeReply, setServerCallback, createTemporaryReferenceSet} from 'react-server-dom-parcel/client'; +import {rscStream} from 'rsc-html-stream/client'; +import { hydrateRoot, HydrationOptions, Root } from 'react-dom/client'; + +// Stream in initial RSC payload embedded in the HTML. +let initialRSCPayload: Promise; +function RSCRoot({value, cb}: {value?: ReactNode, cb?: () => void}) { + initialRSCPayload ??= createFromReadableStream(rscStream); + useInsertionEffect(() => { + cb?.(); + }); + return value === undefined ? initialRSCPayload : value; +} + +export type CallServerCallback = (id: string, args: any[]) => Promise; +export interface HydrateOptions extends HydrationOptions { + handleServerAction?: CallServerCallback, + onHmrReload?: () => void +} + +export function hydrate(options?: HydrateOptions): (value: ReactNode, cb?: () => void) => void { + if (options?.handleServerAction) { + setServerCallback(options.handleServerAction); + } + + if (options?.onHmrReload) { + window.addEventListener('parcelhmrreload', e => { + e.preventDefault(); + options?.onHmrReload?.(); + }); + } + + let root: Root; + startTransition(() => { + root = hydrateRoot(document, , options); + }); + + return (value: ReactNode, cb?: () => void) => { + startTransition(() => { + root?.render( {cb?.(); cb = undefined}} />); + }); + }; +} + +export interface RSCRequestInit extends Omit { + body?: any +} + +export async function fetchRSC(url: string | URL | Request, options?: RSCRequestInit): Promise { + const temporaryReferences = createTemporaryReferenceSet(); + const response = fetch(url, { + ...options, + headers: { + Accept: 'text/x-component', + ...options?.headers, + }, + body: options && 'body' in options + ? await encodeReply(options.body, {temporaryReferences, signal: options?.signal}) + : undefined, + }); + + return createFromFetch(response, {temporaryReferences}); +} diff --git a/packages/utils/rsc/src/node.tsx b/packages/utils/rsc/src/node.tsx new file mode 100644 index 00000000000..27ddb240251 --- /dev/null +++ b/packages/utils/rsc/src/node.tsx @@ -0,0 +1,48 @@ +import type { IncomingMessage, ServerResponse } from 'http'; +import type { ReadableStream as NodeReadableStream } from 'stream/web'; + +import {Readable} from 'stream'; +import {renderToReadableStream, createTemporaryReferenceSet} from 'react-server-dom-parcel/server.edge'; +import {RSCToHTMLOptions, RSCOptions, renderRSCToHTML as renderRSCToHTMLBase, callAction as callActionBase} from './server'; + +export function renderRSC(root: any, options?: RSCOptions): Readable { + return Readable.fromWeb(renderToReadableStream(root, options) as NodeReadableStream); +} + +export async function renderHTML(root: any, options?: RSCToHTMLOptions): Promise { + let htmlStream = await renderRSCToHTMLBase(root, options); + return Readable.fromWeb(htmlStream as NodeReadableStream); +} + +const temporaryReferencesSymbol = Symbol.for('temporaryReferences') + +export async function renderRequest(request: IncomingMessage, response: ServerResponse, root: any, options?: RSCToHTMLOptions): Promise { + options = { + ...options, + temporaryReferences: options?.temporaryReferences ?? (request as any)[temporaryReferencesSymbol] + }; + + if (request.headers.accept?.includes('text/html')) { + let html = await renderHTML(root, options); + response.setHeader('Content-Type', 'text/html'); + html.pipe(response); + } else { + response.setHeader('Content-Type', 'text/x-component'); + renderRSC(root, options).pipe(response); + } +} + +export async function callAction(request: IncomingMessage, id: string | null | undefined): Promise<{result: any}> { + (request as any)[temporaryReferencesSymbol] ??= createTemporaryReferenceSet(); + + let req = new Request('http://localhost' + request.url, { + method: 'POST', + headers: request.headers as any, + body: Readable.toWeb(request) as ReadableStream, + // @ts-ignore + duplex: 'half' + }); + + (req as any)[temporaryReferencesSymbol] = (request as any)[temporaryReferencesSymbol]; + return callActionBase(req, id); +} diff --git a/packages/utils/rsc/src/server.tsx b/packages/utils/rsc/src/server.tsx new file mode 100644 index 00000000000..c96b2167eae --- /dev/null +++ b/packages/utils/rsc/src/server.tsx @@ -0,0 +1,117 @@ +import type { ErrorInfo } from 'react-dom/client'; + +// Server dependencies. +import {renderToReadableStream, loadServerAction, decodeReply, decodeAction, createTemporaryReferenceSet} from 'react-server-dom-parcel/server.edge'; +import {injectRSCPayload} from 'rsc-html-stream/server'; + +// Client dependencies, used for SSR. +// These must run in the same environment as client components (e.g. same instance of React). +import {createFromReadableStream} from 'react-server-dom-parcel/client.edge' with {env: 'react-client'}; +import {renderToReadableStream as renderHTMLToReadableStream} from 'react-dom/server.edge' with {env: 'react-client'}; +import {ComponentType, ReactNode} from 'react' with {env: 'react-client'}; + +export interface RSCOptions { + // environmentName?: string | (() => string), + // filterStackFrame?: (url: string, functionName: string) => boolean, + identifierPrefix?: string, + signal?: AbortSignal, + temporaryReferences?: any, + onError?: (error: unknown) => void, + onPostpone?: (reason: string) => void, +} + +export function renderRSC(root: any, options?: RSCOptions): ReadableStream { + return renderToReadableStream(root, options); +} + +export interface RSCToHTMLOptions { + component?: ComponentType, + identifierPrefix?: string; + namespaceURI?: string; + nonce?: string; + progressiveChunkSize?: number; + signal?: AbortSignal; + temporaryReferences?: any, + onError?: (error: unknown, errorInfo?: ErrorInfo) => string | void; +} + +export async function renderRSCToHTML(root: any, options?: RSCToHTMLOptions): Promise { + let rscStream = renderToReadableStream(root, options); + + // Use client react to render the RSC payload to HTML. + let [s1, s2] = rscStream.tee(); + let data: Promise; + function Content() { + // Important: this must be constructed inside a component for preinit scripts to be inserted. + data ??= createFromReadableStream(s1); + return data; + } + + let htmlStream = await renderHTMLToReadableStream(, { + ...options, + bootstrapScriptContent: (options?.component as any)?.bootstrapScript + }); + + return htmlStream.pipeThrough(injectRSCPayload(s2)); +} + +export interface RenderRequestOptions extends RSCToHTMLOptions { + headers?: HeadersInit +} + +const temporaryReferencesSymbol = Symbol.for('temporaryReferences') + +export async function renderRequest(request: Request, root: any, options?: RenderRequestOptions): Promise { + options = { + ...options, + temporaryReferences: options?.temporaryReferences ?? (request as any)[temporaryReferencesSymbol] + }; + + if (request.headers.get('Accept')?.includes('text/html')) { + let html = await renderRSCToHTML(root, options); + return new Response(html, { + headers: { + ...options?.headers, + 'Content-Type': 'text/html' + } + }); + } else { + let rscStream = renderToReadableStream(root, options); + return new Response(rscStream, { + headers: { + ...options?.headers, + 'Content-Type': 'text/x-component' + } + }); + } +} + +export async function callAction(request: Request, id: string | null | undefined): Promise<{result: any}> { + (request as any)[temporaryReferencesSymbol] ??= createTemporaryReferenceSet(); + + if (id) { + let action = await loadServerAction(id); + let body = request.headers.get('content-type')?.includes('multipart/form-data') + ? await request.formData() + : await request.text(); + let args = await decodeReply(body, { + temporaryReferences: (request as any)[temporaryReferencesSymbol] + }); + + let result = action.apply(null, args); + try { + // Wait for any mutations + await result; + } catch { + // Handle the error on the client + } + return {result}; + } else { + // Form submitted by browser (progressive enhancement). + let formData = await request.formData(); + let action = await decodeAction(formData); + // Don't catch error here: this should be handled by the caller (e.g. render an error page). + let result = await action(); + return {result}; + } +} diff --git a/packages/utils/rsc/tsconfig.json b/packages/utils/rsc/tsconfig.json new file mode 100644 index 00000000000..62b153e8e5a --- /dev/null +++ b/packages/utils/rsc/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig to read more about this file */ + "target": "ES2020", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "useDefineForClassFields": true, + + /* Modules */ + "module": "ESNext", + "moduleResolution": "bundler", + + /* Emit */ + "noEmit": true, + "declaration": true, + + /* Interop Constraints */ + "isolatedModules": true, + "isolatedDeclarations": true, + "allowSyntheticDefaultImports": true, + "allowImportingTsExtensions": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + + /* Type Checking */ + "strict": true, + + /* Completeness */ + "skipLibCheck": true + } +} diff --git a/packages/utils/rsc/types.d.ts b/packages/utils/rsc/types.d.ts new file mode 100644 index 00000000000..91bcbf99c88 --- /dev/null +++ b/packages/utils/rsc/types.d.ts @@ -0,0 +1,81 @@ +declare module 'react-server-dom-parcel/client' { + type TemporaryReferenceSet = {__ref: true}; + type ReactCustomFormAction = { + name?: string; + action?: string; + encType?: string; + method?: string; + target?: string; + data?: null | FormData; + }; + type EncodeFormActionCallback = ( + id: any, + args: Promise, + ) => ReactCustomFormAction; + type Options = { + nonce?: string; + encodeFormAction?: EncodeFormActionCallback; + temporaryReferences?: TemporaryReferenceSet; + replayConsoleLogs?: boolean; + environmentName?: string; + }; + + export function createFromFetch( + res: Promise, + options?: Options, + ): Promise; + export function createFromReadableStream( + stream: ReadableStream, + options?: Options, + ): Promise; + export function encodeReply( + value: any, + options?: { + temporaryReferences?: TemporaryReferenceSet; + signal?: AbortSignal | null | undefined; + }, + ): Promise; + export function createTemporaryReferenceSet(): TemporaryReferenceSet; + + type CallServerCallback = (id: string, args: any[]) => Promise; + export function setServerCallback(cb: CallServerCallback): void; +} + +declare module 'react-server-dom-parcel/client.edge' { + export function createFromReadableStream( + stream: ReadableStream, + ): Promise; +} + +declare module 'react-server-dom-parcel/server.edge' { + type TemporaryReferenceSet = {__ref: true}; + type Options = { + environmentName?: string | (() => string); + filterStackFrame?: (url: string, functionName: string) => boolean; + identifierPrefix?: string; + signal?: AbortSignal; + temporaryReferences?: TemporaryReferenceSet; + onError?: (error: unknown) => void; + onPostpone?: (reason: string) => void; + }; + + export function renderToReadableStream( + value: any, + options?: Options, + ): ReadableStream; + export function loadServerAction( + id: string, + ): Promise<(...args: any[]) => Promise>; + export function decodeReply( + body: string | FormData, + options?: {temporaryReferences?: TemporaryReferenceSet}, + ): Promise; + export function decodeAction( + body: FormData, + ): Promise<(...args: any[]) => Promise>; + export function createTemporaryReferenceSet(): TemporaryReferenceSet; +} + +declare module 'react-dom/server.edge' { + export * from 'react-dom/server'; +} diff --git a/yarn.lock b/yarn.lock index 8d9c45cdcbe..3b2fbe033ad 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2667,14 +2667,14 @@ integrity sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw== "@types/react-dom@^19": - version "19.0.2" - resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.0.2.tgz#ad21f9a1ee881817995fd3f7fd33659c87e7b1b7" - integrity sha512-c1s+7TKFaDRRxr1TxccIX2u7sfCnc3RxkVyBIUA2lCpyqCF+QoAwQ/CBg7bsMdVwP120HEH143VQezKtef5nCg== + version "19.0.3" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.0.3.tgz#0804dfd279a165d5a0ad8b53a5b9e65f338050a4" + integrity sha512-0Knk+HJiMP/qOZgMyNFamlIjw9OFCsyC2ZbigmEEyXXixgre6IQpm/4V+r3qH4GC1JPvRJKInw+on2rV6YZLeA== "@types/react@^19": - version "19.0.3" - resolved "https://registry.yarnpkg.com/@types/react/-/react-19.0.3.tgz#7867240defc1a3686f151644ac886a7e8e0868f4" - integrity sha512-UavfHguIjnnuq9O67uXfgy/h3SRJbidAYvNjLceB+2RIKVRBzVsh0QO+Pw6BCSQqFS9xwzKfwstXx0m6AbAREA== + version "19.0.7" + resolved "https://registry.yarnpkg.com/@types/react/-/react-19.0.7.tgz#c451968b999d1cb2d9207dc5ff56496164cf511d" + integrity sha512-MoFsEJKkAtZCrC1r6CM8U22GzhG7u2Wir8ons/aCKH6MBdD1ibV24zOSSkdZVUKqN5i396zG5VKLYZ3yaUZdLA== dependencies: csstype "^3.0.2" @@ -13270,7 +13270,7 @@ string-natural-compare@^3.0.1: resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4" integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw== -"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -13296,6 +13296,15 @@ string-width@^1.0.1, string-width@^1.0.2: is-fullwidth-code-point "^2.0.0" strip-ansi "^4.0.0" +"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + string-width@^5.0.1, string-width@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" @@ -13396,7 +13405,7 @@ stringify-object@^3.3.0: is-obj "^1.0.1" is-regexp "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -13417,6 +13426,13 @@ strip-ansi@^4.0.0: dependencies: ansi-regex "^3.0.0" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -14810,7 +14826,7 @@ workerpool@6.1.0: resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.1.0.tgz#a8e038b4c94569596852de7a8ea4228eefdeb37b" integrity sha512-toV7q9rWNYha963Pl/qyeZ6wG+3nnsyvolaNUS8+R5Wtw6qJPTxIlOP1ZSvcGhEJw+l3HMMmtiNo9Gl61G4GVg== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -14836,6 +14852,15 @@ wrap-ansi@^6.0.1, wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" From db9992d63e0b830e98eae24b883353f89aa8d675 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Mon, 20 Jan 2025 16:42:11 -0500 Subject: [PATCH 2/2] Fix lint --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 2b9177ab42d..3bce8501439 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "@khanacademy/flow-to-ts": "^0.5.2", "@napi-rs/cli": "^2.18.3", "@parcel/babel-register": "*", + "@swc/core": "^1.10.7", "@types/node": ">= 18", "buffer": "mischnic/buffer#b8a4fa94", "cross-env": "^7.0.0",