200 lines
8.5 KiB
JavaScript
200 lines
8.5 KiB
JavaScript
"use strict";
|
|
/**
|
|
* Copyright © 2024 650 Industries.
|
|
* Copyright © 2024 dai-shi.
|
|
*
|
|
* This source code is licensed under the MIT license found in the
|
|
* LICENSE file in the root directory of this source tree.
|
|
*
|
|
* From waku https://github.com/dai-shi/waku/blob/32d52242c1450b5f5965860e671ff73c42da8bd0/packages/waku/src/lib/renderers/rsc-renderer.ts
|
|
*/
|
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
exports.renderRsc = renderRsc;
|
|
// This file must remain platform agnostic for production exports.
|
|
// Import the runtime to support polyfills for webpack to load modules in the server using Metro.
|
|
require("@expo/metro-runtime/rsc/runtime");
|
|
const rsc_1 = require("expo-router/internal/rsc");
|
|
const server_1 = require("react-server-dom-webpack/server");
|
|
const path_1 = require("./path");
|
|
const server_2 = require("./server");
|
|
async function renderRsc(args, opts) {
|
|
const { input, body, contentType, context, onError } = args;
|
|
const { resolveClientEntry, entries } = opts;
|
|
const { default: { renderEntries },
|
|
// @ts-expect-error
|
|
buildConfig, } = entries;
|
|
function resolveRequest(isServer, encodedId) {
|
|
const [
|
|
// File is the on-disk location of the module, this is injected during the "use client" transformation (babel).
|
|
file,
|
|
// The name of the import (e.g. "default" or "")
|
|
// This will be empty when using `module.exports = ` and `require('...')`.
|
|
name = '',] = encodedId.split('#');
|
|
const filePath = file.startsWith('file://') ? (0, path_1.fileURLToFilePath)(file) : file;
|
|
args.moduleIdCallback?.({
|
|
id: filePath,
|
|
chunks: [
|
|
// TODO: Add a lookup later which reads from the SSR manifest to get the correct chunk.
|
|
// NOTE(EvanBacon): This is a placeholder since we need to render RSC to get the client boundaries, which we then inject later.
|
|
'chunk:' + filePath,
|
|
],
|
|
name,
|
|
async: true,
|
|
});
|
|
// We'll augment the file path with the incoming RSC request which will forward the metro props required to make a cache hit, e.g. platform=web&...
|
|
// This is similar to how we handle lazy bundling.
|
|
const resolved = resolveClientEntry(filePath, isServer);
|
|
return { id: resolved.id, chunks: resolved.chunks, name, async: true };
|
|
}
|
|
const bundlerConfig = new Proxy({}, {
|
|
get(_target, encodedId) {
|
|
return resolveRequest(false, encodedId);
|
|
},
|
|
});
|
|
const serverConfig = new Proxy({}, {
|
|
get(_target, encodedId) {
|
|
return resolveRequest(true, encodedId);
|
|
},
|
|
});
|
|
// @ts-ignore: Not part of global types. This is added to support server actions loading more actions.
|
|
global[`${__METRO_GLOBAL_PREFIX__}__loadBundleAsync`] = opts.loadServerModuleRsc;
|
|
const renderWithContext = async (context, input, params) => {
|
|
const renderStore = {
|
|
context: context || {},
|
|
rerender: () => {
|
|
throw new Error('Cannot rerender');
|
|
},
|
|
};
|
|
return (0, server_2.runWithRenderStore)(renderStore, async () => {
|
|
const elements = await renderEntries(input, {
|
|
params,
|
|
buildConfig,
|
|
});
|
|
if (elements === null) {
|
|
const err = new Error('No function component found at: ' + input);
|
|
err.statusCode = 404;
|
|
throw err;
|
|
}
|
|
if (Object.keys(elements).some((key) => key.startsWith('_'))) {
|
|
throw new Error('"_" prefix is reserved');
|
|
}
|
|
return (0, server_1.renderToReadableStream)(elements, bundlerConfig, {
|
|
onError,
|
|
});
|
|
});
|
|
};
|
|
const renderWithContextWithAction = async (context, actionFn, actionArgs) => {
|
|
let elementsPromise = Promise.resolve({});
|
|
let rendered = false;
|
|
const renderStore = {
|
|
context: context || {},
|
|
rerender: async (input, params) => {
|
|
if (rendered) {
|
|
throw new Error('already rendered');
|
|
}
|
|
elementsPromise = Promise.all([
|
|
elementsPromise,
|
|
renderEntries(input, { params, buildConfig }),
|
|
]).then(([oldElements, newElements]) => ({
|
|
...oldElements,
|
|
// FIXME we should actually check if newElements is null and send an error
|
|
...newElements,
|
|
}));
|
|
},
|
|
};
|
|
return (0, server_2.runWithRenderStore)(renderStore, async () => {
|
|
const actionValue = await actionFn(...actionArgs);
|
|
const elements = await elementsPromise;
|
|
rendered = true;
|
|
if (Object.keys(elements).some((key) => key.startsWith('_'))) {
|
|
throw new Error('"_" prefix is reserved');
|
|
}
|
|
return (0, server_1.renderToReadableStream)({ ...elements, _value: actionValue }, bundlerConfig, {
|
|
onError,
|
|
});
|
|
});
|
|
};
|
|
let decodedBody = args.decodedBody;
|
|
if (body) {
|
|
const bodyStr = await streamToString(body);
|
|
if (typeof contentType === 'string' && contentType.startsWith('multipart/form-data')) {
|
|
// XXX This doesn't support streaming unlike busboy
|
|
const formData = parseFormData(bodyStr, contentType);
|
|
decodedBody = await (0, server_1.decodeReply)(formData, serverConfig);
|
|
}
|
|
else if (bodyStr) {
|
|
decodedBody = await (0, server_1.decodeReply)(bodyStr, serverConfig);
|
|
}
|
|
}
|
|
const actionId = (0, rsc_1.decodeActionId)(input);
|
|
if (actionId) {
|
|
if (!opts.isExporting &&
|
|
// @ts-ignore
|
|
!process.env.EXPO_UNSTABLE_SERVER_FUNCTIONS) {
|
|
throw new Error('Experimental support for React Server Functions is not enabled');
|
|
}
|
|
const args = Array.isArray(decodedBody) ? decodedBody : [];
|
|
const chunkInfo = serverConfig[actionId];
|
|
// Load module into memory.
|
|
await Promise.all(chunkInfo.chunks.map((chunk) => globalThis.__webpack_chunk_load__(chunk)));
|
|
// Import module.
|
|
const mod = globalThis.__webpack_require__(chunkInfo.id);
|
|
const fn = chunkInfo.name === '*' ? chunkInfo.name : mod[chunkInfo.name] || mod;
|
|
if (!fn) {
|
|
throw new Error(`Could not find server action: ${actionId}. Module: ${JSON.stringify(chunkInfo, null, 2)}`);
|
|
}
|
|
return renderWithContextWithAction(context, fn, args);
|
|
}
|
|
// method === 'GET'
|
|
return renderWithContext(context, input, decodedBody);
|
|
}
|
|
// TODO is this correct? better to use a library?
|
|
const parseFormData = (body, contentType) => {
|
|
const boundary = contentType.split('boundary=')[1];
|
|
const parts = body.split(`--${boundary}`);
|
|
const formData = new FormData();
|
|
for (const part of parts) {
|
|
if (part.trim() === '' || part === '--')
|
|
continue;
|
|
const [rawHeaders, content] = part.split('\r\n\r\n', 2);
|
|
const headers = rawHeaders.split('\r\n').reduce((acc, currentHeader) => {
|
|
const [key, value] = currentHeader.split(': ');
|
|
acc[key.toLowerCase()] = value;
|
|
return acc;
|
|
}, {});
|
|
const contentDisposition = headers['content-disposition'];
|
|
const nameMatch = /name="([^"]+)"/.exec(contentDisposition);
|
|
const filenameMatch = /filename="([^"]+)"/.exec(contentDisposition);
|
|
if (nameMatch) {
|
|
const name = nameMatch[1];
|
|
if (filenameMatch) {
|
|
const filename = filenameMatch[1];
|
|
const type = headers['content-type'] || 'application/octet-stream';
|
|
const blob = new Blob([content], { type });
|
|
formData.append(name, blob, filename);
|
|
}
|
|
else {
|
|
formData.append(name, content.trim());
|
|
}
|
|
}
|
|
}
|
|
return formData;
|
|
};
|
|
const streamToString = async (stream) => {
|
|
const decoder = new TextDecoder();
|
|
const reader = stream.getReader();
|
|
const outs = [];
|
|
let result;
|
|
do {
|
|
result = await reader.read();
|
|
if (result.value) {
|
|
if (!(result.value instanceof Uint8Array)) {
|
|
throw new Error('Unexepected buffer type');
|
|
}
|
|
outs.push(decoder.decode(result.value, { stream: true }));
|
|
}
|
|
} while (!result.done);
|
|
outs.push(decoder.decode());
|
|
return outs.join('');
|
|
};
|
|
//# sourceMappingURL=rsc-renderer.js.map
|