[Blocks] Scaffolding react-fetch + first pass at node implementation (#18863)

* First pass at scaffolding out the Node implementation of react-data.

While incomplete, this patch contains some changes to the react-data
package in order to start adding support for Node.

The first part of this change accounts for splitting react-data/fetch
into two discrete entries, adding (and defaulting to) the Node
implementation.

The second part is sketching out a rough approximation of `fetch` for
Node. This implementation is not complete by any means, but provides a
starting point.

* Remove NodeFetch module and put it directly into ReactDataFetchNode.

* Replaced react-data with react-fetch.

This patch shuffles around some of the scaffolding that was in
react-data in favor of react-fetch. It also removes the additional
"fetch" package in favor of something flatter.

* Tweak package organization

* Simplify and add a test

Co-authored-by: Dan Abramov <dan.abramov@me.com>
This commit is contained in:
Richard Maisano 2020-05-12 12:21:45 -04:00 committed by GitHub
parent e936034eec
commit c512aa0081
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 376 additions and 70 deletions

View File

@ -7,7 +7,7 @@
/* eslint-disable import/first */ /* eslint-disable import/first */
import * as React from 'react'; import * as React from 'react';
import {fetch} from 'react-data/fetch'; import {fetch} from 'react-fetch';
// TODO: Replace with asset reference. // TODO: Replace with asset reference.
import Link from '../client/Link'; import Link from '../client/Link';

View File

@ -7,7 +7,7 @@
/* eslint-disable import/first */ /* eslint-disable import/first */
import * as React from 'react'; import * as React from 'react';
import {fetch} from 'react-data/fetch'; import {fetch} from 'react-fetch';
import PostList from './PostList'; import PostList from './PostList';
export default function Feed() { export default function Feed() {

View File

@ -8,7 +8,7 @@
import * as React from 'react'; import * as React from 'react';
import {Suspense, unstable_SuspenseList as SuspenseList} from 'react'; import {Suspense, unstable_SuspenseList as SuspenseList} from 'react';
import {preload} from 'react-data/fetch'; import {preload} from 'react-fetch';
import PostGlimmer from './PostGlimmer'; import PostGlimmer from './PostGlimmer';
import Post from './Post'; import Post from './Post';

View File

@ -7,7 +7,7 @@
/* eslint-disable import/first */ /* eslint-disable import/first */
import * as React from 'react'; import * as React from 'react';
import {fetch} from 'react-data/fetch'; import {fetch} from 'react-fetch';
export default function ProfileBio({userId}) { export default function ProfileBio({userId}) {
const user = fetch(`/users/${userId}`).json(); const user = fetch(`/users/${userId}`).json();

View File

@ -7,7 +7,7 @@
import * as React from 'react'; import * as React from 'react';
import {Suspense} from 'react'; import {Suspense} from 'react';
import {fetch} from 'react-data/fetch'; import {fetch} from 'react-fetch';
import {matchRoute} from './ServerRouter'; import {matchRoute} from './ServerRouter';
import ProfileTimeline from './ProfileTimeline'; import ProfileTimeline from './ProfileTimeline';
import ProfileBio from './ProfileBio'; import ProfileBio from './ProfileBio';

View File

@ -7,7 +7,7 @@
/* eslint-disable import/first */ /* eslint-disable import/first */
import * as React from 'react'; import * as React from 'react';
import {fetch} from 'react-data/fetch'; import {fetch} from 'react-fetch';
import PostList from './PostList'; import PostList from './PostList';
export default function ProfileTimeline({userId}) { export default function ProfileTimeline({userId}) {

View File

@ -1,7 +0,0 @@
'use strict';
if (process.env.NODE_ENV === 'production') {
module.exports = require('./cjs/react-data-fetch.production.min.js');
} else {
module.exports = require('./cjs/react-data-fetch.development.js');
}

View File

@ -1,7 +0,0 @@
'use strict';
if (process.env.NODE_ENV === 'production') {
module.exports = require('./cjs/react-data.production.min.js');
} else {
module.exports = require('./cjs/react-data.development.js');
}

View File

@ -1,23 +0,0 @@
/**
* Copyright (c) Facebook, Inc. and its 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
*/
'use strict';
describe('ReactDataFetch', () => {
let ReactDataFetch;
beforeEach(() => {
ReactDataFetch = require('react-data/fetch');
});
// TODO: test something useful.
it('exports something', () => {
expect(ReactDataFetch.fetch).not.toBe(undefined);
});
});

View File

@ -1,4 +1,4 @@
# react-data # react-fetch
This package is meant to be used alongside yet-to-be-released, experimental React features. It's unlikely to be useful in any other context. This package is meant to be used alongside yet-to-be-released, experimental React features. It's unlikely to be useful in any other context.

View File

@ -9,4 +9,4 @@
'use strict'; 'use strict';
export * from './src/fetch/ReactDataFetch'; export * from './src/ReactFetchBrowser';

View File

@ -9,4 +9,4 @@
'use strict'; 'use strict';
export * from './src/ReactData'; export * from './index.node';

View File

@ -7,6 +7,6 @@
* @flow * @flow
*/ */
export function createResource(): any { 'use strict';
// TODO
} export * from './src/ReactFetchNode';

View File

@ -0,0 +1,7 @@
'use strict';
if (process.env.NODE_ENV === 'production') {
module.exports = require('./cjs/react-fetch.browser.production.min.js');
} else {
module.exports = require('./cjs/react-fetch.browser.development.js');
}

3
packages/react-fetch/npm/index.js vendored Normal file
View File

@ -0,0 +1,3 @@
'use strict';
module.exports = require('./index.node');

View File

@ -0,0 +1,7 @@
'use strict';
if (process.env.NODE_ENV === 'production') {
module.exports = require('./cjs/react-fetch.node.production.min.js');
} else {
module.exports = require('./cjs/react-fetch.node.development.js');
}

View File

@ -1,22 +1,26 @@
{ {
"private": true, "private": true,
"name": "react-data", "name": "react-fetch",
"description": "Helpers for creating React data sources", "description": "Helpers for creating React data sources",
"version": "0.0.0", "version": "0.0.0",
"repository": { "repository": {
"type" : "git", "type" : "git",
"url" : "https://github.com/facebook/react.git", "url" : "https://github.com/facebook/react.git",
"directory": "packages/react-data" "directory": "packages/react-fetch"
}, },
"files": [ "files": [
"LICENSE", "LICENSE",
"README.md", "README.md",
"build-info.json", "build-info.json",
"index.js", "index.js",
"fetch.js", "index.node.js",
"index.browser.js",
"cjs/" "cjs/"
], ],
"peerDependencies": { "peerDependencies": {
"react": "^16.13.1" "react": "^16.13.1"
},
"browser": {
"./index.js": "./index.browser.js"
} }
} }

View File

@ -0,0 +1,229 @@
/**
* Copyright (c) Facebook, Inc. and its 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 type {Wakeable} from 'shared/ReactTypes';
import * as http from 'http';
import * as https from 'https';
import {readCache} from 'react/unstable-cache';
type FetchResponse = {|
// Properties
headers: any,
ok: boolean,
redirected: boolean,
status: number,
statusText: string,
type: 'basic',
url: string,
// Methods
arrayBuffer(): ArrayBuffer,
blob(): any,
json(): any,
text(): string,
|};
function nodeFetch(
url: string,
options: mixed,
onResolve: any => void,
onReject: any => void,
): void {
const {hostname, pathname, search, port, protocol} = new URL(url);
const nodeOptions = {
hostname,
port,
path: pathname + search,
// TODO: cherry-pick supported user-passed options.
};
const nodeImpl = protocol === 'https:' ? https : http;
const request = nodeImpl.request(nodeOptions, response => {
// TODO: support redirects.
onResolve(new Response(response));
});
request.on('error', error => {
onReject(error);
});
request.end();
}
const Pending = 0;
const Resolved = 1;
const Rejected = 2;
type PendingResult = {|
status: 0,
value: Wakeable,
|};
type ResolvedResult<V> = {|
status: 1,
value: V,
|};
type RejectedResult = {|
status: 2,
value: mixed,
|};
type Result<V> = PendingResult | ResolvedResult<V> | RejectedResult;
const fetchKey = {};
function readResultMap(): Map<string, Result<FetchResponse>> {
const resources = readCache().resources;
let map = resources.get(fetchKey);
if (map === undefined) {
map = new Map();
resources.set(fetchKey, map);
}
return map;
}
function readResult<T>(result: Result<T>): T {
if (result.status === Resolved) {
return result.value;
} else {
throw result.value;
}
}
function Response(nativeResponse) {
this.headers = nativeResponse.headers;
this.ok = nativeResponse.statusCode >= 200 && nativeResponse.statusCode < 300;
this.redirected = false; // TODO
this.status = nativeResponse.statusCode;
this.statusText = nativeResponse.statusMessage;
this.type = 'basic';
this.url = nativeResponse.url;
this._response = nativeResponse;
this._blob = null;
this._json = null;
this._text = null;
const callbacks = [];
function wake() {
// This assumes they won't throw.
while (callbacks.length > 0) {
const cb = callbacks.pop();
cb();
}
}
const result: PendingResult = (this._result = {
status: Pending,
value: {
then(cb) {
callbacks.push(cb);
},
},
});
const data = [];
nativeResponse.on('data', chunk => data.push(chunk));
nativeResponse.on('end', () => {
if (result.status === Pending) {
const resolvedResult = ((result: any): ResolvedResult<Buffer>);
resolvedResult.status = Resolved;
resolvedResult.value = Buffer.concat(data);
wake();
}
});
nativeResponse.on('error', err => {
if (result.status === Pending) {
const rejectedResult = ((result: any): RejectedResult);
rejectedResult.status = Rejected;
rejectedResult.value = err;
wake();
}
});
}
Response.prototype = {
constructor: Response,
arrayBuffer() {
const buffer = readResult(this._result);
return buffer;
},
blob() {
// TODO: Is this needed?
throw new Error('Not implemented.');
},
json() {
const buffer = readResult(this._result);
return JSON.parse(buffer.toString());
},
text() {
const buffer = readResult(this._result);
return buffer.toString();
},
};
function preloadResult(url: string, options: mixed): Result<FetchResponse> {
const map = readResultMap();
let entry = map.get(url);
if (!entry) {
if (options) {
if (options.method || options.body || options.signal) {
// TODO: wire up our own cancellation mechanism.
// TODO: figure out what to do with POST.
throw Error('Unsupported option');
}
}
const callbacks = [];
const wakeable = {
then(cb) {
callbacks.push(cb);
},
};
const wake = () => {
// This assumes they won't throw.
while (callbacks.length > 0) {
const cb = callbacks.pop();
cb();
}
};
const result: Result<FetchResponse> = (entry = {
status: Pending,
value: wakeable,
});
nodeFetch(
url,
options,
response => {
if (result.status === Pending) {
const resolvedResult = ((result: any): ResolvedResult<FetchResponse>);
resolvedResult.status = Resolved;
resolvedResult.value = response;
wake();
}
},
err => {
if (result.status === Pending) {
const rejectedResult = ((result: any): RejectedResult);
rejectedResult.status = Rejected;
rejectedResult.value = err;
wake();
}
},
);
map.set(url, entry);
}
return entry;
}
export function preload(url: string, options: mixed): void {
preloadResult(url, options);
// Don't return anything.
}
export function fetch(url: string, options: mixed): FetchResponse {
const result = preloadResult(url, options);
return readResult(result);
}

View File

@ -9,15 +9,15 @@
'use strict'; 'use strict';
describe('ReactData', () => { describe('ReactFetchBrowser', () => {
let ReactData; let ReactFetchBrowser;
beforeEach(() => { beforeEach(() => {
ReactData = require('react-data'); ReactFetchBrowser = require('react-fetch');
}); });
// TODO: test something useful. // TODO: test something useful.
it('exports something', () => { it('exports something', () => {
expect(ReactData.createResource).not.toBe(undefined); expect(ReactFetchBrowser.fetch).not.toBe(undefined);
}); });
}); });

View File

@ -0,0 +1,92 @@
/**
* Copyright (c) Facebook, Inc. and its 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
*/
'use strict';
describe('ReactFetchNode', () => {
let ReactCache;
let ReactFetchNode;
let http;
let fetch;
let server;
let serverEndpoint;
let serverImpl;
beforeEach(done => {
jest.resetModules();
ReactCache = require('react/unstable-cache');
ReactFetchNode = require('react-fetch');
http = require('http');
fetch = ReactFetchNode.fetch;
server = http.createServer((req, res) => {
serverImpl(req, res);
});
server.listen(done);
serverEndpoint = `http://localhost:${server.address().port}/`;
// TODO: A way to pass load context.
ReactCache.CacheProvider._context._currentValue = ReactCache.createCache();
});
afterEach(done => {
server.close(done);
server = null;
});
async function waitForSuspense(fn) {
while (true) {
try {
return fn();
} catch (promise) {
if (typeof promise.then === 'function') {
await promise;
} else {
throw promise;
}
}
}
}
it('can read text', async () => {
serverImpl = (req, res) => {
res.write('ok');
res.end();
};
await waitForSuspense(() => {
const response = fetch(serverEndpoint);
expect(response.status).toBe(200);
expect(response.statusText).toBe('OK');
expect(response.ok).toBe(true);
expect(response.text()).toEqual('ok');
// Can read again:
expect(response.text()).toEqual('ok');
});
});
it('can read json', async () => {
serverImpl = (req, res) => {
res.write(JSON.stringify({name: 'Sema'}));
res.end();
};
await waitForSuspense(() => {
const response = fetch(serverEndpoint);
expect(response.status).toBe(200);
expect(response.statusText).toBe('OK');
expect(response.ok).toBe(true);
expect(response.json()).toEqual({
name: 'Sema',
});
// Can read again:
expect(response.json()).toEqual({
name: 'Sema',
});
});
});
});

View File

@ -260,7 +260,7 @@ function getFormat(bundleType) {
function getFilename(name, globalName, bundleType) { function getFilename(name, globalName, bundleType) {
// we do this to replace / to -, for react-dom/server // we do this to replace / to -, for react-dom/server
name = name.replace('/', '-'); name = name.replace('/index.', '.').replace('/', '-');
switch (bundleType) { switch (bundleType) {
case UMD_DEV: case UMD_DEV:
return `${name}.development.js`; return `${name}.development.js`;

View File

@ -115,16 +115,7 @@ const bundles = [
externals: ['react'], externals: ['react'],
}, },
/******* React Data (experimental, new) *******/ /******* React Fetch Browser (experimental, new) *******/
{
bundleTypes: [NODE_DEV, NODE_PROD, NODE_PROFILING],
moduleType: ISOMORPHIC,
entry: 'react-data',
global: 'ReactData',
externals: ['react'],
},
/******* React Data Fetch (experimental, new) *******/
{ {
bundleTypes: [ bundleTypes: [
NODE_DEV, NODE_DEV,
@ -135,9 +126,18 @@ const bundles = [
FB_WWW_PROFILING, FB_WWW_PROFILING,
], ],
moduleType: ISOMORPHIC, moduleType: ISOMORPHIC,
entry: 'react-data/fetch', entry: 'react-fetch/index.browser',
global: 'ReactDataFetch', global: 'ReactFetch',
externals: ['react', 'react-data'], externals: ['react'],
},
/******* React Fetch Node (experimental, new) *******/
{
bundleTypes: [NODE_DEV, NODE_PROD],
moduleType: ISOMORPHIC,
entry: 'react-fetch/index.node',
global: 'ReactFetch',
externals: ['react', 'http', 'https'],
}, },
/******* React DOM *******/ /******* React DOM *******/

View File

@ -15,6 +15,7 @@ const importSideEffects = Object.freeze({
'scheduler/tracing': HAS_NO_SIDE_EFFECTS_ON_IMPORT, 'scheduler/tracing': HAS_NO_SIDE_EFFECTS_ON_IMPORT,
'react-dom/server': HAS_NO_SIDE_EFFECTS_ON_IMPORT, 'react-dom/server': HAS_NO_SIDE_EFFECTS_ON_IMPORT,
'react/jsx-dev-runtime': HAS_NO_SIDE_EFFECTS_ON_IMPORT, 'react/jsx-dev-runtime': HAS_NO_SIDE_EFFECTS_ON_IMPORT,
'react-fetch/node': HAS_NO_SIDE_EFFECTS_ON_IMPORT,
}); });
// Bundles exporting globals that other modules rely on. // Bundles exporting globals that other modules rely on.