This fixes security vulnerabilities in Server Functions. --------- Co-authored-by: Sebastian Markbåge <sebastian@calyptus.eu> Co-authored-by: Josh Story <josh.c.story@gmail.com> Co-authored-by: Janka Uryga <lolzatu2@gmail.com> Co-authored-by: Sebastian Sebbie Silbermann <sebastian.silbermann@vercel.com>
1937 lines
57 KiB
JavaScript
1937 lines
57 KiB
JavaScript
/**
|
|
* 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 type {Thenable} from 'shared/ReactTypes';
|
|
|
|
// The server acts as a Client of itself when resolving Server References.
|
|
// That's why we import the Client configuration from the Server.
|
|
// Everything is aliased as their Server equivalence for clarity.
|
|
import type {
|
|
ServerReferenceId,
|
|
ServerManifest,
|
|
ClientReference as ServerReference,
|
|
} from 'react-client/src/ReactFlightClientConfig';
|
|
|
|
import type {TemporaryReferenceSet} from './ReactFlightServerTemporaryReferences';
|
|
|
|
import {
|
|
resolveServerReference,
|
|
preloadModule,
|
|
requireModule,
|
|
} from 'react-client/src/ReactFlightClientConfig';
|
|
|
|
import {
|
|
createTemporaryReference,
|
|
registerTemporaryReference,
|
|
} from './ReactFlightServerTemporaryReferences';
|
|
import {ASYNC_ITERATOR} from 'shared/ReactSymbols';
|
|
|
|
import hasOwnProperty from 'shared/hasOwnProperty';
|
|
import getPrototypeOf from 'shared/getPrototypeOf';
|
|
import isArray from 'shared/isArray';
|
|
|
|
interface FlightStreamController {
|
|
enqueueModel(json: string): void;
|
|
close(json: string): void;
|
|
error(error: Error): void;
|
|
}
|
|
|
|
export type JSONValue =
|
|
| number
|
|
| null
|
|
| boolean
|
|
| string
|
|
| {+[key: string]: JSONValue}
|
|
| $ReadOnlyArray<JSONValue>;
|
|
|
|
const PENDING = 'pending';
|
|
const BLOCKED = 'blocked';
|
|
const RESOLVED_MODEL = 'resolved_model';
|
|
const INITIALIZED = 'fulfilled';
|
|
const ERRORED = 'rejected';
|
|
|
|
const __PROTO__ = '__proto__';
|
|
|
|
type RESPONSE_SYMBOL_TYPE = 'RESPONSE_SYMBOL'; // Fake symbol type.
|
|
const RESPONSE_SYMBOL: RESPONSE_SYMBOL_TYPE = (Symbol(): any);
|
|
|
|
type PendingChunk<T> = {
|
|
status: 'pending',
|
|
value: null | Array<InitializationReference | (T => mixed)>,
|
|
reason: null | Array<InitializationReference | (mixed => mixed)>,
|
|
then(resolve: (T) => mixed, reject?: (mixed) => mixed): void,
|
|
};
|
|
type BlockedChunk<T> = {
|
|
status: 'blocked',
|
|
value: null | Array<InitializationReference | (T => mixed)>,
|
|
reason: null | Array<InitializationReference | (mixed => mixed)>,
|
|
then(resolve: (T) => mixed, reject?: (mixed) => mixed): void,
|
|
};
|
|
type ResolvedModelChunk<T> = {
|
|
status: 'resolved_model',
|
|
value: string,
|
|
reason: {id: number, [RESPONSE_SYMBOL_TYPE]: Response},
|
|
then(resolve: (T) => mixed, reject?: (mixed) => mixed): void,
|
|
};
|
|
type InitializedChunk<T> = {
|
|
status: 'fulfilled',
|
|
value: T,
|
|
reason: null | NestedArrayContext,
|
|
then(resolve: (T) => mixed, reject?: (mixed) => mixed): void,
|
|
};
|
|
type InitializedStreamChunk<
|
|
T: ReadableStream | $AsyncIterable<any, any, void>,
|
|
> = {
|
|
status: 'fulfilled',
|
|
value: T,
|
|
reason: FlightStreamController,
|
|
then(resolve: (ReadableStream) => mixed, reject?: (mixed) => mixed): void,
|
|
};
|
|
type ErroredChunk<T> = {
|
|
status: 'rejected',
|
|
value: null,
|
|
reason: mixed,
|
|
then(resolve: (T) => mixed, reject?: (mixed) => mixed): void,
|
|
};
|
|
type SomeChunk<T> =
|
|
| PendingChunk<T>
|
|
| BlockedChunk<T>
|
|
| ResolvedModelChunk<T>
|
|
| InitializedChunk<T>
|
|
| ErroredChunk<T>;
|
|
|
|
// $FlowFixMe[missing-this-annot]
|
|
function ReactPromise(status: any, value: any, reason: any) {
|
|
this.status = status;
|
|
this.value = value;
|
|
this.reason = reason;
|
|
}
|
|
// We subclass Promise.prototype so that we get other methods like .catch
|
|
ReactPromise.prototype = (Object.create(Promise.prototype): any);
|
|
// TODO: This doesn't return a new Promise chain unlike the real .then
|
|
ReactPromise.prototype.then = function <T>(
|
|
this: SomeChunk<T>,
|
|
resolve: (value: T) => mixed,
|
|
reject: ?(reason: mixed) => mixed,
|
|
) {
|
|
const chunk: SomeChunk<T> = this;
|
|
// If we have resolved content, we try to initialize it first which
|
|
// might put us back into one of the other states.
|
|
switch (chunk.status) {
|
|
case RESOLVED_MODEL:
|
|
initializeModelChunk(chunk);
|
|
break;
|
|
}
|
|
// The status might have changed after initialization.
|
|
switch (chunk.status) {
|
|
case INITIALIZED:
|
|
if (typeof resolve === 'function') {
|
|
let inspectedValue = chunk.value;
|
|
// Recursively check if the value is itself a ReactPromise and if so if it points
|
|
// back to itself. This helps catch recursive thenables early error.
|
|
let cycleProtection = 0;
|
|
const visited = new Set<typeof ReactPromise>();
|
|
while (inspectedValue instanceof ReactPromise) {
|
|
cycleProtection++;
|
|
if (
|
|
inspectedValue === chunk ||
|
|
visited.has(inspectedValue) ||
|
|
cycleProtection > 1000
|
|
) {
|
|
if (typeof reject === 'function') {
|
|
reject(new Error('Cannot have cyclic thenables.'));
|
|
}
|
|
return;
|
|
}
|
|
visited.add(inspectedValue);
|
|
if (inspectedValue.status === INITIALIZED) {
|
|
inspectedValue = inspectedValue.value;
|
|
} else {
|
|
// If this is lazily resolved, pending or blocked, it'll eventually become
|
|
// initialized and break the loop. Rejected also breaks it.
|
|
break;
|
|
}
|
|
}
|
|
resolve(chunk.value);
|
|
}
|
|
break;
|
|
case PENDING:
|
|
case BLOCKED:
|
|
if (typeof resolve === 'function') {
|
|
if (chunk.value === null) {
|
|
chunk.value = ([]: Array<InitializationReference | (T => mixed)>);
|
|
}
|
|
chunk.value.push(resolve);
|
|
}
|
|
if (typeof reject === 'function') {
|
|
if (chunk.reason === null) {
|
|
chunk.reason = ([]: Array<
|
|
InitializationReference | (mixed => mixed),
|
|
>);
|
|
}
|
|
chunk.reason.push(reject);
|
|
}
|
|
break;
|
|
default:
|
|
if (typeof reject === 'function') {
|
|
reject(chunk.reason);
|
|
}
|
|
break;
|
|
}
|
|
};
|
|
|
|
const ObjectPrototype = Object.prototype;
|
|
const ArrayPrototype = Array.prototype;
|
|
|
|
export type Response = {
|
|
_bundlerConfig: ServerManifest,
|
|
_prefix: string,
|
|
_formData: FormData,
|
|
_chunks: Map<number, SomeChunk<any>>,
|
|
_closed: boolean,
|
|
_closedReason: mixed,
|
|
_temporaryReferences: void | TemporaryReferenceSet,
|
|
_rootArrayContexts: WeakMap<$ReadOnlyArray<mixed>, NestedArrayContext>,
|
|
_arraySizeLimit: number,
|
|
};
|
|
|
|
export function getRoot<T>(response: Response): Thenable<T> {
|
|
const chunk = getChunk(response, 0);
|
|
return (chunk: any);
|
|
}
|
|
|
|
function createPendingChunk<T>(response: Response): PendingChunk<T> {
|
|
// $FlowFixMe[invalid-constructor] Flow doesn't support functions as constructors
|
|
return new ReactPromise(PENDING, null, null);
|
|
}
|
|
|
|
function wakeChunk<T>(
|
|
response: Response,
|
|
listeners: Array<InitializationReference | (T => mixed)>,
|
|
value: T,
|
|
chunk: InitializedChunk<T>,
|
|
): void {
|
|
for (let i = 0; i < listeners.length; i++) {
|
|
const listener = listeners[i];
|
|
if (typeof listener === 'function') {
|
|
listener(value);
|
|
} else {
|
|
fulfillReference(response, listener, value, chunk.reason);
|
|
}
|
|
}
|
|
}
|
|
|
|
function rejectChunk(
|
|
response: Response,
|
|
listeners: Array<InitializationReference | (mixed => mixed)>,
|
|
error: mixed,
|
|
): void {
|
|
for (let i = 0; i < listeners.length; i++) {
|
|
const listener = listeners[i];
|
|
if (typeof listener === 'function') {
|
|
listener(error);
|
|
} else {
|
|
rejectReference(response, listener.handler, error);
|
|
}
|
|
}
|
|
}
|
|
|
|
function wakeChunkIfInitialized<T>(
|
|
response: Response,
|
|
chunk: SomeChunk<T>,
|
|
resolveListeners: Array<InitializationReference | (T => mixed)>,
|
|
rejectListeners: null | Array<InitializationReference | (mixed => mixed)>,
|
|
): void {
|
|
switch (chunk.status) {
|
|
case INITIALIZED:
|
|
wakeChunk(response, resolveListeners, chunk.value, chunk);
|
|
break;
|
|
case BLOCKED:
|
|
case PENDING:
|
|
if (chunk.value) {
|
|
for (let i = 0; i < resolveListeners.length; i++) {
|
|
chunk.value.push(resolveListeners[i]);
|
|
}
|
|
} else {
|
|
chunk.value = resolveListeners;
|
|
}
|
|
|
|
if (chunk.reason) {
|
|
if (rejectListeners) {
|
|
for (let i = 0; i < rejectListeners.length; i++) {
|
|
chunk.reason.push(rejectListeners[i]);
|
|
}
|
|
}
|
|
} else {
|
|
chunk.reason = rejectListeners;
|
|
}
|
|
break;
|
|
case ERRORED:
|
|
if (rejectListeners) {
|
|
rejectChunk(response, rejectListeners, chunk.reason);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
function triggerErrorOnChunk<T>(
|
|
response: Response,
|
|
chunk: SomeChunk<T>,
|
|
error: mixed,
|
|
): void {
|
|
if (chunk.status !== PENDING && chunk.status !== BLOCKED) {
|
|
// If we get more data to an already resolved ID, we assume that it's
|
|
// a stream chunk since any other row shouldn't have more than one entry.
|
|
const streamChunk: InitializedStreamChunk<any> = (chunk: any);
|
|
const controller = streamChunk.reason;
|
|
// $FlowFixMe[incompatible-call]: The error method should accept mixed.
|
|
controller.error(error);
|
|
return;
|
|
}
|
|
const listeners = chunk.reason;
|
|
const erroredChunk: ErroredChunk<T> = (chunk: any);
|
|
erroredChunk.status = ERRORED;
|
|
erroredChunk.reason = error;
|
|
if (listeners !== null) {
|
|
rejectChunk(response, listeners, error);
|
|
}
|
|
}
|
|
|
|
function createResolvedModelChunk<T>(
|
|
response: Response,
|
|
value: string,
|
|
id: number,
|
|
): ResolvedModelChunk<T> {
|
|
// $FlowFixMe[invalid-constructor] Flow doesn't support functions as constructors
|
|
return new ReactPromise(RESOLVED_MODEL, value, {
|
|
id,
|
|
[RESPONSE_SYMBOL]: response,
|
|
});
|
|
}
|
|
|
|
function createErroredChunk<T>(
|
|
response: Response,
|
|
reason: mixed,
|
|
): ErroredChunk<T> {
|
|
// $FlowFixMe[invalid-constructor] Flow doesn't support functions as constructors
|
|
return new ReactPromise(ERRORED, null, reason);
|
|
}
|
|
|
|
function resolveModelChunk<T>(
|
|
response: Response,
|
|
chunk: SomeChunk<T>,
|
|
value: string,
|
|
id: number,
|
|
): void {
|
|
if (chunk.status !== PENDING) {
|
|
// If we get more data to an already resolved ID, we assume that it's
|
|
// a stream chunk since any other row shouldn't have more than one entry.
|
|
const streamChunk: InitializedStreamChunk<any> = (chunk: any);
|
|
const controller = streamChunk.reason;
|
|
if (value[0] === 'C') {
|
|
controller.close(value === 'C' ? '"$undefined"' : value.slice(1));
|
|
} else {
|
|
controller.enqueueModel(value);
|
|
}
|
|
return;
|
|
}
|
|
const resolveListeners = chunk.value;
|
|
const rejectListeners = chunk.reason;
|
|
const resolvedChunk: ResolvedModelChunk<T> = (chunk: any);
|
|
resolvedChunk.status = RESOLVED_MODEL;
|
|
resolvedChunk.value = value;
|
|
resolvedChunk.reason = {id, [RESPONSE_SYMBOL]: response};
|
|
if (resolveListeners !== null) {
|
|
// This is unfortunate that we're reading this eagerly if
|
|
// we already have listeners attached since they might no
|
|
// longer be rendered or might not be the highest pri.
|
|
initializeModelChunk(resolvedChunk);
|
|
// The status might have changed after initialization.
|
|
wakeChunkIfInitialized(response, chunk, resolveListeners, rejectListeners);
|
|
}
|
|
}
|
|
|
|
function createInitializedStreamChunk<
|
|
T: ReadableStream | $AsyncIterable<any, any, void>,
|
|
>(
|
|
response: Response,
|
|
value: T,
|
|
controller: FlightStreamController,
|
|
): InitializedChunk<T> {
|
|
// We use the reason field to stash the controller since we already have that
|
|
// field. It's a bit of a hack but efficient.
|
|
// $FlowFixMe[invalid-constructor] Flow doesn't support functions as constructors
|
|
return new ReactPromise(INITIALIZED, value, controller);
|
|
}
|
|
|
|
function createResolvedIteratorResultChunk<T>(
|
|
response: Response,
|
|
value: string,
|
|
done: boolean,
|
|
): ResolvedModelChunk<IteratorResult<T, T>> {
|
|
// To reuse code as much code as possible we add the wrapper element as part of the JSON.
|
|
const iteratorResultJSON =
|
|
(done ? '{"done":true,"value":' : '{"done":false,"value":') + value + '}';
|
|
// $FlowFixMe[invalid-constructor] Flow doesn't support functions as constructors
|
|
return new ReactPromise(RESOLVED_MODEL, iteratorResultJSON, {
|
|
id: -1,
|
|
[RESPONSE_SYMBOL]: response,
|
|
});
|
|
}
|
|
|
|
function resolveIteratorResultChunk<T>(
|
|
response: Response,
|
|
chunk: SomeChunk<IteratorResult<T, T>>,
|
|
value: string,
|
|
done: boolean,
|
|
): void {
|
|
// To reuse code as much code as possible we add the wrapper element as part of the JSON.
|
|
const iteratorResultJSON =
|
|
(done ? '{"done":true,"value":' : '{"done":false,"value":') + value + '}';
|
|
resolveModelChunk(response, chunk, iteratorResultJSON, -1);
|
|
}
|
|
|
|
function loadServerReference<A: Iterable<any>, T>(
|
|
response: Response,
|
|
metaData: {
|
|
id: any,
|
|
bound: null | Thenable<Array<any>>,
|
|
},
|
|
parentObject: Object,
|
|
key: string,
|
|
): (...A) => Promise<T> {
|
|
const id: ServerReferenceId = metaData.id;
|
|
if (typeof id !== 'string') {
|
|
return (null: any);
|
|
}
|
|
if (key === 'then') {
|
|
// This should never happen because we always serialize objects with then-functions
|
|
// as "thenable" which reduces to ReactPromise with no other fields.
|
|
return (null: any);
|
|
}
|
|
|
|
// Check for a cached promise from a previous call with the same metadata.
|
|
// This handles deduplication when the same server reference appears multiple
|
|
// times in the payload.
|
|
const cachedPromise: SomeChunk<T> | void = (metaData: any).$$promise;
|
|
if (cachedPromise !== undefined) {
|
|
if (cachedPromise.status === INITIALIZED) {
|
|
// The value was already resolved by a previous call.
|
|
const resolvedValue: T = cachedPromise.value;
|
|
if (key === __PROTO__) {
|
|
return (null: any);
|
|
}
|
|
parentObject[key] = resolvedValue;
|
|
return (resolvedValue: any);
|
|
}
|
|
|
|
// The promise is still blocked. Increment the handler dependency count ...
|
|
let handler: InitializationHandler;
|
|
if (initializingHandler) {
|
|
handler = initializingHandler;
|
|
handler.deps++;
|
|
} else {
|
|
handler = initializingHandler = {
|
|
chunk: null,
|
|
value: null,
|
|
reason: null,
|
|
deps: 1,
|
|
errored: false,
|
|
};
|
|
}
|
|
// ... and register resolve and reject listeners on the promise.
|
|
cachedPromise.then(
|
|
resolveReference.bind(null, response, handler, parentObject, key),
|
|
rejectReference.bind(null, response, handler),
|
|
);
|
|
|
|
// Return a place holder value for now.
|
|
return (null: any);
|
|
}
|
|
|
|
// This is the first call for this server reference metadata. Create a cached
|
|
// promise to be used for subsequent calls.
|
|
// $FlowFixMe[invalid-constructor] Flow doesn't support functions as constructors
|
|
const blockedPromise: BlockedChunk<T> = new ReactPromise(BLOCKED, null, null);
|
|
(metaData: any).$$promise = blockedPromise;
|
|
|
|
const serverReference: ServerReference<T> =
|
|
resolveServerReference<$FlowFixMe>(response._bundlerConfig, id);
|
|
// We expect most servers to not really need this because you'd just have all
|
|
// the relevant modules already loaded but it allows for lazy loading of code
|
|
// if needed.
|
|
const bound = metaData.bound;
|
|
let serverReferencePromise: null | Thenable<any> =
|
|
preloadModule(serverReference);
|
|
if (!serverReferencePromise) {
|
|
if (bound instanceof ReactPromise) {
|
|
serverReferencePromise = Promise.resolve(bound);
|
|
} else {
|
|
const resolvedValue = (requireModule(serverReference): any);
|
|
// Resolve the cached promise synchronously.
|
|
const initializedPromise: InitializedChunk<T> = (blockedPromise: any);
|
|
initializedPromise.status = INITIALIZED;
|
|
initializedPromise.value = resolvedValue;
|
|
return resolvedValue;
|
|
}
|
|
} else if (bound instanceof ReactPromise) {
|
|
serverReferencePromise = Promise.all([serverReferencePromise, bound]);
|
|
}
|
|
|
|
let handler: InitializationHandler;
|
|
if (initializingHandler) {
|
|
handler = initializingHandler;
|
|
handler.deps++;
|
|
} else {
|
|
handler = initializingHandler = {
|
|
chunk: null,
|
|
value: null,
|
|
reason: null,
|
|
deps: 1,
|
|
errored: false,
|
|
};
|
|
}
|
|
|
|
function fulfill(): void {
|
|
let resolvedValue = (requireModule(serverReference): any);
|
|
|
|
if (metaData.bound) {
|
|
// This promise is coming from us and should have initialized by now.
|
|
const promiseValue = (metaData.bound: any).value;
|
|
const boundArgs: Array<any> = isArray(promiseValue)
|
|
? promiseValue.slice(0)
|
|
: [];
|
|
if (boundArgs.length > MAX_BOUND_ARGS) {
|
|
reject(
|
|
new Error(
|
|
'Server Function has too many bound arguments. Received ' +
|
|
boundArgs.length +
|
|
' but the limit is ' +
|
|
MAX_BOUND_ARGS +
|
|
'.',
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
boundArgs.unshift(null); // this
|
|
resolvedValue = resolvedValue.bind.apply(resolvedValue, boundArgs);
|
|
}
|
|
|
|
// Resolve the cached promise so subsequent references can use the value.
|
|
const resolveListeners = blockedPromise.value;
|
|
const initializedPromise: InitializedChunk<T> = (blockedPromise: any);
|
|
initializedPromise.status = INITIALIZED;
|
|
initializedPromise.value = resolvedValue;
|
|
initializedPromise.reason = null;
|
|
if (resolveListeners !== null) {
|
|
// Notify any resolve listeners that were added via .then() from
|
|
// subsequent loadServerReference calls for the same reference.
|
|
wakeChunk(response, resolveListeners, resolvedValue, initializedPromise);
|
|
}
|
|
|
|
resolveReference(response, handler, parentObject, key, resolvedValue);
|
|
}
|
|
|
|
function reject(error: mixed): void {
|
|
// Mark the cached promise as errored so subsequent references fail too.
|
|
const rejectListeners = blockedPromise.reason;
|
|
const erroredPromise: ErroredChunk<T> = (blockedPromise: any);
|
|
erroredPromise.status = ERRORED;
|
|
erroredPromise.value = null;
|
|
erroredPromise.reason = error;
|
|
if (rejectListeners !== null) {
|
|
// Notify any reject listeners that were added via .then() from subsequent
|
|
// loadServerReference calls for the same reference.
|
|
rejectChunk(response, rejectListeners, error);
|
|
}
|
|
|
|
rejectReference(response, handler, error);
|
|
}
|
|
|
|
serverReferencePromise.then(fulfill, reject);
|
|
|
|
// Return a place holder value for now.
|
|
return (null: any);
|
|
}
|
|
|
|
function reviveModel(
|
|
response: Response,
|
|
parentObj: any,
|
|
parentKey: string,
|
|
value: JSONValue,
|
|
reference: void | string,
|
|
arrayRoot: null | NestedArrayContext,
|
|
): any {
|
|
if (typeof value === 'string') {
|
|
// We can't use .bind here because we need the "this" value.
|
|
return parseModelString(
|
|
response,
|
|
parentObj,
|
|
parentKey,
|
|
value,
|
|
reference,
|
|
arrayRoot,
|
|
);
|
|
}
|
|
if (typeof value === 'object' && value !== null) {
|
|
if (
|
|
reference !== undefined &&
|
|
response._temporaryReferences !== undefined
|
|
) {
|
|
// Store this object's reference in case it's returned later.
|
|
registerTemporaryReference(
|
|
response._temporaryReferences,
|
|
value,
|
|
reference,
|
|
);
|
|
}
|
|
if (isArray(value)) {
|
|
let childContext: NestedArrayContext;
|
|
if (arrayRoot === null) {
|
|
childContext = ({
|
|
count: 0,
|
|
fork: false,
|
|
}: NestedArrayContext);
|
|
response._rootArrayContexts.set(value, childContext);
|
|
} else {
|
|
childContext = arrayRoot;
|
|
}
|
|
if (value.length > 1) {
|
|
childContext.fork = true;
|
|
}
|
|
bumpArrayCount(childContext, value.length + 1, response);
|
|
for (let i = 0; i < value.length; i++) {
|
|
const childRef =
|
|
reference !== undefined ? reference + ':' + i : undefined;
|
|
// $FlowFixMe[cannot-write]
|
|
value[i] = reviveModel(
|
|
response,
|
|
value,
|
|
'' + i,
|
|
value[i],
|
|
childRef,
|
|
childContext,
|
|
);
|
|
}
|
|
} else {
|
|
for (const key in value) {
|
|
if (hasOwnProperty.call(value, key)) {
|
|
if (key === __PROTO__) {
|
|
// $FlowFixMe[cannot-write]
|
|
delete value[key];
|
|
continue;
|
|
}
|
|
const childRef =
|
|
reference !== undefined && key.indexOf(':') === -1
|
|
? reference + ':' + key
|
|
: undefined;
|
|
const newValue = reviveModel(
|
|
response,
|
|
value,
|
|
key,
|
|
value[key],
|
|
childRef,
|
|
null, // The array context resets when we're entering a non-array
|
|
);
|
|
if (newValue !== undefined) {
|
|
// $FlowFixMe[cannot-write]
|
|
value[key] = newValue;
|
|
} else {
|
|
// $FlowFixMe[cannot-write]
|
|
delete value[key];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return value;
|
|
}
|
|
|
|
type NestedArrayContext = {
|
|
// Keeps track of how many slots, bytes or characters are in nested arrays/strings/typed arrays.
|
|
count: number,
|
|
// A single child is itself not harmful. There needs to be at least one parent array with more
|
|
// than one child.
|
|
fork: boolean,
|
|
};
|
|
|
|
function bumpArrayCount(
|
|
arrayContext: NestedArrayContext,
|
|
slots: number,
|
|
response: Response,
|
|
): void {
|
|
const newCount = (arrayContext.count += slots);
|
|
if (newCount > response._arraySizeLimit && arrayContext.fork) {
|
|
throw new Error(
|
|
'Maximum array nesting exceeded. Large nested arrays can be dangerous. Try adding intermediate objects.',
|
|
);
|
|
}
|
|
}
|
|
|
|
type InitializationReference = {
|
|
handler: InitializationHandler,
|
|
parentObject: Object,
|
|
key: string,
|
|
map: (
|
|
response: Response,
|
|
model: any,
|
|
parentObject: Object,
|
|
key: string,
|
|
) => any,
|
|
path: Array<string>,
|
|
arrayRoot: null | NestedArrayContext,
|
|
};
|
|
type InitializationHandler = {
|
|
chunk: null | BlockedChunk<any>,
|
|
value: any,
|
|
reason: any,
|
|
deps: number,
|
|
errored: boolean,
|
|
};
|
|
let initializingHandler: null | InitializationHandler = null;
|
|
|
|
function initializeModelChunk<T>(chunk: ResolvedModelChunk<T>): void {
|
|
const prevHandler = initializingHandler;
|
|
initializingHandler = null;
|
|
|
|
const {[RESPONSE_SYMBOL]: response, id} = chunk.reason;
|
|
|
|
const rootReference = id === -1 ? undefined : id.toString(16);
|
|
|
|
const resolvedModel = chunk.value;
|
|
|
|
// We go to the BLOCKED state until we've fully resolved this.
|
|
// We do this before parsing in case we try to initialize the same chunk
|
|
// while parsing the model. Such as in a cyclic reference.
|
|
const cyclicChunk: BlockedChunk<T> = (chunk: any);
|
|
cyclicChunk.status = BLOCKED;
|
|
cyclicChunk.value = null;
|
|
cyclicChunk.reason = null;
|
|
|
|
try {
|
|
const rawModel = JSON.parse(resolvedModel);
|
|
|
|
// The root might not be an array but if it is we want to track the count of entries.
|
|
const arrayRoot: NestedArrayContext = {
|
|
count: 0,
|
|
fork: false,
|
|
};
|
|
|
|
const value: T = reviveModel(
|
|
response,
|
|
{'': rawModel},
|
|
'',
|
|
rawModel,
|
|
rootReference,
|
|
arrayRoot,
|
|
);
|
|
|
|
// Invoke any listeners added while resolving this model. I.e. cyclic
|
|
// references. This may or may not fully resolve the model depending on
|
|
// if they were blocked.
|
|
const resolveListeners = cyclicChunk.value;
|
|
if (resolveListeners !== null) {
|
|
cyclicChunk.value = null;
|
|
cyclicChunk.reason = null;
|
|
for (let i = 0; i < resolveListeners.length; i++) {
|
|
const listener = resolveListeners[i];
|
|
if (typeof listener === 'function') {
|
|
listener(value);
|
|
} else {
|
|
fulfillReference(response, listener, value, arrayRoot);
|
|
}
|
|
}
|
|
}
|
|
if (initializingHandler !== null) {
|
|
if (initializingHandler.errored) {
|
|
throw initializingHandler.reason;
|
|
}
|
|
if (initializingHandler.deps > 0) {
|
|
// We discovered new dependencies on modules that are not yet resolved.
|
|
// We have to keep the BLOCKED state until they're resolved.
|
|
initializingHandler.value = value;
|
|
initializingHandler.reason = arrayRoot;
|
|
initializingHandler.chunk = cyclicChunk;
|
|
return;
|
|
}
|
|
}
|
|
const initializedChunk: InitializedChunk<T> = (chunk: any);
|
|
initializedChunk.status = INITIALIZED;
|
|
initializedChunk.value = value;
|
|
initializedChunk.reason = arrayRoot;
|
|
} catch (error) {
|
|
const erroredChunk: ErroredChunk<T> = (chunk: any);
|
|
erroredChunk.status = ERRORED;
|
|
erroredChunk.reason = error;
|
|
} finally {
|
|
initializingHandler = prevHandler;
|
|
}
|
|
}
|
|
|
|
// Report that any missing chunks in the model is now going to throw this
|
|
// error upon read. Also notify any pending promises.
|
|
export function reportGlobalError(response: Response, error: Error): void {
|
|
response._closed = true;
|
|
response._closedReason = error;
|
|
response._chunks.forEach(chunk => {
|
|
// If this chunk was already resolved or errored, it won't
|
|
// trigger an error but if it wasn't then we need to
|
|
// because we won't be getting any new data to resolve it.
|
|
if (chunk.status === PENDING) {
|
|
triggerErrorOnChunk(response, chunk, error);
|
|
} else if (chunk.status === INITIALIZED && chunk.reason !== null) {
|
|
const maybeController = chunk.reason;
|
|
// $FlowFixMe
|
|
if (typeof maybeController.error === 'function') {
|
|
maybeController.error(error);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function getChunk(response: Response, id: number): SomeChunk<any> {
|
|
const chunks = response._chunks;
|
|
let chunk = chunks.get(id);
|
|
if (!chunk) {
|
|
const prefix = response._prefix;
|
|
const key = prefix + id;
|
|
// Check if we have this field in the backing store already.
|
|
const backingEntry = response._formData.get(key);
|
|
if (typeof backingEntry === 'string') {
|
|
chunk = createResolvedModelChunk(response, backingEntry, id);
|
|
} else if (response._closed) {
|
|
// We have already errored the response and we're not going to get
|
|
// anything more streaming in so this will immediately error.
|
|
chunk = createErroredChunk(response, response._closedReason);
|
|
} else {
|
|
// We're still waiting on this entry to stream in.
|
|
chunk = createPendingChunk(response);
|
|
}
|
|
chunks.set(id, chunk);
|
|
}
|
|
return chunk;
|
|
}
|
|
|
|
function fulfillReference(
|
|
response: Response,
|
|
reference: InitializationReference,
|
|
value: any,
|
|
arrayRoot: null | NestedArrayContext,
|
|
): void {
|
|
const {handler, parentObject, key, map, path} = reference;
|
|
|
|
let resolvedValue;
|
|
try {
|
|
let localLength: number = 0;
|
|
const rootArrayContexts = response._rootArrayContexts;
|
|
for (let i = 1; i < path.length; i++) {
|
|
// The server doesn't have any lazy references so we don't expect to go through a Promise.
|
|
const name = path[i];
|
|
if (
|
|
typeof value === 'object' &&
|
|
value !== null &&
|
|
(getPrototypeOf(value) === ObjectPrototype ||
|
|
getPrototypeOf(value) === ArrayPrototype) &&
|
|
hasOwnProperty.call(value, name)
|
|
) {
|
|
value = value[name];
|
|
if (isArray(value)) {
|
|
localLength = 0;
|
|
arrayRoot = rootArrayContexts.get(value) || arrayRoot;
|
|
} else {
|
|
arrayRoot = null;
|
|
if (typeof value === 'string') {
|
|
localLength = value.length;
|
|
} else if (typeof value === 'bigint') {
|
|
// Estimate the length to avoid expensive toString() calls on large
|
|
// BigInt values. If the value is too large, we get Infinity, which
|
|
// will trigger the array size limit error.
|
|
// eslint-disable-next-line react-internal/no-primitive-constructors
|
|
const n = Math.abs(Number(value));
|
|
if (n === 0) {
|
|
localLength = 1;
|
|
} else {
|
|
localLength = Math.floor(Math.log10(n)) + 1;
|
|
}
|
|
} else if (ArrayBuffer.isView(value)) {
|
|
localLength = value.byteLength;
|
|
} else {
|
|
localLength = 0;
|
|
}
|
|
}
|
|
} else {
|
|
throw new Error('Invalid reference.');
|
|
}
|
|
}
|
|
|
|
resolvedValue = map(response, value, parentObject, key);
|
|
|
|
// Add any array counts to the reference's array root. The value that we're
|
|
// resolving might have deep nesting that we need to resolve.
|
|
const referenceArrayRoot = reference.arrayRoot;
|
|
if (referenceArrayRoot !== null) {
|
|
if (arrayRoot !== null) {
|
|
if (arrayRoot.fork) {
|
|
referenceArrayRoot.fork = true;
|
|
}
|
|
bumpArrayCount(referenceArrayRoot, arrayRoot.count, response);
|
|
} else if (localLength > 0) {
|
|
bumpArrayCount(referenceArrayRoot, localLength, response);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
rejectReference(response, handler, error);
|
|
return;
|
|
}
|
|
|
|
// There are no Elements or Debug Info to transfer here.
|
|
|
|
resolveReference(response, handler, parentObject, key, resolvedValue);
|
|
}
|
|
|
|
function resolveReference(
|
|
response: Response,
|
|
handler: InitializationHandler,
|
|
parentObject: Object,
|
|
key: string,
|
|
resolvedValue: mixed,
|
|
): void {
|
|
if (key !== __PROTO__) {
|
|
parentObject[key] = resolvedValue;
|
|
}
|
|
|
|
// If this is the root object for a model reference, where `handler.value`
|
|
// is a stale `null`, the resolved value can be used directly.
|
|
if (key === '' && handler.value === null) {
|
|
handler.value = resolvedValue;
|
|
}
|
|
|
|
handler.deps--;
|
|
|
|
if (handler.deps === 0) {
|
|
const chunk = handler.chunk;
|
|
if (chunk === null || chunk.status !== BLOCKED) {
|
|
return;
|
|
}
|
|
const resolveListeners = chunk.value;
|
|
const initializedChunk: InitializedChunk<any> = (chunk: any);
|
|
initializedChunk.status = INITIALIZED;
|
|
initializedChunk.value = handler.value;
|
|
initializedChunk.reason = handler.reason; // Used by streaming chunks
|
|
if (resolveListeners !== null) {
|
|
wakeChunk(response, resolveListeners, handler.value, initializedChunk);
|
|
}
|
|
}
|
|
}
|
|
|
|
function rejectReference(
|
|
response: Response,
|
|
handler: InitializationHandler,
|
|
error: mixed,
|
|
): void {
|
|
if (handler.errored) {
|
|
// We've already errored. We could instead build up an AggregateError
|
|
// but if there are multiple errors we just take the first one like
|
|
// Promise.all.
|
|
return;
|
|
}
|
|
handler.errored = true;
|
|
handler.value = null;
|
|
handler.reason = error;
|
|
const chunk = handler.chunk;
|
|
if (chunk === null || chunk.status !== BLOCKED) {
|
|
return;
|
|
}
|
|
// There's no debug info to forward in this direction.
|
|
triggerErrorOnChunk(response, chunk, error);
|
|
}
|
|
|
|
function waitForReference<T>(
|
|
response: Response,
|
|
referencedChunk: BlockedChunk<T>,
|
|
parentObject: Object,
|
|
key: string,
|
|
arrayRoot: null | NestedArrayContext,
|
|
map: (response: Response, model: any, parentObject: Object, key: string) => T,
|
|
path: Array<string>,
|
|
): T {
|
|
let handler: InitializationHandler;
|
|
if (initializingHandler) {
|
|
handler = initializingHandler;
|
|
handler.deps++;
|
|
} else {
|
|
handler = initializingHandler = {
|
|
chunk: null,
|
|
value: null,
|
|
reason: null,
|
|
deps: 1,
|
|
errored: false,
|
|
};
|
|
}
|
|
|
|
const reference: InitializationReference = {
|
|
handler,
|
|
parentObject,
|
|
key,
|
|
map,
|
|
path,
|
|
arrayRoot,
|
|
};
|
|
|
|
// Add "listener".
|
|
if (referencedChunk.value === null) {
|
|
referencedChunk.value = [reference];
|
|
} else {
|
|
referencedChunk.value.push(reference);
|
|
}
|
|
if (referencedChunk.reason === null) {
|
|
referencedChunk.reason = [reference];
|
|
} else {
|
|
referencedChunk.reason.push(reference);
|
|
}
|
|
|
|
// Return a place holder value for now.
|
|
return (null: any);
|
|
}
|
|
|
|
function getOutlinedModel<T>(
|
|
response: Response,
|
|
reference: string,
|
|
parentObject: Object,
|
|
key: string,
|
|
referenceArrayRoot: null | NestedArrayContext,
|
|
map: (response: Response, model: any, parentObject: Object, key: string) => T,
|
|
): T {
|
|
const path = reference.split(':');
|
|
const id = parseInt(path[0], 16);
|
|
const chunk = getChunk(response, id);
|
|
switch (chunk.status) {
|
|
case RESOLVED_MODEL:
|
|
initializeModelChunk(chunk);
|
|
break;
|
|
}
|
|
// The status might have changed after initialization.
|
|
switch (chunk.status) {
|
|
case INITIALIZED:
|
|
let value = chunk.value;
|
|
let arrayRoot: null | NestedArrayContext = chunk.reason;
|
|
let localLength: number = 0;
|
|
const rootArrayContexts = response._rootArrayContexts;
|
|
for (let i = 1; i < path.length; i++) {
|
|
const name = path[i];
|
|
if (
|
|
typeof value === 'object' &&
|
|
value !== null &&
|
|
(getPrototypeOf(value) === ObjectPrototype ||
|
|
getPrototypeOf(value) === ArrayPrototype) &&
|
|
hasOwnProperty.call(value, name)
|
|
) {
|
|
value = value[name];
|
|
if (isArray(value)) {
|
|
localLength = 0;
|
|
arrayRoot = rootArrayContexts.get(value) || arrayRoot;
|
|
} else {
|
|
arrayRoot = null;
|
|
if (typeof value === 'string') {
|
|
localLength = value.length;
|
|
} else if (typeof value === 'bigint') {
|
|
// Estimate the length to avoid expensive toString() calls on large
|
|
// BigInt values. If the value is too large, we get Infinity, which
|
|
// will trigger the array size limit error.
|
|
// eslint-disable-next-line react-internal/no-primitive-constructors
|
|
const n = Math.abs(Number(value));
|
|
if (n === 0) {
|
|
localLength = 1;
|
|
} else {
|
|
localLength = Math.floor(Math.log10(n)) + 1;
|
|
}
|
|
} else if (ArrayBuffer.isView(value)) {
|
|
localLength = value.byteLength;
|
|
} else {
|
|
localLength = 0;
|
|
}
|
|
}
|
|
} else {
|
|
throw new Error('Invalid reference.');
|
|
}
|
|
}
|
|
const chunkValue = map(response, value, parentObject, key);
|
|
|
|
// Add any array counts to the reference's array root. The value that we're
|
|
// resolving might have deep nesting that we need to resolve.
|
|
if (referenceArrayRoot !== null) {
|
|
if (arrayRoot !== null) {
|
|
if (arrayRoot.fork) {
|
|
referenceArrayRoot.fork = true;
|
|
}
|
|
bumpArrayCount(referenceArrayRoot, arrayRoot.count, response);
|
|
} else if (localLength > 0) {
|
|
bumpArrayCount(referenceArrayRoot, localLength, response);
|
|
}
|
|
}
|
|
// There's no Element nor Debug Info in the ReplyServer so we don't have to check those here.
|
|
return chunkValue;
|
|
case BLOCKED:
|
|
return waitForReference(
|
|
response,
|
|
chunk,
|
|
parentObject,
|
|
key,
|
|
referenceArrayRoot,
|
|
map,
|
|
path,
|
|
);
|
|
case PENDING:
|
|
// If we don't have the referenced chunk yet, then this must be a forward reference,
|
|
// which is not allowed.
|
|
throw new Error('Invalid forward reference.');
|
|
default:
|
|
// This is an error. Instead of erroring directly, we're going to encode this on
|
|
// an initialization handler.
|
|
if (initializingHandler) {
|
|
initializingHandler.errored = true;
|
|
initializingHandler.value = null;
|
|
initializingHandler.reason = chunk.reason;
|
|
} else {
|
|
initializingHandler = {
|
|
chunk: null,
|
|
value: null,
|
|
reason: chunk.reason,
|
|
deps: 0,
|
|
errored: true,
|
|
};
|
|
}
|
|
// Placeholder
|
|
return (null: any);
|
|
}
|
|
}
|
|
|
|
function createMap(
|
|
response: Response,
|
|
model: Array<[any, any]>,
|
|
): Map<any, any> {
|
|
if (!isArray(model)) {
|
|
throw new Error('Invalid Map initializer.');
|
|
}
|
|
if ((model as any).$$consumed === true) {
|
|
throw new Error('Already initialized Map.');
|
|
}
|
|
const map = new Map(model);
|
|
(model as any).$$consumed = true;
|
|
return map;
|
|
}
|
|
|
|
function createSet(response: Response, model: Array<any>): Set<any> {
|
|
if (!isArray(model)) {
|
|
throw new Error('Invalid Set initializer.');
|
|
}
|
|
if ((model as any).$$consumed === true) {
|
|
throw new Error('Already initialized Set.');
|
|
}
|
|
const set = new Set(model);
|
|
(model as any).$$consumed = true;
|
|
return set;
|
|
}
|
|
|
|
function extractIterator(response: Response, model: Array<any>): Iterator<any> {
|
|
if (!isArray(model)) {
|
|
throw new Error('Invalid Iterator initializer.');
|
|
}
|
|
if ((model as any).$$consumed === true) {
|
|
throw new Error('Already initialized Iterator.');
|
|
}
|
|
// $FlowFixMe[incompatible-use]: This uses raw Symbols because we're extracting from a native array.
|
|
const iterator = model[Symbol.iterator]();
|
|
(model as any).$$consumed = true;
|
|
return iterator;
|
|
}
|
|
|
|
function createModel(
|
|
response: Response,
|
|
model: any,
|
|
parentObject: Object,
|
|
key: string,
|
|
): any {
|
|
if (key === 'then' && typeof model === 'function') {
|
|
// This should never happen because we always serialize objects with then-functions
|
|
// as "thenable" which reduces to ReactPromise with no other fields.
|
|
return null;
|
|
}
|
|
return model;
|
|
}
|
|
|
|
function parseTypedArray<T: $ArrayBufferView | ArrayBuffer>(
|
|
response: Response,
|
|
reference: string,
|
|
constructor: any,
|
|
bytesPerElement: number,
|
|
parentObject: Object,
|
|
parentKey: string,
|
|
referenceArrayRoot: null | NestedArrayContext,
|
|
): null {
|
|
const id = parseInt(reference.slice(2), 16);
|
|
const prefix = response._prefix;
|
|
const key = prefix + id;
|
|
const chunks = response._chunks;
|
|
if (chunks.has(id)) {
|
|
throw new Error('Already initialized typed array.');
|
|
}
|
|
chunks.set(
|
|
id,
|
|
// We don't need to put the actual Blob in the chunk,
|
|
// because it shouldn't be accessed by anything else.
|
|
createErroredChunk(response, new Error('Already initialized typed array.')),
|
|
);
|
|
|
|
// We should have this backingEntry in the store already because we emitted
|
|
// it before referencing it. It should be a Blob.
|
|
const backingEntry: Blob = (response._formData.get(key): any);
|
|
|
|
const promise: Promise<ArrayBuffer> = backingEntry.arrayBuffer();
|
|
|
|
// Since loading the buffer is an async operation we'll be blocking the parent
|
|
// chunk.
|
|
|
|
let handler: InitializationHandler;
|
|
if (initializingHandler) {
|
|
handler = initializingHandler;
|
|
handler.deps++;
|
|
} else {
|
|
handler = initializingHandler = {
|
|
chunk: null,
|
|
value: null,
|
|
reason: null,
|
|
deps: 1,
|
|
errored: false,
|
|
};
|
|
}
|
|
|
|
function fulfill(buffer: ArrayBuffer): void {
|
|
try {
|
|
if (referenceArrayRoot !== null) {
|
|
bumpArrayCount(referenceArrayRoot, buffer.byteLength, response);
|
|
}
|
|
|
|
const resolvedValue: T =
|
|
constructor === ArrayBuffer
|
|
? (buffer: any)
|
|
: (new constructor(buffer): any);
|
|
|
|
if (key !== __PROTO__) {
|
|
parentObject[parentKey] = resolvedValue;
|
|
}
|
|
|
|
// If this is the root object for a model reference, where `handler.value`
|
|
// is a stale `null`, the resolved value can be used directly.
|
|
if (parentKey === '' && handler.value === null) {
|
|
handler.value = resolvedValue;
|
|
}
|
|
} catch (x) {
|
|
reject(x);
|
|
return;
|
|
}
|
|
|
|
handler.deps--;
|
|
|
|
if (handler.deps === 0) {
|
|
const chunk = handler.chunk;
|
|
if (chunk === null || chunk.status !== BLOCKED) {
|
|
return;
|
|
}
|
|
const resolveListeners = chunk.value;
|
|
const initializedChunk: InitializedChunk<T> = (chunk: any);
|
|
initializedChunk.status = INITIALIZED;
|
|
initializedChunk.value = handler.value;
|
|
// We don't keep an array count for this since it won't be referenced again.
|
|
// In fact, we don't really need to store this chunk at all.
|
|
initializedChunk.reason = null;
|
|
if (resolveListeners !== null) {
|
|
wakeChunk(response, resolveListeners, handler.value, initializedChunk);
|
|
}
|
|
}
|
|
}
|
|
|
|
function reject(error: mixed): void {
|
|
if (handler.errored) {
|
|
// We've already errored. We could instead build up an AggregateError
|
|
// but if there are multiple errors we just take the first one like
|
|
// Promise.all.
|
|
return;
|
|
}
|
|
handler.errored = true;
|
|
handler.value = null;
|
|
handler.reason = error;
|
|
const chunk = handler.chunk;
|
|
if (chunk === null || chunk.status !== BLOCKED) {
|
|
return;
|
|
}
|
|
triggerErrorOnChunk(response, chunk, error);
|
|
}
|
|
|
|
promise.then(fulfill, reject);
|
|
|
|
return null;
|
|
}
|
|
|
|
function resolveStream<T: ReadableStream | $AsyncIterable<any, any, void>>(
|
|
response: Response,
|
|
id: number,
|
|
stream: T,
|
|
controller: FlightStreamController,
|
|
): void {
|
|
const chunks = response._chunks;
|
|
const chunk = createInitializedStreamChunk(response, stream, controller);
|
|
chunks.set(id, chunk);
|
|
|
|
const prefix = response._prefix;
|
|
const key = prefix + id;
|
|
const existingEntries = response._formData.getAll(key);
|
|
for (let i = 0; i < existingEntries.length; i++) {
|
|
const value = existingEntries[i];
|
|
if (typeof value === 'string') {
|
|
if (value[0] === 'C') {
|
|
controller.close(value === 'C' ? '"$undefined"' : value.slice(1));
|
|
} else {
|
|
controller.enqueueModel(value);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function parseReadableStream<T>(
|
|
response: Response,
|
|
reference: string,
|
|
type: void | 'bytes',
|
|
parentObject: Object,
|
|
parentKey: string,
|
|
): ReadableStream {
|
|
const id = parseInt(reference.slice(2), 16);
|
|
const chunks = response._chunks;
|
|
if (chunks.has(id)) {
|
|
throw new Error('Already initialized stream.');
|
|
}
|
|
|
|
let controller: ReadableStreamController = (null: any);
|
|
let closed = false;
|
|
const stream = new ReadableStream({
|
|
type: type,
|
|
start(c) {
|
|
controller = c;
|
|
},
|
|
});
|
|
let previousBlockedChunk: SomeChunk<T> | null = null;
|
|
function enqueue(value: T): void {
|
|
if (type === 'bytes' && !ArrayBuffer.isView(value)) {
|
|
flightController.error(new Error('Invalid data for bytes stream.'));
|
|
return;
|
|
}
|
|
controller.enqueue(value);
|
|
}
|
|
const flightController = {
|
|
enqueueModel(json: string): void {
|
|
if (previousBlockedChunk === null) {
|
|
// If we're not blocked on any other chunks, we can try to eagerly initialize
|
|
// this as a fast-path to avoid awaiting them.
|
|
const chunk: ResolvedModelChunk<T> = createResolvedModelChunk(
|
|
response,
|
|
json,
|
|
-1,
|
|
);
|
|
initializeModelChunk(chunk);
|
|
const initializedChunk: SomeChunk<T> = chunk;
|
|
if (initializedChunk.status === INITIALIZED) {
|
|
enqueue(initializedChunk.value);
|
|
} else {
|
|
chunk.then(enqueue, flightController.error);
|
|
previousBlockedChunk = chunk;
|
|
}
|
|
} else {
|
|
// We're still waiting on a previous chunk so we can't enqueue quite yet.
|
|
const blockedChunk = previousBlockedChunk;
|
|
const chunk: SomeChunk<T> = createPendingChunk(response);
|
|
chunk.then(enqueue, flightController.error);
|
|
previousBlockedChunk = chunk;
|
|
blockedChunk.then(function () {
|
|
if (previousBlockedChunk === chunk) {
|
|
// We were still the last chunk so we can now clear the queue and return
|
|
// to synchronous emitting.
|
|
previousBlockedChunk = null;
|
|
}
|
|
resolveModelChunk(response, chunk, json, -1);
|
|
});
|
|
}
|
|
},
|
|
close(json: string): void {
|
|
if (closed) {
|
|
return;
|
|
}
|
|
closed = true;
|
|
if (previousBlockedChunk === null) {
|
|
controller.close();
|
|
} else {
|
|
const blockedChunk = previousBlockedChunk;
|
|
// We shouldn't get any more enqueues after this so we can set it back to null.
|
|
previousBlockedChunk = null;
|
|
blockedChunk.then(() => controller.close());
|
|
}
|
|
},
|
|
error(error: mixed): void {
|
|
if (closed) {
|
|
return;
|
|
}
|
|
closed = true;
|
|
if (previousBlockedChunk === null) {
|
|
// $FlowFixMe[incompatible-call]
|
|
controller.error(error);
|
|
} else {
|
|
const blockedChunk = previousBlockedChunk;
|
|
// We shouldn't get any more enqueues after this so we can set it back to null.
|
|
previousBlockedChunk = null;
|
|
blockedChunk.then(() => controller.error((error: any)));
|
|
}
|
|
},
|
|
};
|
|
resolveStream(response, id, stream, flightController);
|
|
return stream;
|
|
}
|
|
|
|
function FlightIterator(
|
|
this: {next: (arg: void) => SomeChunk<IteratorResult<any, any>>, ...},
|
|
next: (arg: void) => SomeChunk<IteratorResult<any, any>>,
|
|
) {
|
|
this.next = next;
|
|
// TODO: Add return/throw as options for aborting.
|
|
}
|
|
// TODO: The iterator could inherit the AsyncIterator prototype which is not exposed as
|
|
// a global but exists as a prototype of an AsyncGenerator. However, it's not needed
|
|
// to satisfy the iterable protocol.
|
|
FlightIterator.prototype = ({}: any);
|
|
FlightIterator.prototype[ASYNC_ITERATOR] = function asyncIterator(
|
|
this: $AsyncIterator<any, any, void>,
|
|
) {
|
|
// Self referencing iterator.
|
|
return this;
|
|
};
|
|
|
|
function parseAsyncIterable<T>(
|
|
response: Response,
|
|
reference: string,
|
|
iterator: boolean,
|
|
parentObject: Object,
|
|
parentKey: string,
|
|
): $AsyncIterable<T, T, void> | $AsyncIterator<T, T, void> {
|
|
const id = parseInt(reference.slice(2), 16);
|
|
const chunks = response._chunks;
|
|
if (chunks.has(id)) {
|
|
throw new Error('Already initialized stream.');
|
|
}
|
|
|
|
const buffer: Array<SomeChunk<IteratorResult<T, T>>> = [];
|
|
let closed = false;
|
|
let nextWriteIndex = 0;
|
|
const flightController = {
|
|
enqueueModel(value: string): void {
|
|
if (nextWriteIndex === buffer.length) {
|
|
buffer[nextWriteIndex] = createResolvedIteratorResultChunk(
|
|
response,
|
|
value,
|
|
false,
|
|
);
|
|
} else {
|
|
resolveIteratorResultChunk(
|
|
response,
|
|
buffer[nextWriteIndex],
|
|
value,
|
|
false,
|
|
);
|
|
}
|
|
nextWriteIndex++;
|
|
},
|
|
close(value: string): void {
|
|
if (closed) {
|
|
return;
|
|
}
|
|
closed = true;
|
|
if (nextWriteIndex === buffer.length) {
|
|
buffer[nextWriteIndex] = createResolvedIteratorResultChunk(
|
|
response,
|
|
value,
|
|
true,
|
|
);
|
|
} else {
|
|
resolveIteratorResultChunk(
|
|
response,
|
|
buffer[nextWriteIndex],
|
|
value,
|
|
true,
|
|
);
|
|
}
|
|
nextWriteIndex++;
|
|
while (nextWriteIndex < buffer.length) {
|
|
// In generators, any extra reads from the iterator have the value undefined.
|
|
resolveIteratorResultChunk(
|
|
response,
|
|
buffer[nextWriteIndex++],
|
|
'"$undefined"',
|
|
true,
|
|
);
|
|
}
|
|
},
|
|
error(error: Error): void {
|
|
if (closed) {
|
|
return;
|
|
}
|
|
closed = true;
|
|
if (nextWriteIndex === buffer.length) {
|
|
buffer[nextWriteIndex] =
|
|
createPendingChunk<IteratorResult<T, T>>(response);
|
|
}
|
|
while (nextWriteIndex < buffer.length) {
|
|
triggerErrorOnChunk(response, buffer[nextWriteIndex++], error);
|
|
}
|
|
},
|
|
};
|
|
const iterable: $AsyncIterable<T, T, void> = {
|
|
[ASYNC_ITERATOR](): $AsyncIterator<T, T, void> {
|
|
let nextReadIndex = 0;
|
|
// $FlowFixMe[invalid-constructor] Flow doesn't support functions as constructors
|
|
return new FlightIterator((arg: void) => {
|
|
if (arg !== undefined) {
|
|
throw new Error(
|
|
'Values cannot be passed to next() of AsyncIterables passed to Client Components.',
|
|
);
|
|
}
|
|
if (nextReadIndex === buffer.length) {
|
|
if (closed) {
|
|
// $FlowFixMe[invalid-constructor] Flow doesn't support functions as constructors
|
|
return new ReactPromise(
|
|
INITIALIZED,
|
|
{done: true, value: undefined},
|
|
null,
|
|
);
|
|
}
|
|
buffer[nextReadIndex] =
|
|
createPendingChunk<IteratorResult<T, T>>(response);
|
|
}
|
|
return buffer[nextReadIndex++];
|
|
});
|
|
},
|
|
};
|
|
// TODO: If it's a single shot iterator we can optimize memory by cleaning up the buffer after
|
|
// reading through the end, but currently we favor code size over this optimization.
|
|
const stream = iterator ? iterable[ASYNC_ITERATOR]() : iterable;
|
|
resolveStream(response, id, stream, flightController);
|
|
return stream;
|
|
}
|
|
|
|
function parseModelString(
|
|
response: Response,
|
|
obj: Object,
|
|
key: string,
|
|
value: string,
|
|
reference: void | string,
|
|
arrayRoot: null | NestedArrayContext,
|
|
): any {
|
|
if (value[0] === '$') {
|
|
switch (value[1]) {
|
|
case '$': {
|
|
// This was an escaped string value.
|
|
if (arrayRoot !== null) {
|
|
bumpArrayCount(arrayRoot, value.length - 1, response);
|
|
}
|
|
return value.slice(1);
|
|
}
|
|
case '@': {
|
|
// Promise
|
|
const id = parseInt(value.slice(2), 16);
|
|
const chunk = getChunk(response, id);
|
|
return chunk;
|
|
}
|
|
case 'h': {
|
|
// Server Reference
|
|
const ref = value.slice(2);
|
|
return getOutlinedModel(
|
|
response,
|
|
ref,
|
|
obj,
|
|
key,
|
|
null,
|
|
loadServerReference,
|
|
);
|
|
}
|
|
case 'T': {
|
|
// Temporary Reference
|
|
if (
|
|
reference === undefined ||
|
|
response._temporaryReferences === undefined
|
|
) {
|
|
throw new Error(
|
|
'Could not reference an opaque temporary reference. ' +
|
|
'This is likely due to misconfiguring the temporaryReferences options ' +
|
|
'on the server.',
|
|
);
|
|
}
|
|
return createTemporaryReference(
|
|
response._temporaryReferences,
|
|
reference,
|
|
);
|
|
}
|
|
case 'Q': {
|
|
// Map
|
|
const ref = value.slice(2);
|
|
return getOutlinedModel(response, ref, obj, key, null, createMap);
|
|
}
|
|
case 'W': {
|
|
// Set
|
|
const ref = value.slice(2);
|
|
return getOutlinedModel(response, ref, obj, key, null, createSet);
|
|
}
|
|
case 'K': {
|
|
// FormData
|
|
const stringId = value.slice(2);
|
|
const formPrefix = response._prefix + stringId + '_';
|
|
const data = new FormData();
|
|
const backingFormData = response._formData;
|
|
// We assume that the reference to FormData always comes after each
|
|
// entry that it references so we can assume they all exist in the
|
|
// backing store already.
|
|
// Clone the keys to workaround bugs in the delete-while-iterating
|
|
// algorithm of FormData.
|
|
const keys = Array.from(backingFormData.keys());
|
|
for (let i = 0; i < keys.length; i++) {
|
|
const entryKey = keys[i];
|
|
if (entryKey.startsWith(formPrefix)) {
|
|
const entries = backingFormData.getAll(entryKey);
|
|
const newKey = entryKey.slice(formPrefix.length);
|
|
for (let j = 0; j < entries.length; j++) {
|
|
// $FlowFixMe[incompatible-call]
|
|
data.append(newKey, entries[j]);
|
|
}
|
|
// These entries have now all been consumed. Let's free it.
|
|
// This also ensures that we don't have any entries left if we
|
|
// see the same key twice.
|
|
backingFormData.delete(entryKey);
|
|
}
|
|
}
|
|
return data;
|
|
}
|
|
case 'i': {
|
|
// Iterator
|
|
const ref = value.slice(2);
|
|
return getOutlinedModel(response, ref, obj, key, null, extractIterator);
|
|
}
|
|
case 'I': {
|
|
// $Infinity
|
|
return Infinity;
|
|
}
|
|
case '-': {
|
|
// $-0 or $-Infinity
|
|
if (value === '$-0') {
|
|
return -0;
|
|
} else {
|
|
return -Infinity;
|
|
}
|
|
}
|
|
case 'N': {
|
|
// $NaN
|
|
return NaN;
|
|
}
|
|
case 'u': {
|
|
// matches "$undefined"
|
|
// Special encoding for `undefined` which can't be serialized as JSON otherwise.
|
|
return undefined;
|
|
}
|
|
case 'D': {
|
|
// Date
|
|
return new Date(Date.parse(value.slice(2)));
|
|
}
|
|
case 'n': {
|
|
// BigInt
|
|
const bigIntStr = value.slice(2);
|
|
if (bigIntStr.length > MAX_BIGINT_DIGITS) {
|
|
throw new Error(
|
|
'BigInt is too large. Received ' +
|
|
bigIntStr.length +
|
|
' digits but the limit is ' +
|
|
MAX_BIGINT_DIGITS +
|
|
'.',
|
|
);
|
|
}
|
|
if (arrayRoot !== null) {
|
|
bumpArrayCount(arrayRoot, bigIntStr.length, response);
|
|
}
|
|
return BigInt(bigIntStr);
|
|
}
|
|
case 'A':
|
|
return parseTypedArray(
|
|
response,
|
|
value,
|
|
ArrayBuffer,
|
|
1,
|
|
obj,
|
|
key,
|
|
arrayRoot,
|
|
);
|
|
case 'O':
|
|
return parseTypedArray(
|
|
response,
|
|
value,
|
|
Int8Array,
|
|
1,
|
|
obj,
|
|
key,
|
|
arrayRoot,
|
|
);
|
|
case 'o':
|
|
return parseTypedArray(
|
|
response,
|
|
value,
|
|
Uint8Array,
|
|
1,
|
|
obj,
|
|
key,
|
|
arrayRoot,
|
|
);
|
|
case 'U':
|
|
return parseTypedArray(
|
|
response,
|
|
value,
|
|
Uint8ClampedArray,
|
|
1,
|
|
obj,
|
|
key,
|
|
arrayRoot,
|
|
);
|
|
case 'S':
|
|
return parseTypedArray(
|
|
response,
|
|
value,
|
|
Int16Array,
|
|
2,
|
|
obj,
|
|
key,
|
|
arrayRoot,
|
|
);
|
|
case 's':
|
|
return parseTypedArray(
|
|
response,
|
|
value,
|
|
Uint16Array,
|
|
2,
|
|
obj,
|
|
key,
|
|
arrayRoot,
|
|
);
|
|
case 'L':
|
|
return parseTypedArray(
|
|
response,
|
|
value,
|
|
Int32Array,
|
|
4,
|
|
obj,
|
|
key,
|
|
arrayRoot,
|
|
);
|
|
case 'l':
|
|
return parseTypedArray(
|
|
response,
|
|
value,
|
|
Uint32Array,
|
|
4,
|
|
obj,
|
|
key,
|
|
arrayRoot,
|
|
);
|
|
case 'G':
|
|
return parseTypedArray(
|
|
response,
|
|
value,
|
|
Float32Array,
|
|
4,
|
|
obj,
|
|
key,
|
|
arrayRoot,
|
|
);
|
|
case 'g':
|
|
return parseTypedArray(
|
|
response,
|
|
value,
|
|
Float64Array,
|
|
8,
|
|
obj,
|
|
key,
|
|
arrayRoot,
|
|
);
|
|
case 'M':
|
|
return parseTypedArray(
|
|
response,
|
|
value,
|
|
BigInt64Array,
|
|
8,
|
|
obj,
|
|
key,
|
|
arrayRoot,
|
|
);
|
|
case 'm':
|
|
return parseTypedArray(
|
|
response,
|
|
value,
|
|
BigUint64Array,
|
|
8,
|
|
obj,
|
|
key,
|
|
arrayRoot,
|
|
);
|
|
case 'V':
|
|
return parseTypedArray(
|
|
response,
|
|
value,
|
|
DataView,
|
|
1,
|
|
obj,
|
|
key,
|
|
arrayRoot,
|
|
);
|
|
case 'B': {
|
|
// Blob
|
|
const id = parseInt(value.slice(2), 16);
|
|
const prefix = response._prefix;
|
|
const blobKey = prefix + id;
|
|
// We should have this backingEntry in the store already because we emitted
|
|
// it before referencing it. It should be a Blob.
|
|
const backingEntry: Blob = (response._formData.get(blobKey): any);
|
|
return backingEntry;
|
|
}
|
|
case 'R': {
|
|
return parseReadableStream(response, value, undefined, obj, key);
|
|
}
|
|
case 'r': {
|
|
return parseReadableStream(response, value, 'bytes', obj, key);
|
|
}
|
|
case 'X': {
|
|
return parseAsyncIterable(response, value, false, obj, key);
|
|
}
|
|
case 'x': {
|
|
return parseAsyncIterable(response, value, true, obj, key);
|
|
}
|
|
}
|
|
// We assume that anything else is a reference ID.
|
|
const ref = value.slice(1);
|
|
return getOutlinedModel(response, ref, obj, key, arrayRoot, createModel);
|
|
}
|
|
if (arrayRoot !== null) {
|
|
bumpArrayCount(arrayRoot, value.length, response);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
const DEFAULT_MAX_ARRAY_NESTING = 1000000;
|
|
|
|
// Limit BigInt size to prevent CPU exhaustion from parsing very large values.
|
|
// 300 digits covers most practical use cases (even 512-bit integers need only
|
|
// ~154 digits) and aligns with the implicit limit from the Number approximation
|
|
// checks in fulfillReference and getOutlinedModel.
|
|
const MAX_BIGINT_DIGITS = 300;
|
|
|
|
export const MAX_BOUND_ARGS = 1000;
|
|
|
|
export function createResponse(
|
|
bundlerConfig: ServerManifest,
|
|
formFieldPrefix: string,
|
|
temporaryReferences: void | TemporaryReferenceSet,
|
|
backingFormData?: FormData = new FormData(),
|
|
arraySizeLimit?: number = DEFAULT_MAX_ARRAY_NESTING,
|
|
): Response {
|
|
const chunks: Map<number, SomeChunk<any>> = new Map();
|
|
const response: Response = {
|
|
_bundlerConfig: bundlerConfig,
|
|
_prefix: formFieldPrefix,
|
|
_formData: backingFormData,
|
|
_chunks: chunks,
|
|
_closed: false,
|
|
_closedReason: null,
|
|
_temporaryReferences: temporaryReferences,
|
|
_rootArrayContexts: new WeakMap(),
|
|
_arraySizeLimit: arraySizeLimit,
|
|
};
|
|
return response;
|
|
}
|
|
|
|
export function resolveField(
|
|
response: Response,
|
|
key: string,
|
|
value: string,
|
|
): void {
|
|
// Add this field to the backing store.
|
|
response._formData.append(key, value);
|
|
const prefix = response._prefix;
|
|
if (key.startsWith(prefix)) {
|
|
const chunks = response._chunks;
|
|
const id = +key.slice(prefix.length);
|
|
const chunk = chunks.get(id);
|
|
if (chunk) {
|
|
// We were waiting on this key so now we can resolve it.
|
|
resolveModelChunk(response, chunk, value, id);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function resolveFile(response: Response, key: string, file: File): void {
|
|
// Add this field to the backing store.
|
|
response._formData.append(key, file);
|
|
}
|
|
|
|
export opaque type FileHandle = {
|
|
chunks: Array<Uint8Array>,
|
|
filename: string,
|
|
mime: string,
|
|
};
|
|
|
|
export function resolveFileInfo(
|
|
response: Response,
|
|
key: string,
|
|
filename: string,
|
|
mime: string,
|
|
): FileHandle {
|
|
return {
|
|
chunks: [],
|
|
filename,
|
|
mime,
|
|
};
|
|
}
|
|
|
|
export function resolveFileChunk(
|
|
response: Response,
|
|
handle: FileHandle,
|
|
chunk: Uint8Array,
|
|
): void {
|
|
handle.chunks.push(chunk);
|
|
}
|
|
|
|
export function resolveFileComplete(
|
|
response: Response,
|
|
key: string,
|
|
handle: FileHandle,
|
|
): void {
|
|
// Add this file to the backing store.
|
|
// Node.js doesn't expose a global File constructor so we need to use
|
|
// the append() form that takes the file name as the third argument,
|
|
// to create a File object.
|
|
const blob = new Blob(handle.chunks, {type: handle.mime});
|
|
response._formData.append(key, blob, handle.filename);
|
|
}
|
|
|
|
export function close(response: Response): void {
|
|
// In case there are any remaining unresolved chunks, they won't
|
|
// be resolved now. So we need to issue an error to those.
|
|
// Ideally we should be able to early bail out if we kept a
|
|
// ref count of pending chunks.
|
|
reportGlobalError(response, new Error('Connection closed.'));
|
|
}
|