Skip to content

Commit

Permalink
Implement asset-preloading for all scenarios
Browse files Browse the repository at this point in the history
This moves from the marker function to a very specific object property
name. This follows what was done in:
- zth/rescript-relay#369
- #47
- #48

This reimplements the changes of those last two R3 PRs on top of the
work done to turn server.mjs into ReScript code. A few changes are made
with respect to the original PRs.

*Vite Plugin*
- I did not alter the user’s config. The rollup option not working with
  the `SSR` option is not caused by our plugin, so it shouldn’t be fixed
  in our plugin. It’s a user configuration error. Altering the config
  without the user knowing may confuse them.
- Removed `production` check in `transform` this allows using the Vite
  generated `ssr-manifest.json`
- Removed the `Rescript` check in `transform`, our regex should be fast
  enough that this doesn’t matter and it ensures our plugin works in
  codebases or pipelines that strip this header.
- Removed tracking of `didReplace` since `magic-string` does this for us.

*EntryServer.res*
- Omitted asset emitting change for RelayRouter since the function has a
  different shape than RelaySSRUtils.AssetRegisterer. This was because
 `eagerPreloadFn` was not available in the function the router got.
  Ideally when we bring this back we only require the consuming developer
  to create a single preload handler on top of our transformation stream.

*package.json*
- It looks like the introduction of SSR in production mode has also exposed
  the bug in Relay’s faulty exports (or ReScript/Vite’s handling of them)
  here.
- Upgrade `history` to 5.3.0 which is required for ESM support.
  • Loading branch information
Kingdutch committed Jun 28, 2022
1 parent 18f1b1c commit a3db9be
Show file tree
Hide file tree
Showing 7 changed files with 156 additions and 29 deletions.
113 changes: 106 additions & 7 deletions RescriptRelayVitePlugin.mjs
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import fs from "fs";
import fsPromised from "fs/promises";
import path from "path";
import readline from "readline";
import MagicString from "magic-string";
import { normalizePath } from 'vite'
import { runCli } from "./cli/RescriptRelayRouterCli__Commands.mjs";

/**
* @typedef {import("vite").ResolvedConfig} ResolvedConfig
*/

// Expected to run in vite.config.js folder, right next to bsconfig.
let cwd = process.cwd();

Expand Down Expand Up @@ -69,10 +75,28 @@ export let rescriptRelayVitePlugin = ({
autoScaffoldRenderers = true,
deleteRemoved = true,
} = {}) => {
// The watcher for the ReScript Relay Router CLI.
let watcher;
// An in-memory copy of the ssr-manifest.json for bundle manipulation.
let ssrManifest = {};
// The resolved Vite config to ensure we do what the rest of Vite does.
/** @type ResolvedConfig */
let config;

return {
name: "rescript-relay",
/**
* @param {ResolvedConfig} resolvedConfig
*/
configResolved(resolvedConfig) {
config = resolvedConfig
// For the server build in SSR we read the client manifest from disk.
if (config.build.ssr) {
// TODO: This relies on the client and server paths being next to eachother. Perhaps add config?
// TODO: SSR Manifest name is configurable in Vite and may be different.
ssrManifest = JSON.parse(fs.readFileSync(path.resolve(config.build.outDir, "../client/ssr-manifest.json"), 'utf-8'));
}
},
buildStart() {
// Run single generate in prod
if (process.env.NODE_ENV === "production") {
Expand Down Expand Up @@ -128,21 +152,25 @@ export let rescriptRelayVitePlugin = ({
}
}
},
// Transforms the magic string `__transformReScriptModuleToJsPath("@rescriptModule/package")`
// into the actual path fo the asset.
// Transforms the magic object property's value `__$rescriptChunkName__` from `ModuleName` (without extension)
// into the actual path for the compiled asset.
async transform(code, id) {
const transformedCode = await replaceAsyncWithMagicString(
code,
/__transformReScriptModuleToJsPath\("@rescriptModule\/([A-Za-z0-9_]*)"\)/gm,
/__\$rescriptChunkName__:\s*"([A-Za-z0-9_]+)"/gm,
async (fullMatch, moduleId) => {
if (moduleId != null && moduleId !== "") {
let resolved = await findGeneratedModule(moduleId);
if (resolved != null) {
// Transform the absolute path from findGeneratedModule to a relative path.
if (path.isAbsolute(resolved)) {
resolved = path.normalize(path.relative(process.cwd(), resolved))
// The location of findGeneratedModule is an absolute URL but we
// want the URL relative to the project root. That's also what
// vite uses internally as URL for src assets.
resolved = resolved.replace(config.root, "");

if (resolved.includes("index.")) {
throw new Error();
}
return `"${resolved}"`;
return `__$rescriptChunkName__: "${resolved}"`;
}
console.warn(`Could not resolve Rescript Module '${moduleId}' for match '${fullMatch}'.`);
}
Expand All @@ -154,6 +182,10 @@ export let rescriptRelayVitePlugin = ({
}
);

if (!transformedCode.hasChanged()) {
return null;
}

const sourceMap = transformedCode.generateMap({
source: id,
file: `${id}.map`,
Expand All @@ -162,8 +194,75 @@ export let rescriptRelayVitePlugin = ({
return {
code: transformedCode.toString(),
map: sourceMap.toString(),
};
},
// In addition to the transform from ReScript module name to JS file.
// In production we want to change the JS file name to the corresponding chunk that contains the compiled JS.
// This is similar to what Rollup does for us for `import` statements.
// We start out by creating a lookup table of JS files to output assets.
// This is copied from vite/packages/vite/src/node/ssr/ssrManifestPlugin.ts but does not track CSS files.
generateBundle(_options, bundle) {
// We only have to collect the ssr-manifest during client bundling.
// For SSR it's just read from disk.
if (config.build.ssr) {
return;
}
for (const file in bundle) {
const chunk = bundle[file]
if (chunk.type === 'chunk') {
for (const id in chunk.modules) {
const normalizedId = normalizePath(path.relative(config.root, id))
const mappedChunks =
ssrManifest[normalizedId] ?? (ssrManifest[normalizedId] = [])
if (!chunk.isEntry) {
mappedChunks.push(config.base + chunk.fileName)
}
chunk.viteMetadata.importedAssets.forEach((file) => {
mappedChunks.push(config.base + file)
})
}
}
}
},
// We can't do the gathering of chunk names at the same time but must complete all of that
// before we can do the replacement so we know we replace all. Therefore we do this in
// writeBundle which also only runs in production like generateBundle.
writeBundle(outConfig, bundle) {
Object.entries(bundle).forEach(async ([_bundleName, bundleContents]) => {
const code = bundleContents.code;
if (typeof code === "undefined") {
return;
}
const transformedCode = await replaceAsyncWithMagicString(
code,
/__\$rescriptChunkName__:\s*"\/([A-Za-z0-9_\/\.]+)"/gm,
(fullMatch, jsUrl) => {
if (jsUrl != null && jsUrl !== "") {
let chunk = ssrManifest[jsUrl][0] ?? null;
if (chunk.includes("index.")) {
throw new Error();
}
if (chunk !== null) {
return `__$rescriptChunkName__:"${chunk}"`;
}
console.warn(`Could not find chunk path for '${jsUrl}' for match '${fullMatch}'.`);
}
else {
console.warn(`Tried to rewrite compiled path to chunk but match '${fullMatch}' didn't contain a compiled path.`);
}

return fullMatch;
}
);

if (transformedCode.hasChanged()) {
await fsPromised.writeFile(
path.resolve(outConfig.dir, bundleContents.fileName),
transformedCode.toString()
);
}
});
}
};
};

Expand Down
23 changes: 14 additions & 9 deletions Server.res
Original file line number Diff line number Diff line change
Expand Up @@ -41,20 +41,25 @@ switch (NodeJs.isProduction) {
->Js.Json.decodeObject
->Belt.Option.getExn

// This will throw an exception if our manifest doesn't include an "index.html" entry.
// That's what Vite uses for our main app entry point.
// We must prefix with `/` (Vite's configured root) because the manifest only contains paths relative to base.
// TODO: This breaks if vite.base is not `/`.
let clientBundle = "/" ++ manifest->Js.Dict.get("index.html")
->Belt.Option.getExn
->Js.Json.decodeObject
->Belt.Option.getExn
->Js.Dict.unsafeGet("file")
->Js.Json.decodeString
->Belt.Option.getExn

// TODO: Read clientBundle deps from manifest so we can also immediatly load those direct dependencies.

// Load our compiled production server entry.
import_("./dist/server/EntryServer.js")
->Promise.then(imported => imported["default"])
->Promise.thenResolve(handleRequest => {

// This will throw an exception if our manifest doesn't include an "index.html" entry.
// That's what Vite uses for our main app entry point.
let clientBundle = manifest
->Js.Dict.get("index.html")
->Belt.Option.getExn
->Js.Json.decodeObject
->Belt.Option.getExn
->Js.Dict.unsafeGet("file")

// Production server side rendering helper.
app->useRoute("*", (request, response) => {
handleRequest(~request, ~response, ~clientScripts=[j`<script type="module" src="$clientBundle" async></script>`])
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"build:vite": "run-s build:vite:*",
"build:vite:client": "vite build --outDir dist/client --ssrManifest --manifest",
"build:vite:server": "cross-env IS_VITE_SSR=1 vite build --outDir dist/server --ssr src/EntryServer.mjs",
"build:vite:server-fix": "perl -i -pe 's/import \\* as ReactRelay/import ReactRelay/' dist/server/EntryServer.js",
"preview": "cross-env ENABLE_FILESERVER=true yarn start",
"start": "cross-env NODE_ENV=production node Server.mjs",
"dev": "run-s build:relay build:rescript && run-p dev:*",
Expand Down
8 changes: 7 additions & 1 deletion router/RelayRouter__Types.res
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,15 @@ type renderRouteFn = (. ~childRoutes: React.element) => React.element
@live
type preloadPriority = High | Default | Low

type preloadComponentAsset = {
moduleName: string,
@as("__$rescriptChunkName__") chunk: string,
eagerPreloadFn: unit => unit,
}

@live
type preloadAsset =
| Component({moduleName: string, chunk: string, eagerPreloadFn: unit => unit})
| Component(preloadComponentAsset)
| Image({url: string})

type preparedRoute = {routeKey: string, render: renderRouteFn}
Expand Down
8 changes: 6 additions & 2 deletions src/EntryServer.res
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ module PreloadInsertingStream = {
@send external onAssetPreload : (t, string) => () = "onAssetPreload"
}


// TODO: Remove this if the TODO around line 70 is accepted.
// Otherwise move this into NodeJS bindings.
@send external writeToStream : (NodeJs.Stream.Writable.t, string) => unit = "write"
Expand Down Expand Up @@ -56,7 +55,12 @@ let default = (~request, ~response, ~clientScripts) => {
let stream = ref(None)
stream := ReactDOMServer.renderToPipeableStream(
<RelaySSRUtils.AssetRegisterer.Provider
value={_ => (/* TODO: Figure out asset to module converter. */)}>
value={asset => switch(asset) {
// TODO: If multiple lazy components are in the same chunk then this may load the same asset multiple times.
| Component({ chunk }) => transformOutputStream->PreloadInsertingStream.onAssetPreload(j`<script type="module" src="$chunk" async></script>`)
| _ => () // Unimplemented
}}
>
<Main environment routerContext />
</RelaySSRUtils.AssetRegisterer.Provider>,
ReactDOMServer.renderToPipeableStreamOptions(
Expand Down
10 changes: 5 additions & 5 deletions test/RescriptRelayVitePlugin.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,15 @@ describe("RescriptRelayVitePlugin", () => {
const testCode = `[
{
type: "script",
url: __transformReScriptModuleToJsPath("@rescriptModule/RelayRouter"),
__$rescriptChunkName__: "RelayRouter",
},
{
type: "image",
url: "/assets/myimg.jpg",
},
{
type: "script",
url: __transformReScriptModuleToJsPath("@rescriptModule/RelayRouter")
__$rescriptChunkName__: "RelayRouter"
}
]`;

Expand All @@ -39,18 +39,18 @@ describe("RescriptRelayVitePlugin", () => {
code: `[
{
type: "script",
url: "${resultPath}",
__$rescriptChunkName__: "${resultPath}",
},
{
type: "image",
url: "/assets/myimg.jpg",
},
{
type: "script",
url: "${resultPath}"
__$rescriptChunkName__: "${resultPath}"
}
]`,
map: '{"version":3,"file":"test.mjs.map","sources":["test.mjs"],"sourcesContent":[null],"names":[],"mappings":"AAAA;AACA;AACA;AACA,eAAe,wBAAgE;AAC/E;AACA;AACA;AACA;AACA;AACA;AACA;AACA,eAAe,wBAAgE;AAC/E;AACA"}'
map: '{"version":3,"file":"test.mjs.map","sources":["test.mjs"],"sourcesContent":[null],"names":[],"mappings":"AAAA;AACA;AACA;AACA,UAAU,gDAAqC;AAC/C;AACA;AACA;AACA;AACA;AACA;AACA;AACA,UAAU,gDAAqC;AAC/C;AACA"}'
}

expect(await plugin.transform(testCode, "test.mjs")).toEqual(expected);
Expand Down
22 changes: 17 additions & 5 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1186,7 +1186,7 @@
dependencies:
regenerator-runtime "^0.13.4"

"@babel/runtime@^7.10.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.3.1", "@babel/runtime@^7.4.4", "@babel/runtime@^7.5.0", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7":
"@babel/runtime@^7.10.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.3.1", "@babel/runtime@^7.4.4", "@babel/runtime@^7.5.0", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7":
version "7.13.8"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.13.8.tgz#cc886a85c072df1de23670dc1aa59fc116c4017c"
integrity sha512-CwQljpw6qSayc0fRG1soxHAKs1CnQMOChm4mlQP6My0kf9upVGizj/KhlTTgyUnETmHpcUXjaluNAkteRFuafg==
Expand All @@ -1200,6 +1200,13 @@
dependencies:
regenerator-runtime "^0.13.4"

"@babel/runtime@^7.7.6":
version "7.18.6"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.18.6.tgz#6a1ef59f838debd670421f8c7f2cbb8da9751580"
integrity sha512-t9wi7/AW6XtKahAe20Yw0/mMljKq0B1r2fPdvaAdV/KPDZewFXdaaa6K7lxmZBZ8FBNpCiAT6iHPmd6QO9bKfQ==
dependencies:
regenerator-runtime "^0.13.4"

"@babel/template@^7.12.13", "@babel/template@^7.12.7":
version "7.12.13"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.12.13.tgz#530265be8a2589dbb37523844c5bcb55947fb327"
Expand Down Expand Up @@ -6667,9 +6674,9 @@ highlight.js@^10.1.1, highlight.js@~10.6.0:
integrity sha512-8mlRcn5vk/r4+QcqerapwBYTe+iPL5ih6xrNylxrnBdHQiijDETfXX7VIxC3UiCRiINBJfANBAsPzAvRQj8RpQ==

history@^5.2.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/history/-/history-5.2.0.tgz#7cdd31cf9bac3c5d31f09c231c9928fad0007b7c"
integrity sha512-uPSF6lAJb3nSePJ43hN3eKj1dTWpN9gMod0ZssbFTIsen+WehTmEadgL+kg78xLJFdRfrrC//SavDzmRVdE+Ig==
version "5.3.0"
resolved "https://registry.yarnpkg.com/history/-/history-5.3.0.tgz#1548abaa245ba47992f063a0783db91ef201c73b"
integrity sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ==
dependencies:
"@babel/runtime" "^7.7.6"

Expand Down Expand Up @@ -10264,7 +10271,12 @@ regenerate@^1.4.0:
resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.2.tgz#b9346d8827e8f5a32f7ba29637d398b69014848a"
integrity sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==

regenerator-runtime@^0.13.4, regenerator-runtime@^0.13.7:
regenerator-runtime@^0.13.4:
version "0.13.9"
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz#8925742a98ffd90814988d7566ad30ca3b263b52"
integrity sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==

regenerator-runtime@^0.13.7:
version "0.13.7"
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz#cac2dacc8a1ea675feaabaeb8ae833898ae46f55"
integrity sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==
Expand Down

0 comments on commit a3db9be

Please sign in to comment.