diff --git a/packages/react-dom/npm/server.node.js b/packages/react-dom/npm/server.node.js
index 0373a33b3a7..f5e7b82597c 100644
--- a/packages/react-dom/npm/server.node.js
+++ b/packages/react-dom/npm/server.node.js
@@ -1,12 +1,14 @@
'use strict';
-var l, s;
+var l, s, w;
if (process.env.NODE_ENV === 'production') {
l = require('./cjs/react-dom-server-legacy.node.production.js');
s = require('./cjs/react-dom-server.node.production.js');
+ w = require('./cjs/react-dom-server.node-webstreams.production.js');
} else {
l = require('./cjs/react-dom-server-legacy.node.development.js');
s = require('./cjs/react-dom-server.node.development.js');
+ w = require('./cjs/react-dom-server.node-webstreams.development.js');
}
exports.version = l.version;
@@ -16,3 +18,7 @@ exports.renderToPipeableStream = s.renderToPipeableStream;
if (s.resumeToPipeableStream) {
exports.resumeToPipeableStream = s.resumeToPipeableStream;
}
+exports.renderToReadableStream = w.renderToReadableStream;
+if (w.resume) {
+ exports.resume = w.resume;
+}
diff --git a/packages/react-dom/npm/static.node.js b/packages/react-dom/npm/static.node.js
index 5dc47d472ba..60936401c9b 100644
--- a/packages/react-dom/npm/static.node.js
+++ b/packages/react-dom/npm/static.node.js
@@ -1,12 +1,16 @@
'use strict';
-var s;
+var s, w;
if (process.env.NODE_ENV === 'production') {
s = require('./cjs/react-dom-server.node.production.js');
+ w = require('./cjs/react-dom-server.node-webstreams.production.js');
} else {
s = require('./cjs/react-dom-server.node.development.js');
+ w = require('./cjs/react-dom-server.node-webstreams.development.js');
}
exports.version = s.version;
exports.prerenderToNodeStream = s.prerenderToNodeStream;
exports.resumeAndPrerenderToNodeStream = s.resumeAndPrerenderToNodeStream;
+exports.prerender = w.prerender;
+exports.resumeAndPrerender = w.resumeAndPrerender;
diff --git a/packages/react-dom/server.node.js b/packages/react-dom/server.node.js
index 5f9c78f6dbd..2e25bc044b6 100644
--- a/packages/react-dom/server.node.js
+++ b/packages/react-dom/server.node.js
@@ -37,3 +37,17 @@ export function resumeToPipeableStream() {
arguments,
);
}
+
+export function renderToReadableStream() {
+ return require('./src/server/react-dom-server.node-webstreams').renderToReadableStream.apply(
+ this,
+ arguments,
+ );
+}
+
+export function resume() {
+ return require('./src/server/react-dom-server.node-webstreams').resume.apply(
+ this,
+ arguments,
+ );
+}
diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServerNodeWebStreams-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServerNodeWebStreams-test.js
new file mode 100644
index 00000000000..403beefeda4
--- /dev/null
+++ b/packages/react-dom/src/__tests__/ReactDOMFizzServerNodeWebStreams-test.js
@@ -0,0 +1,43 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @emails react-core
+ * @jest-environment node
+ */
+
+'use strict';
+
+let React;
+let ReactDOMFizzServer;
+
+describe('ReactDOMFizzServerNodeWebStreams', () => {
+ beforeEach(() => {
+ jest.resetModules();
+ jest.useRealTimers();
+ React = require('react');
+ ReactDOMFizzServer = require('react-dom/server.node');
+ });
+
+ async function readResult(stream) {
+ const reader = stream.getReader();
+ let result = '';
+ while (true) {
+ const {done, value} = await reader.read();
+ if (done) {
+ return result;
+ }
+ result += Buffer.from(value).toString('utf8');
+ }
+ }
+
+ it('should call renderToPipeableStream', async () => {
+ const stream = await ReactDOMFizzServer.renderToReadableStream(
+
hello world
,
+ );
+ const result = await readResult(stream);
+ expect(result).toMatchInlineSnapshot(`"hello world
"`);
+ });
+});
diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzStaticNodeWebStreams-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzStaticNodeWebStreams-test.js
new file mode 100644
index 00000000000..9b328f6bbf4
--- /dev/null
+++ b/packages/react-dom/src/__tests__/ReactDOMFizzStaticNodeWebStreams-test.js
@@ -0,0 +1,177 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @emails react-core
+ * @jest-environment node
+ */
+
+'use strict';
+
+import {
+ getVisibleChildren,
+ insertNodesAndExecuteScripts,
+} from '../test-utils/FizzTestUtils';
+
+let JSDOM;
+let React;
+let ReactDOMFizzServer;
+let ReactDOMFizzStatic;
+let Suspense;
+let container;
+let serverAct;
+
+describe('ReactDOMFizzStaticNodeWebStreams', () => {
+ beforeEach(() => {
+ jest.resetModules();
+ serverAct = require('internal-test-utils').serverAct;
+
+ JSDOM = require('jsdom').JSDOM;
+
+ React = require('react');
+ ReactDOMFizzServer = require('react-dom/server.node');
+ ReactDOMFizzStatic = require('react-dom/static.node');
+ Suspense = React.Suspense;
+
+ const jsdom = new JSDOM(
+ // The Fizz runtime assumes requestAnimationFrame exists so we need to polyfill it.
+ '',
+ {
+ runScripts: 'dangerously',
+ },
+ );
+ global.window = jsdom.window;
+ global.document = jsdom.window.document;
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(container);
+ });
+
+ async function readContent(stream) {
+ const reader = stream.getReader();
+ let content = '';
+ while (true) {
+ const {done, value} = await reader.read();
+ if (done) {
+ return content;
+ }
+ content += Buffer.from(value).toString('utf8');
+ }
+ }
+
+ async function readIntoContainer(stream) {
+ const reader = stream.getReader();
+ let result = '';
+ while (true) {
+ const {done, value} = await reader.read();
+ if (done) {
+ break;
+ }
+ result += Buffer.from(value).toString('utf8');
+ }
+ const temp = document.createElement('div');
+ temp.innerHTML = result;
+ await insertNodesAndExecuteScripts(temp, container, null);
+ jest.runAllTimers();
+ }
+
+ it('should call prerender', async () => {
+ const result = await serverAct(() =>
+ ReactDOMFizzStatic.prerender(hello world
),
+ );
+ const prelude = await readContent(result.prelude);
+ expect(prelude).toMatchInlineSnapshot(`"hello world
"`);
+ });
+
+ // @gate enableHalt
+ it('can resume render of a prerender', async () => {
+ const errors = [];
+
+ let resolveA;
+ const promiseA = new Promise(r => (resolveA = r));
+ let resolveB;
+ const promiseB = new Promise(r => (resolveB = r));
+
+ async function ComponentA() {
+ await promiseA;
+ return (
+
+
+
+ );
+ }
+
+ async function ComponentB() {
+ await promiseB;
+ return 'Hello';
+ }
+
+ function App() {
+ return (
+
+
+
+
+
+ );
+ }
+
+ const controller = new AbortController();
+ let pendingResult;
+ await serverAct(async () => {
+ pendingResult = ReactDOMFizzStatic.prerender(, {
+ signal: controller.signal,
+ onError(x) {
+ errors.push(x.message);
+ },
+ });
+ });
+
+ controller.abort();
+ const prerendered = await pendingResult;
+ const postponedState = JSON.stringify(prerendered.postponed);
+
+ await readIntoContainer(prerendered.prelude);
+ expect(getVisibleChildren(container)).toEqual(Loading A
);
+
+ await resolveA();
+
+ expect(prerendered.postponed).not.toBe(null);
+
+ const controller2 = new AbortController();
+ await serverAct(async () => {
+ pendingResult = ReactDOMFizzStatic.resumeAndPrerender(
+ ,
+ JSON.parse(postponedState),
+ {
+ signal: controller2.signal,
+ onError(x) {
+ errors.push(x.message);
+ },
+ },
+ );
+ });
+
+ controller2.abort();
+
+ const prerendered2 = await pendingResult;
+ const postponedState2 = JSON.stringify(prerendered2.postponed);
+
+ await readIntoContainer(prerendered2.prelude);
+ expect(getVisibleChildren(container)).toEqual(Loading B
);
+
+ await resolveB();
+
+ const dynamic = await serverAct(() =>
+ ReactDOMFizzServer.resume(, JSON.parse(postponedState2)),
+ );
+
+ await readIntoContainer(dynamic);
+ expect(getVisibleChildren(container)).toEqual(Hello
);
+ });
+});
diff --git a/packages/react-dom/src/server/react-dom-server.node-webstreams.js b/packages/react-dom/src/server/react-dom-server.node-webstreams.js
new file mode 100644
index 00000000000..e70e8fd4cbe
--- /dev/null
+++ b/packages/react-dom/src/server/react-dom-server.node-webstreams.js
@@ -0,0 +1,11 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @flow
+ */
+
+export * from './ReactDOMFizzServerEdge.js';
+export {prerender, resumeAndPrerender} from './ReactDOMFizzStaticEdge.js';
diff --git a/packages/react-dom/src/server/react-dom-server.node-webstreams.stable.js b/packages/react-dom/src/server/react-dom-server.node-webstreams.stable.js
new file mode 100644
index 00000000000..5f47ecafd37
--- /dev/null
+++ b/packages/react-dom/src/server/react-dom-server.node-webstreams.stable.js
@@ -0,0 +1,11 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @flow
+ */
+
+export {renderToReadableStream, version} from './ReactDOMFizzServerEdge.js';
+export {prerender} from './ReactDOMFizzStaticEdge.js';
diff --git a/packages/react-dom/static.node.js b/packages/react-dom/static.node.js
index a25c88af4d9..b9d037bdaa0 100644
--- a/packages/react-dom/static.node.js
+++ b/packages/react-dom/static.node.js
@@ -3,12 +3,51 @@
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
- *
- * @flow
*/
-export {
- prerenderToNodeStream,
- resumeAndPrerenderToNodeStream,
- version,
-} from './src/server/react-dom-server.node';
+// This file is only used for tests.
+// It lazily loads the implementation so that we get the correct set of host configs.
+
+import ReactVersion from 'shared/ReactVersion';
+export {ReactVersion as version};
+
+export function renderToString() {
+ return require('./src/server/ReactDOMLegacyServerNode').renderToString.apply(
+ this,
+ arguments,
+ );
+}
+export function renderToStaticMarkup() {
+ return require('./src/server/ReactDOMLegacyServerNode').renderToStaticMarkup.apply(
+ this,
+ arguments,
+ );
+}
+
+export function prerenderToNodeStream() {
+ return require('./src/server/react-dom-server.node').prerenderToNodeStream.apply(
+ this,
+ arguments,
+ );
+}
+
+export function resumeAndPrerenderToNodeStream() {
+ return require('./src/server/react-dom-server.node').resumeAndPrerenderToNodeStream.apply(
+ this,
+ arguments,
+ );
+}
+
+export function prerender() {
+ return require('./src/server/react-dom-server.node-webstreams').prerender.apply(
+ this,
+ arguments,
+ );
+}
+
+export function resumeAndPrerender() {
+ return require('./src/server/react-dom-server.node-webstreams').resumeAndPrerender.apply(
+ this,
+ arguments,
+ );
+}
diff --git a/packages/react-server/src/ReactServerStreamConfigNodeWebStreams.js b/packages/react-server/src/ReactServerStreamConfigNodeWebStreams.js
new file mode 100644
index 00000000000..16df0dfc371
--- /dev/null
+++ b/packages/react-server/src/ReactServerStreamConfigNodeWebStreams.js
@@ -0,0 +1,186 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @flow
+ */
+
+import {TextEncoder} from 'util';
+import {createHash} from 'crypto';
+
+export type Destination = ReadableStreamController;
+
+export type PrecomputedChunk = Uint8Array;
+export opaque type Chunk = Uint8Array;
+export type BinaryChunk = Uint8Array;
+
+export function scheduleWork(callback: () => void) {
+ setImmediate(callback);
+}
+
+export const scheduleMicrotask = queueMicrotask;
+
+export function flushBuffered(destination: Destination) {
+ // WHATWG Streams do not yet have a way to flush the underlying
+ // transform streams. https://github.com/whatwg/streams/issues/960
+}
+
+const VIEW_SIZE = 2048;
+let currentView = null;
+let writtenBytes = 0;
+
+export function beginWriting(destination: Destination) {
+ currentView = new Uint8Array(VIEW_SIZE);
+ writtenBytes = 0;
+}
+
+export function writeChunk(
+ destination: Destination,
+ chunk: PrecomputedChunk | Chunk | BinaryChunk,
+): void {
+ if (chunk.byteLength === 0) {
+ return;
+ }
+
+ if (chunk.byteLength > VIEW_SIZE) {
+ // this chunk may overflow a single view which implies it was not
+ // one that is cached by the streaming renderer. We will enqueu
+ // it directly and expect it is not re-used
+ if (writtenBytes > 0) {
+ destination.enqueue(
+ new Uint8Array(
+ ((currentView: any): Uint8Array).buffer,
+ 0,
+ writtenBytes,
+ ),
+ );
+ currentView = new Uint8Array(VIEW_SIZE);
+ writtenBytes = 0;
+ }
+ destination.enqueue(chunk);
+ return;
+ }
+
+ let bytesToWrite = chunk;
+ const allowableBytes = ((currentView: any): Uint8Array).length - writtenBytes;
+ if (allowableBytes < bytesToWrite.byteLength) {
+ // this chunk would overflow the current view. We enqueue a full view
+ // and start a new view with the remaining chunk
+ if (allowableBytes === 0) {
+ // the current view is already full, send it
+ destination.enqueue(currentView);
+ } else {
+ // fill up the current view and apply the remaining chunk bytes
+ // to a new view.
+ ((currentView: any): Uint8Array).set(
+ bytesToWrite.subarray(0, allowableBytes),
+ writtenBytes,
+ );
+ // writtenBytes += allowableBytes; // this can be skipped because we are going to immediately reset the view
+ destination.enqueue(currentView);
+ bytesToWrite = bytesToWrite.subarray(allowableBytes);
+ }
+ currentView = new Uint8Array(VIEW_SIZE);
+ writtenBytes = 0;
+ }
+ ((currentView: any): Uint8Array).set(bytesToWrite, writtenBytes);
+ writtenBytes += bytesToWrite.byteLength;
+}
+
+export function writeChunkAndReturn(
+ destination: Destination,
+ chunk: PrecomputedChunk | Chunk | BinaryChunk,
+): boolean {
+ writeChunk(destination, chunk);
+ // in web streams there is no backpressure so we can alwas write more
+ return true;
+}
+
+export function completeWriting(destination: Destination) {
+ if (currentView && writtenBytes > 0) {
+ destination.enqueue(new Uint8Array(currentView.buffer, 0, writtenBytes));
+ currentView = null;
+ writtenBytes = 0;
+ }
+}
+
+export function close(destination: Destination) {
+ destination.close();
+}
+
+const textEncoder = new TextEncoder();
+
+export function stringToChunk(content: string): Chunk {
+ return textEncoder.encode(content);
+}
+
+export function stringToPrecomputedChunk(content: string): PrecomputedChunk {
+ const precomputedChunk = textEncoder.encode(content);
+
+ if (__DEV__) {
+ if (precomputedChunk.byteLength > VIEW_SIZE) {
+ console.error(
+ 'precomputed chunks must be smaller than the view size configured for this host. This is a bug in React.',
+ );
+ }
+ }
+
+ return precomputedChunk;
+}
+
+export function typedArrayToBinaryChunk(
+ content: $ArrayBufferView,
+): BinaryChunk {
+ // Convert any non-Uint8Array array to Uint8Array. We could avoid this for Uint8Arrays.
+ // If we passed through this straight to enqueue we wouldn't have to convert it but since
+ // we need to copy the buffer in that case, we need to convert it to copy it.
+ // When we copy it into another array using set() it needs to be a Uint8Array.
+ const buffer = new Uint8Array(
+ content.buffer,
+ content.byteOffset,
+ content.byteLength,
+ );
+ // We clone large chunks so that we can transfer them when we write them.
+ // Others get copied into the target buffer.
+ return content.byteLength > VIEW_SIZE ? buffer.slice() : buffer;
+}
+
+export function byteLengthOfChunk(chunk: Chunk | PrecomputedChunk): number {
+ return chunk.byteLength;
+}
+
+export function byteLengthOfBinaryChunk(chunk: BinaryChunk): number {
+ return chunk.byteLength;
+}
+
+export function closeWithError(destination: Destination, error: mixed): void {
+ // $FlowFixMe[method-unbinding]
+ if (typeof destination.error === 'function') {
+ // $FlowFixMe[incompatible-call]: This is an Error object or the destination accepts other types.
+ destination.error(error);
+ } else {
+ // Earlier implementations doesn't support this method. In that environment you're
+ // supposed to throw from a promise returned but we don't return a promise in our
+ // approach. We could fork this implementation but this is environment is an edge
+ // case to begin with. It's even less common to run this in an older environment.
+ // Even then, this is not where errors are supposed to happen and they get reported
+ // to a global callback in addition to this anyway. So it's fine just to close this.
+ destination.close();
+ }
+}
+
+export function createFastHash(input: string): string | number {
+ const hash = createHash('md5');
+ hash.update(input);
+ return hash.digest('hex');
+}
+
+export function readAsDataURL(blob: Blob): Promise {
+ return blob.arrayBuffer().then(arrayBuffer => {
+ const encoded = Buffer.from(arrayBuffer).toString('base64');
+ const mimeType = blob.type || 'application/octet-stream';
+ return 'data:' + mimeType + ';base64,' + encoded;
+ });
+}
diff --git a/packages/react-server/src/forks/ReactServerStreamConfig.dom-node-webstreams.js b/packages/react-server/src/forks/ReactServerStreamConfig.dom-node-webstreams.js
new file mode 100644
index 00000000000..7862f283eee
--- /dev/null
+++ b/packages/react-server/src/forks/ReactServerStreamConfig.dom-node-webstreams.js
@@ -0,0 +1,10 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @flow
+ */
+
+export * from '../ReactServerStreamConfigNodeWebStreams';
diff --git a/packages/react/src/__tests__/ReactMismatchedVersions-test.js b/packages/react/src/__tests__/ReactMismatchedVersions-test.js
index 234df6c8e48..d815f298641 100644
--- a/packages/react/src/__tests__/ReactMismatchedVersions-test.js
+++ b/packages/react/src/__tests__/ReactMismatchedVersions-test.js
@@ -109,6 +109,7 @@ describe('ReactMismatchedVersions-test', () => {
);
});
+ // @gate !source
it('importing "react-dom/static.node" throws if version does not match React version', async () => {
expect(() => require('react-dom/static.node')).toThrow(
'Incompatible React versions: The "react" and "react-dom" packages ' +
diff --git a/scripts/rollup/bundles.js b/scripts/rollup/bundles.js
index 824a1bbd52f..73a29cc8600 100644
--- a/scripts/rollup/bundles.js
+++ b/scripts/rollup/bundles.js
@@ -365,6 +365,16 @@ const bundles = [
wrapWithModuleBoundaries: false,
externals: ['react', 'util', 'crypto', 'async_hooks', 'react-dom'],
},
+ {
+ bundleTypes: [NODE_DEV, NODE_PROD],
+ moduleType: RENDERER,
+ entry: 'react-dom/src/server/react-dom-server.node-webstreams.js',
+ name: 'react-dom-server.node-webstreams',
+ global: 'ReactDOMServer',
+ minifyWithProdErrorCodes: false,
+ wrapWithModuleBoundaries: false,
+ externals: ['react', 'util', 'crypto', 'async_hooks', 'react-dom'],
+ },
{
bundleTypes: __EXPERIMENTAL__ ? [FB_WWW_DEV, FB_WWW_PROD] : [],
moduleType: RENDERER,
diff --git a/scripts/shared/inlinedHostConfigs.js b/scripts/shared/inlinedHostConfigs.js
index a6748114e09..cb065afbdc4 100644
--- a/scripts/shared/inlinedHostConfigs.js
+++ b/scripts/shared/inlinedHostConfigs.js
@@ -60,7 +60,6 @@ module.exports = [
entryPoints: [
'react-dom/src/ReactDOMReactServer.js',
'react-dom/src/server/react-dom-server.node.js',
- 'react-dom/static.node',
'react-dom/test-utils',
'react-dom/unstable_server-external-runtime',
'react-server-dom-webpack/client.node.unbundled',
@@ -103,6 +102,42 @@ module.exports = [
isFlowTyped: true,
isServerSupported: true,
},
+ {
+ shortName: 'dom-node-webstreams',
+ entryPoints: ['react-dom/src/server/react-dom-server.node-webstreams.js'],
+ paths: [
+ 'react-dom',
+ 'react-dom/src/ReactDOMReactServer.js',
+ 'react-dom-bindings',
+ 'react-dom/client',
+ 'react-dom/profiling',
+ 'react-dom/server',
+ 'react-dom/server.node',
+ 'react-dom/static',
+ 'react-dom/static.node',
+ 'react-dom/test-utils',
+ 'react-dom/src/server/react-dom-server.node-webstreams',
+ 'react-dom/src/server/ReactDOMFizzServerEdge.js',
+ 'react-dom/src/server/ReactDOMFizzStaticEdge.js',
+ 'react-dom-bindings/src/server/ReactDOMFlightServerHostDispatcher.js',
+ 'react-dom-bindings/src/server/ReactFlightServerConfigDOM.js',
+ 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM.js',
+ 'react-server-dom-webpack',
+ 'react-server-dom-webpack/client.node.unbundled',
+ 'react-server-dom-webpack/server',
+ 'react-server-dom-webpack/server.node.unbundled',
+ 'react-server-dom-webpack/static',
+ 'react-server-dom-webpack/static.node.unbundled',
+ 'react-server-dom-webpack/src/client/ReactFlightDOMClientEdge.js', // react-server-dom-webpack/client.node
+ 'react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerNode.js',
+ 'react-server-dom-webpack/src/server/react-flight-dom-server.node.unbundled',
+ 'react-server-dom-webpack/src/server/ReactFlightDOMServerEdge.js', // react-server-dom-webpack/src/server/react-flight-dom-server.node
+ 'shared/ReactDOMSharedInternals',
+ 'react-server/src/ReactFlightServerConfigDebugNode.js',
+ ],
+ isFlowTyped: true,
+ isServerSupported: true,
+ },
{
shortName: 'dom-node-webpack',
entryPoints: [
@@ -221,8 +256,6 @@ module.exports = [
'react-server-dom-parcel/src/client/ReactFlightClientConfigBundlerParcel.js',
'react-server-dom-parcel/src/server/react-flight-dom-server.node',
'react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js', // react-server-dom-parcel/src/server/react-flight-dom-server.node
- 'react-server-dom-parcel/node-register',
- 'react-server-dom-parcel/src/ReactFlightParcelNodeRegister.js',
'react-devtools',
'react-devtools-core',
'react-devtools-shell',