Repository: ProjectEvergreen/greenwood (js)

Issue:
single file bundles (SFBs) for SSR page and API routes
## Summary
Coming out of the PR for #1088 wanted to track an issue around the output of the bundle files created for SSR page and API route bundles.  It would be nice to make sure all these files are as self contained as possible to not have to rely on any particular bundling setups for different hosting providers.

In particular this applies to SSR pages, as currently two output files are generated
- `__<page>.js` - the entry point file used to invoke a `_<page>.js` file that exports itself as a `handler` function (like in the `server` command)
- `_<page>.js` - the bundled output of `executeModuleUrl`
- As well as _execute-route-module.js_. 


Another (potential) complication / urgency for this has been observed in https://github.com/thescientist13/greenwood-demo-adapter-vercel-lit/issues/3 where in now our page output bundling is not consistent, because we have to do hacky things [like this](https://github.com/ProjectEvergreen/greenwood/blob/v0.29.0-alpha.5/packages/cli/src/config/rollup.config.js#L277).  😬 

In general there should be a way to collapse down a lot of these files into one.  🙏 

## Details
One particular callout on this is that **WCC** take a `URL` for `renderToString` as the entry point, which means at least one file has to exist on disk I guess, meaning there will always be an entry point and then the actual userland code?

In addition this would also mean making sure Rollup doesn't create chunks.

edit: also, coming out #1122 and the actual prototypes for the Vercel and Netlify adapters, the current implementation of adapter plugins by their current design _also_ introduce an entry point, which also seems by nature of the design, e.g.
```sh
functions/
  my-function/
    my-function.js // userland entrypoint
    index.js // adapter wrapper that calls my-function.js
    package.json
```

Also see examples of this in the adapter plugins that have to copy a bunch of files around when preparing for uploading to the respective hosting platform
- #1128 

----

> _**Note:** Assuming we get through #1008 without any issues like if things break _without_ only a single file, otherwise as this is more of an enhancement / optimization, this could either be a 1.0 or post 1.0 feature.  Tracking in the 1.0 Milestone for now ._

Pull request description:
enhancement/Issue-1118: Single File Bundles for SSR and API routes
## Related Issue
Resolves #1118 

## Summary of Changes
- Refactor the API Rollup config generation to create a single entry point _for each API route_, rather than a single Rollup process _for all API routes_. 
- Refactor the SSR Rollup config generation to mimic the API generation, in that, the SSR array is now separate input config files.
- Refactor SSR pages entry point "facade" implementation in bundle lifecycle to use `URL` so we can bundle instead of the hacky string replace solution
- Documented breaking changes in adapter plugin docs

> Demo implementation in - https://github.com/ProjectEvergreen/greenwood-demo-adapter-vercel/pull/22

## TODO
1. [x] Documentation
1. [x] automatic SSR chunks / bundle mapping (nice to have)
    - actually, this is just the same SSR pages issue with us now using `URL`, otherwise it would just be inlined, fwiw
    - just did this for API routes for now, will circle back on SSR pages as part of https://github.com/ProjectEvergreen/greenwood/issues/1163
1. [x] fix windows test cases
1. [x] WCC Upstreams and delete _patches/_ + `postinstall`
    - Some sort of bug with clobbered custom element definitions -  https://github.com/ProjectEvergreen/wcc/issues/144
    - Seems like I may have introduced an infinite loop bug? 😮‍💨  - https://github.com/ProjectEvergreen/wcc/issues/147
1. [x] Should create a discussion to see if there's a better alternative to the URL mechanism, since its good for bundling, but forces a chunk since it really can't be inlined... which isn't the worst thing in the world I suppose 🤷‍♂️ 
    - should capture this a comment in adapter plugins that have to find / filter for route chunk files to copy over to adapter output directories
    - https://github.com/ProjectEvergreen/greenwood/discussions/1204

Patch:
diff --git a/.gitignore b/.gitignore
index a56ab6ff..eaa6b83a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,4 +11,4 @@ packages/**/test/**/netlify
 packages/**/test/**/.netlify
 packages/**/test/**/.vercel
 public/
-adapter-outlet/
\ No newline at end of file
+adapter-output/
\ No newline at end of file
diff --git a/packages/cli/src/config/rollup.config.js b/packages/cli/src/config/rollup.config.js
index 53fa8984..b0ea175c 100644
--- a/packages/cli/src/config/rollup.config.js
+++ b/packages/cli/src/config/rollup.config.js
@@ -1,3 +1,4 @@
+/* eslint-disable complexity */
 import fs from 'fs';
 import path from 'path';
 import { checkResourceExists, normalizePathnameForWindows } from '../lib/resource-utils.js';
@@ -121,7 +122,8 @@ function greenwoodSyncPageResourceBundlesPlugin(compilation) {
 }
 
 function getMetaImportPath(node) {
-  return node.arguments[0].value.split('/').join(path.sep);
+  return node.arguments[0].value.split('/').join(path.sep)
+    .replace(/\\/g, '/'); // handle Windows style paths
 }
 
 function isNewUrlImportMetaUrl(node) {
@@ -157,6 +159,8 @@ function greenwoodImportMetaUrl(compilation) {
       }).map((plugin) => {
         return plugin.provider(compilation);
       });
+      const idAssetName = path.basename(id);
+      const normalizedId = id.replace(/\\\\/g, '/').replace(/\\/g, '/'); // windows shenanigans...
       const idUrl = new URL(`file://${cleanRollupId(id)}`);
       const { pathname } = idUrl;
       const extension = pathname.split('.').pop();
@@ -235,43 +239,71 @@ function greenwoodImportMetaUrl(compilation) {
           ? { type, id: normalizePathnameForWindows(url), name }
           : { type, name: assetName, source: assetContents };
         const ref = this.emitFile(emitConfig);
-        // handle Windows style paths
-        const normalizedRelativeAssetPath = relativeAssetPath.replace(/\\/g, '/');
         const importRef = `import.meta.ROLLUP_FILE_URL_${ref}`;
 
+        // loop through all URL bundle chunks from APIs and SSR pages
+        // and map to their parent file, to pick back up in generateBundle when full hashes are known
+        if (`${compilation.context.apisDir.pathname}${idAssetName}`.indexOf(normalizedId) >= 0) {
+          for (const entry of compilation.manifest.apis.keys()) {
+            const apiRoute = compilation.manifest.apis.get(entry);
+
+            if (normalizedId.endsWith(apiRoute.path)) {
+              const assets = apiRoute.assets || [];
+
+              assets.push(assetUrl.url.href);
+
+              compilation.manifest.apis.set(entry, {
+                ...apiRoute,
+                assets
+              });
+            }
+          }
+        } else {
+          // TODO figure out how to handle URL chunk from SSR pages
+          // https://github.com/ProjectEvergreen/greenwood/issues/1163
+        }
+
         modifiedCode = code
-          .replace(`'${normalizedRelativeAssetPath}'`, importRef)
-          .replace(`"${normalizedRelativeAssetPath}"`, importRef);
+          .replace(`'${relativeAssetPath}'`, importRef)
+          .replace(`"${relativeAssetPath}"`, importRef);
       }
 
       return {
         code: modifiedCode ? modifiedCode : code,
         map: null
       };
-    }
-  };
-}
+    },
 
-// TODO could we use this instead?
-// https://github.com/rollup/rollup/blob/v2.79.1/docs/05-plugin-development.md#resolveimportmeta
-// https://github.com/ProjectEvergreen/greenwood/issues/1087
-function greenwoodPatchSsrPagesEntryPointRuntimeImport() {
-  return {
-    name: 'greenwood-patch-ssr-pages-entry-point-runtime-import',
-    generateBundle(options, bundle) {
-      Object.keys(bundle).forEach((key) => {
-        if (key.startsWith('__')) {
-          // ___GWD_ENTRY_FILE_URL=${filename}___
-          const needle = bundle[key].code.match(/___GWD_ENTRY_FILE_URL=(.*.)___/);
-          if (needle) {
-            const entryPathMatch = needle[1];
-
-            bundle[key].code = bundle[key].code.replace(/'___GWD_ENTRY_FILE_URL=(.*.)___'/, `new URL('./_${entryPathMatch}', import.meta.url)`);
-          } else {
-            console.warn(`Could not find entry path match for bundle => ${key}`);
+    generateBundle(options, bundles) {
+      for (const bundle in bundles) {
+        const bundleExtension = bundle.split('.').pop();
+        const apiKey = `/api/${bundle.replace(`.${bundleExtension}`, '')}`;
+
+        if (compilation.manifest.apis.has(apiKey)) {
+          const apiManifestDetails = compilation.manifest.apis.get(apiKey);
+
+          for (const reference of bundles[bundle].referencedFiles) {
+            if (bundles[reference]) {
+              const assets = apiManifestDetails.assets;
+              let assetIdx;
+
+              assets.forEach((asset, idx) => {
+                // more windows shenanigans...)
+                if (asset.indexOf(bundles[reference]?.facadeModuleId?.replace(/\\/g, '/'))) {
+                  assetIdx = idx;
+                }
+              });
+
+              assets[assetIdx] = new URL(`./api/${reference}`, compilation.context.outputDir).href;
+
+              compilation.manifest.apis.set(apiKey, {
+                ...apiManifestDetails,
+                assets
+              });
+            }
           }
         }
-      });
+      }
     }
   };
 }
@@ -336,49 +368,40 @@ const getRollupConfigForScriptResources = async (compilation) => {
 
 const getRollupConfigForApis = async (compilation) => {
   const { outputDir, userWorkspace } = compilation.context;
-  const input = [...compilation.manifest.apis.values()]
-    .map(api => normalizePathnameForWindows(new URL(`.${api.path}`, userWorkspace)));
 
-  // why is this needed?
-  await fs.promises.mkdir(new URL('./api/assets/', outputDir), {
-    recursive: true
-  });
-
-  // TODO should routes and APIs have chunks?
-  // https://github.com/ProjectEvergreen/greenwood/issues/1118
-  return [{
-    input,
-    output: {
-      dir: `${normalizePathnameForWindows(outputDir)}/api`,
-      entryFileNames: '[name].js',
-      chunkFileNames: '[name].[hash].js'
-    },
-    plugins: [
-      greenwoodResourceLoader(compilation),
-      // support node export conditions for API routes
-      // https://github.com/ProjectEvergreen/greenwood/issues/1118
-      // https://github.com/rollup/plugins/issues/362#issuecomment-873448461
-      nodeResolve({
-        exportConditions: ['node'],
-        preferBuiltins: true
-      }),
-      commonjs(),
-      greenwoodImportMetaUrl(compilation)
-    ]
-  }];
+  return [...compilation.manifest.apis.values()]
+    .map(api => normalizePathnameForWindows(new URL(`.${api.path}`, userWorkspace)))
+    .map(filepath => ({
+      input: filepath,
+      output: {
+        dir: `${normalizePathnameForWindows(outputDir)}/api`,
+        entryFileNames: '[name].js',
+        chunkFileNames: '[name].[hash].js'
+      },
+      plugins: [
+        greenwoodResourceLoader(compilation),
+        // support node export conditions for SSR pages
+        // https://github.com/ProjectEvergreen/greenwood/issues/1118
+        // https://github.com/rollup/plugins/issues/362#issuecomment-873448461
+        nodeResolve({
+          exportConditions: ['node'],
+          preferBuiltins: true
+        }),
+        commonjs(),
+        greenwoodImportMetaUrl(compilation)
+      ]
+    }));
 };
 
 const getRollupConfigForSsr = async (compilation, input) => {
   const { outputDir } = compilation.context;
 
-  // TODO should routes and APIs have chunks?
-  // https://github.com/ProjectEvergreen/greenwood/issues/1118
-  return [{
-    input,
+  return input.map(filepath => ({
+    input: filepath,
     output: {
       dir: normalizePathnameForWindows(outputDir),
-      entryFileNames: '_[name].js',
-      chunkFileNames: '[name].[hash].js'
+      entryFileNames: '[name].route.js',
+      chunkFileNames: '[name].route.chunk.[hash].js'
     },
     plugins: [
       greenwoodResourceLoader(compilation),
@@ -390,8 +413,7 @@ const getRollupConfigForSsr = async (compilation, input) => {
         preferBuiltins: true
       }),
       commonjs(),
-      greenwoodImportMetaUrl(compilation),
-      greenwoodPatchSsrPagesEntryPointRuntimeImport() // TODO a little hacky but works for now
+      greenwoodImportMetaUrl(compilation)
     ],
     onwarn: (errorObj) => {
       const { code, message } = errorObj;
@@ -411,7 +433,7 @@ const getRollupConfigForSsr = async (compilation, input) => {
 
       }
     }
-  }];
+  }));
 };
 
 export {
diff --git a/packages/cli/src/lifecycles/bundle.js b/packages/cli/src/lifecycles/bundle.js
index 22f96cca..e3a037a7 100644
--- a/packages/cli/src/lifecycles/bundle.js
+++ b/packages/cli/src/lifecycles/bundle.js
@@ -185,11 +185,15 @@ async function bundleStyleResources(compilation, resourcePlugins) {
 
 async function bundleApiRoutes(compilation) {
   // https://rollupjs.org/guide/en/#differences-to-the-javascript-api
-  const [rollupConfig] = await getRollupConfigForApis(compilation);
+  const apiConfigs = await getRollupConfigForApis(compilation);
 
-  if (rollupConfig.input.length !== 0) {
-    const bundle = await rollup(rollupConfig);
-    await bundle.write(rollupConfig.output);
+  if (apiConfigs.length > 0 && apiConfigs[0].input.length !== 0) {
+    for (const configIndex in apiConfigs) {
+      const rollupConfig = apiConfigs[configIndex];
+      const bundle = await rollup(rollupConfig);
+      await bundle.write(rollupConfig.output);
+
+    }
   }
 }
 
@@ -213,12 +217,14 @@ async function bundleSsrPages(compilation) {
     for (const page of compilation.graph) {
       if (page.isSSR && !page.prerender) {
         const { filename, imports, route, template, title } = page;
-        const entryFileUrl = new URL(`./_${filename}`, scratchDir);
+        const entryFileUrl = new URL(`./${filename}`, scratchDir);
         const moduleUrl = new URL(`./${filename}`, pagesDir);
         const request = new Request(moduleUrl); // TODO not really sure how to best no-op this?
         // TODO getTemplate has to be static (for now?)
         // https://github.com/ProjectEvergreen/greenwood/issues/955
         const data = await executeRouteModule({ moduleUrl, compilation, page, prerender: false, htmlContents: null, scripts: [], request });
+        const pagesPathDiff = compilation.context.pagesDir.pathname.replace(compilation.context.projectDirectory.pathname, '');
+
         let staticHtml = '';
 
         staticHtml = data.template ? data.template : await getPageTemplate(staticHtml, compilation.context, template, []);
@@ -229,13 +235,15 @@ async function bundleSsrPages(compilation) {
         staticHtml = staticHtml.replace(/[`\\$]/g, '\\$&'); // https://stackoverflow.com/a/75688937/417806
 
         // better way to write out this inline code?
+        // using a URL here produces a bundled chunk, but at leasts its bundled
         await fs.writeFile(entryFileUrl, `
           import { executeRouteModule } from '${normalizePathnameForWindows(executeModuleUrl)}';
 
+          const moduleUrl = new URL('../${pagesPathDiff}${filename}', import.meta.url);
+
           export async function handler(request) {
             const compilation = JSON.parse('${JSON.stringify(compilation)}');
             const page = JSON.parse('${JSON.stringify(page)}');
-            const moduleUrl = '___GWD_ENTRY_FILE_URL=${filename}___';
             const data = await executeRouteModule({ moduleUrl, compilation, page, request });
             let staticHtml = \`${staticHtml}\`;
 
@@ -251,16 +259,18 @@ async function bundleSsrPages(compilation) {
           }
         `);
 
-        input.push(normalizePathnameForWindows(moduleUrl));
         input.push(normalizePathnameForWindows(entryFileUrl));
       }
     }
 
-    const [rollupConfig] = await getRollupConfigForSsr(compilation, input);
+    const ssrConfigs = await getRollupConfigForSsr(compilation, input);
 
-    if (rollupConfig.input.length > 0) {
-      const bundle = await rollup(rollupConfig);
-      await bundle.write(rollupConfig.output);
+    if (ssrConfigs.length > 0 && ssrConfigs[0].input !== '') {
+      for (const configIndex in ssrConfigs) {
+        const rollupConfig = ssrConfigs[configIndex];
+        const bundle = await rollup(rollupConfig);
+        await bundle.write(rollupConfig.output);
+      }
     }
   }
 }
diff --git a/packages/cli/src/lifecycles/serve.js b/packages/cli/src/lifecycles/serve.js
index 903af038..6ae90187 100644
--- a/packages/cli/src/lifecycles/serve.js
+++ b/packages/cli/src/lifecycles/serve.js
@@ -296,6 +296,7 @@ async function getHybridServer(compilation) {
       const request = transformKoaRequestIntoStandardRequest(url, ctx.request);
 
       if (!config.prerender && matchingRoute.isSSR && !matchingRoute.prerender) {
+        const entryPointUrl = new URL(`./${matchingRoute.id}.route.js`, outputDir);
         let html;
 
         if (matchingRoute.isolation || isolationMode) {
@@ -317,13 +318,13 @@ async function getHybridServer(compilation) {
             });
 
             worker.postMessage({
-              routeModuleUrl: new URL(`./__${matchingRoute.filename}`, outputDir).href,
+              routeModuleUrl: entryPointUrl.href,
               request,
               compilation: JSON.stringify(compilation)
             });
           });
         } else {
-          const { handler } = await import(new URL(`./__${matchingRoute.filename}`, outputDir));
+          const { handler } = await import(entryPointUrl);
           const response = await handler(request, compilation);
 
           html = Readable.from(response.body);
@@ -334,6 +335,7 @@ async function getHybridServer(compilation) {
         ctx.status = 200;
       } else if (isApiRoute) {
         const apiRoute = manifest.apis.get(url.pathname);
+        const entryPointUrl = new URL(`.${apiRoute.path}`, outputDir);
         let body, status, headers, statusText;
 
         if (apiRoute.isolation || isolationMode) {
@@ -360,12 +362,12 @@ async function getHybridServer(compilation) {
             });
 
             worker.postMessage({
-              href: new URL(`.${apiRoute.path}`, outputDir).href,
+              href: entryPointUrl.href,
               request: req
             });
           });
         } else {
-          const { handler } = await import(new URL(`.${apiRoute.path}`, outputDir));
+          const { handler } = await import(entryPointUrl);
           const response = await handler(request);
 
           body = response.body;
diff --git a/packages/plugin-adapter-netlify/src/index.js b/packages/plugin-adapter-netlify/src/index.js
index e028bcf1..15ffbf0a 100644
--- a/packages/plugin-adapter-netlify/src/index.js
+++ b/packages/plugin-adapter-netlify/src/index.js
@@ -8,7 +8,7 @@ function generateOutputFormat(id) {
   const handlerAlias = '$handler';
 
   return `
-    import { handler as ${handlerAlias} } from './__${id}.js';
+    import { handler as ${handlerAlias} } from './${id}.js';
 
     export async function handler (event, context = {}) {
       const { rawUrl, body, headers = {}, httpMethod } = event;
@@ -52,10 +52,9 @@ function generateOutputFormat(id) {
 }
 
 async function setupOutputDirectory(id, outputRoot, outputType) {
-  const outputFormat = generateOutputFormat(id, outputType);
-  const filename = outputType === 'api'
-    ? `api-${id}`
-    : `${id}`;
+  const entryPoint = outputType === 'api' ? id : `${id}.route`;
+  const filename = outputType === 'api' ? `api-${id}` : id;
+  const outputFormat = generateOutputFormat(entryPoint, outputType);
 
   await fs.mkdir(outputRoot, { recursive: true });
   await fs.writeFile(new URL(`./${filename}.js`, outputRoot), outputFormat);
@@ -64,7 +63,7 @@ async function setupOutputDirectory(id, outputRoot, outputType) {
   }));
 }
 
-// TODO manifest options, like node version?
+// TODO do we need more manifest options, like node version?
 // https://github.com/netlify/zip-it-and-ship-it#options
 async function createOutputZip(id, outputType, outputRootUrl, projectDirectory) {
   const filename = outputType === 'api'
@@ -92,51 +91,29 @@ async function netlifyAdapter(compilation) {
     await fs.mkdir(adapterOutputUrl, { recursive: true });
   }
 
-  const files = await fs.readdir(outputDir);
-  const isExecuteRouteModule = files.find(file => file.startsWith('execute-route-module'));
-
   await fs.mkdir(new URL('./netlify/functions/', projectDirectory), { recursive: true });
 
   for (const page of ssrPages) {
     const { id } = page;
     const outputType = 'page';
     const outputRoot = new URL(`./${id}/`, adapterOutputUrl);
+    const files = (await fs.readdir(outputDir))
+      .filter(file => file.startsWith(`${id}.route.chunk.`) && file.endsWith('.js'));
 
     await setupOutputDirectory(id, outputRoot, outputType);
 
+    // handle user's actual route entry file
     await fs.cp(
-      new URL(`./_${id}.js`, outputDir),
-      new URL(`./_${id}.js`, outputRoot),
-      { recursive: true }
-    );
-    await fs.cp(
-      new URL(`./__${id}.js`, outputDir),
-      new URL(`./__${id}.js`, outputRoot),
+      new URL(`./${id}.route.js`, outputDir),
+      new URL(`./${id}.route.js`, outputRoot),
       { recursive: true }
     );
 
-    // TODO quick hack to make serverless pages are fully self-contained
-    // for example, execute-route-module.js will only get code split if there are more than one SSR pages
-    // https://github.com/ProjectEvergreen/greenwood/issues/1118
-    if (isExecuteRouteModule) {
+    // and the URL chunk for renderer plugin and executeRouteModule
+    for (const file of files) {
       await fs.cp(
-        new URL(`./${isExecuteRouteModule}`, outputDir),
-        new URL(`./${isExecuteRouteModule}`, outputRoot)
-      );
-    }
-
-    // TODO how to track SSR resources that get dumped out in the public directory?
-    // https://github.com/ProjectEvergreen/greenwood/issues/1118
-    const ssrPageAssets = (await fs.readdir(outputDir))
-      .filter(file => !path.basename(file).startsWith('_')
-        && !path.basename(file).startsWith('execute')
-        && path.basename(file).endsWith('.js')
-      );
-
-    for (const asset of ssrPageAssets) {
-      await fs.cp(
-        new URL(`./${asset}`, outputDir),
-        new URL(`./${asset}`, outputRoot),
+        new URL(`./${file}`, outputDir),
+        new URL(`./${file}`, outputRoot),
         { recursive: true }
       );
     }
@@ -151,36 +128,25 @@ async function netlifyAdapter(compilation) {
     redirects += `${basePath}/api/* /.netlify/functions/api-:splat 200`;
   }
 
-  for (const [key] of apiRoutes) {
+  for (const [key, value] of apiRoutes.entries()) {
     const outputType = 'api';
     const id = key.replace(`${basePath}/api/`, '');
     const outputRoot = new URL(`./api/${id}/`, adapterOutputUrl);
-
+    const { assets = [] } = value;
     await setupOutputDirectory(id, outputRoot, outputType);
 
-    // TODO ideally all functions would be self contained
-    // https://github.com/ProjectEvergreen/greenwood/issues/1118
     await fs.cp(
       new URL(`./api/${id}.js`, outputDir),
-      new URL(`./__${id}.js`, outputRoot),
+      new URL(`./${id}.js`, outputRoot),
       { recursive: true }
     );
 
-    if (await checkResourceExists(new URL('./api/assets/', outputDir))) {
-      await fs.cp(
-        new URL('./api/assets/', outputDir),
-        new URL('./assets/', outputRoot),
-        { recursive: true }
-      );
-    }
-
-    const ssrApiAssets = (await fs.readdir(new URL('./api/', outputDir)))
-      .filter(file => new RegExp(/^[\w][\w-]*\.[a-zA-Z0-9]{4,20}\.[\w]{2,4}$/).test(path.basename(file)));
+    for (const asset of assets) {
+      const name = path.basename(asset);
 
-    for (const asset of ssrApiAssets) {
       await fs.cp(
-        new URL(`./${asset}`, new URL('./api/', outputDir)),
-        new URL(`./${asset}`, outputRoot),
+        new URL(asset),
+        new URL(`./${name}`, outputRoot),
         { recursive: true }
       );
     }
diff --git a/packages/plugin-adapter-vercel/src/index.js b/packages/plugin-adapter-vercel/src/index.js
index 77602ae8..f37e8154 100644
--- a/packages/plugin-adapter-vercel/src/index.js
+++ b/packages/plugin-adapter-vercel/src/index.js
@@ -6,7 +6,7 @@ import { checkResourceExists } from '@greenwood/cli/src/lib/resource-utils.js';
 function generateOutputFormat(id, type) {
   const handlerAlias = '$handler';
   const path = type === 'page'
-    ? `__${id}`
+    ? `${id}.route`
     : id;
 
   return `
@@ -82,85 +82,52 @@ async function vercelAdapter(compilation) {
     'version': 3
   }));
 
-  const files = await fs.readdir(outputDir);
-  const isExecuteRouteModule = files.find(file => file.startsWith('execute-route-module'));
-
   for (const page of ssrPages) {
     const outputType = 'page';
     const { id } = page;
     const outputRoot = new URL(`./${basePath}/${id}.func/`, adapterOutputUrl);
+    const files = (await fs.readdir(outputDir))
+      .filter(file => file.startsWith(`${id}.route.chunk.`) && file.endsWith('.js'));
 
     await setupFunctionBuildFolder(id, outputType, outputRoot);
 
+    // handle user's actual route entry file
     await fs.cp(
-      new URL(`./_${id}.js`, outputDir),
-      new URL(`./_${id}.js`, outputRoot),
-      { recursive: true }
-    );
-
-    await fs.cp(
-      new URL(`./__${id}.js`, outputDir),
-      new URL(`./__${id}.js`, outputRoot),
+      new URL(`./${id}.route.js`, outputDir),
+      new URL(`./${id}.route.js`, outputRoot),
       { recursive: true }
     );
 
-    // TODO quick hack to make serverless pages are fully self-contained
-    // for example, execute-route-module.js will only get code split if there are more than one SSR pages
-    // https://github.com/ProjectEvergreen/greenwood/issues/1118
-    if (isExecuteRouteModule) {
-      await fs.cp(
-        new URL(`./${isExecuteRouteModule}`, outputDir),
-        new URL(`./${isExecuteRouteModule}`, outputRoot)
-      );
-    }
-
-    // TODO how to track SSR resources that get dumped out in the public directory?
-    // https://github.com/ProjectEvergreen/greenwood/issues/1118
-    const ssrPageAssets = (await fs.readdir(outputDir))
-      .filter(file => !path.basename(file).startsWith('_')
-        && !path.basename(file).startsWith('execute')
-        && path.basename(file).endsWith('.js')
-      );
-
-    for (const asset of ssrPageAssets) {
+    // and the URL chunk for renderer plugin and executeRouteModule
+    for (const file of files) {
       await fs.cp(
-        new URL(`./${asset}`, outputDir),
-        new URL(`./${asset}`, outputRoot),
+        new URL(`./${file}`, outputDir),
+        new URL(`./${file}`, outputRoot),
         { recursive: true }
       );
     }
   }
 
-  for (const [key] of apiRoutes) {
+  for (const [key, value] of apiRoutes.entries()) {
     const outputType = 'api';
     const id = key.replace(`${basePath}/api/`, '');
     const outputRoot = new URL(`./${basePath}/api/${id}.func/`, adapterOutputUrl);
+    const { assets = [] } = value;
 
     await setupFunctionBuildFolder(id, outputType, outputRoot);
 
-    // TODO ideally all functions would be self contained
-    // https://github.com/ProjectEvergreen/greenwood/issues/1118
     await fs.cp(
       new URL(`./api/${id}.js`, outputDir),
       new URL(`./${id}.js`, outputRoot),
       { recursive: true }
     );
 
-    if (await checkResourceExists(new URL('./api/assets/', outputDir))) {
-      await fs.cp(
-        new URL('./api/assets/', outputDir),
-        new URL('./assets/', outputRoot),
-        { recursive: true }
-      );
-    }
-
-    const ssrApiAssets = (await fs.readdir(new URL('./api/', outputDir)))
-      .filter(file => new RegExp(/^[\w][\w-]*\.[a-zA-Z0-9]{4,20}\.[\w]{2,4}$/).test(path.basename(file)));
+    for (const asset of assets) {
+      const name = path.basename(asset);
 
-    for (const asset of ssrApiAssets) {
       await fs.cp(
-        new URL(`./${asset}`, new URL('./api/', outputDir)),
-        new URL(`./${asset}`, outputRoot),
+        new URL(asset),
+        new URL(`./${name}`, outputRoot),
         { recursive: true }
       );
     }
diff --git a/www/pages/plugins/adapter.md b/www/pages/plugins/adapter.md
index 76bab683..21180b43 100644
--- a/www/pages/plugins/adapter.md
+++ b/www/pages/plugins/adapter.md
@@ -46,7 +46,7 @@ import { checkResourceExists } from '../../../../cli/src/lib/resource-utils.js';
 
 function generateOutputFormat(id, type) {
   const path = type === 'page'
-    ? `__${id}`
+    ? `${id}.entry`
     : `api/${id}`;
 
   return `
@@ -72,23 +72,58 @@ async function genericAdapter(compilation) {
     await fs.mkdir(adapterOutputUrl);
   }
 
+  // SSR pages
   for (const page of ssrPages) {
     const { id } = page;
     const outputFormat = generateOutputFormat(id, 'page');
+    const files = (await fs.readdir(outputDir))
+      .filter(file => file.startsWith(`${id}.route.chunk.`) && file.endsWith('.js'));
+
+    // generate a facade for all SSR pages for your particular hosting provider
+    await fs.writeFile(new URL('./index.js', adapterOutputUrl), outputFormat);
+
+    // handle user's actual route entry file, appended with .route by Greenwood
+    await fs.cp(
+      new URL(`./${id}.route.js`, outputDir),
+      new URL(`./${id}.route.js`, outputRoot),
+      { recursive: true }
+    );
+
+    // and the URL generated chunk for the route
+    for (const file of files) {
+      await fs.cp(
+        new URL(`./${file}`, outputDir),
+        new URL(`./${file}`, outputRoot),
+        { recursive: true }
+      );
+    }
 
-    // generate a shim for all SSR pages
-    await fs.writeFile(new URL(`./${id}.js`, adapterOutputUrl), outputFormat);
-
-    // copy all entry points
-    await fs.cp(new URL(`./_${id}.js`, outputDir), new URL(`./_${id}.js`, adapterOutputUrl));
-    await fs.cp(new URL(`./__${id}.js`, outputDir), new URL(`./_${id}.js`, adapterOutputUrl));
-
-    // generate a manifest
-    await fs.writeFile(new URL('./metadata.json', adapterOutputUrl), JSON.stringify({
-      version: '1.0.0',
-      runtime: 'nodejs'
-      // ...
-    }));
+    // API routes
+    for (const [key, value] of apiRoutes.entries()) {
+      const outputType = 'api';
+      const id = key.replace(`${basePath}/api/`, '');
+      const outputRoot = new URL(`./${basePath}/api/${id}.func/`, adapterOutputUrl);
+      const { assets = [] } = value;
+
+      await setupFunctionBuildFolder(id, outputType, outputRoot);
+
+      await fs.cp(
+        new URL(`./api/${id}.js`, outputDir),
+        new URL(`./${id}.js`, outputRoot),
+        { recursive: true }
+      );
+
+      // copy any child assets, like URL bundles
+      for (const asset of assets) {
+        const name = path.basename(asset);
+
+        await fs.cp(
+          new URL(asset),
+          new URL(`./${name}`, outputRoot),
+          { recursive: true }
+        );
+      }
+    }
   }
 }
 


Test patch:
diff --git a/packages/cli/test/cases/build.plugins.adapter/generic-adapter.js b/packages/cli/test/cases/build.plugins.adapter/generic-adapter.js
index dbf07037..8fc6141b 100644
--- a/packages/cli/test/cases/build.plugins.adapter/generic-adapter.js
+++ b/packages/cli/test/cases/build.plugins.adapter/generic-adapter.js
@@ -3,7 +3,7 @@ import { checkResourceExists } from '../../../../cli/src/lib/resource-utils.js';
 
 function generateOutputFormat(id, type) {
   const path = type === 'page'
-    ? `__${id}`
+    ? `${id}.route`
     : `api/${id}`;
 
   return `
diff --git a/packages/cli/test/cases/serve.default.ssr/serve.default.ssr.spec.js b/packages/cli/test/cases/serve.default.ssr/serve.default.ssr.spec.js
index d19428e4..3a2a20b7 100644
--- a/packages/cli/test/cases/serve.default.ssr/serve.default.ssr.spec.js
+++ b/packages/cli/test/cases/serve.default.ssr/serve.default.ssr.spec.js
@@ -90,9 +90,8 @@ describe('Serve Greenwood With: ', function() {
         expect(headings[0].textContent).to.equal('Hello from the server rendered home page!');
       });
 
-      it('should have the expected bundled SSR output for the page', async function() {
-        const scriptFiles = (await glob.promise(path.join(this.context.publicDir, '*.js')))
-          .filter(file => file.indexOf('index.js') >= 0);
+      it('should have the expected bundled SSR output for the page entry point and chunk file', async function() {
+        const scriptFiles = await glob.promise(path.join(this.context.publicDir, 'index*.js'));
 
         expect(scriptFiles.length).to.equal(2);
       });
@@ -226,9 +225,8 @@ describe('Serve Greenwood With: ', function() {
         expect(resources.find(resource => resource.endsWith('/header.js'))).to.not.be.undefined;
       });
 
-      it('should have the expected bundled SSR output for the page', async function() {
-        const scriptFiles = (await glob.promise(path.join(this.context.publicDir, '*.js')))
-          .filter(file => file.indexOf('artists.js') >= 0);
+      it('should have the expected bundled SSR output for the page entry point and chunk file', async function() {
+        const scriptFiles = await glob.promise(path.join(this.context.publicDir, 'artists*.js'));
 
         expect(scriptFiles.length).to.equal(2);
       });
@@ -264,9 +262,8 @@ describe('Serve Greenwood With: ', function() {
         expect(cards.length).to.be.greaterThan(0);
       });
 
-      it('should have the expected bundled SSR output for the page', async function() {
-        const scriptFiles = (await glob.promise(path.join(this.context.publicDir, '*.js')))
-          .filter(file => file.indexOf('users.js') >= 0);
+      it('should have the expected bundled SSR output for the page entry point and chunk file', async function() {
+        const scriptFiles = await glob.promise(path.join(this.context.publicDir, 'users*.js'));
 
         expect(scriptFiles.length).to.equal(2);
       });
diff --git a/packages/plugin-adapter-netlify/test/cases/build.default/build.default.spec.js b/packages/plugin-adapter-netlify/test/cases/build.default/build.default.spec.js
index e51a2059..612b27d6 100644
--- a/packages/plugin-adapter-netlify/test/cases/build.default/build.default.spec.js
+++ b/packages/plugin-adapter-netlify/test/cases/build.default/build.default.spec.js
@@ -125,6 +125,22 @@ describe('Build Greenwood With: ', function() {
         expect(headers.get('content-type')).to.be.equal('application/json');
         expect(JSON.parse(body).message).to.be.equal(`Hello ${param}!`);
       });
+
+      it('should not have a shared asset for the card component', async () => {
+        const name = path.basename(apiFunctions[0]).replace('.zip', '');
+
+        await extract(apiFunctions[0], {
+          dir: path.join(normalizePathnameForWindows(netlifyFunctionsOutputUrl), name)
+        });
+
+        const assets = await glob.promise(path.join(normalizePathnameForWindows(netlifyFunctionsOutputUrl), `/${name}/*`));
+        const exists = assets.find((asset) => {
+          const name = asset.split('/').pop();
+          return name.startsWith('card') && name.endsWith('.js');
+        });
+
+        expect(!!exists).to.equal(false);
+      });
     });
 
     describe('Fragments API Route adapter', function() {
@@ -158,6 +174,22 @@ describe('Build Greenwood With: ', function() {
         expect(cardTags.length).to.be.equal(2);
         expect(headers.get('content-type')).to.be.equal('text/html');
       });
+
+      it('should have a shared asset for the card component', async () => {
+        const name = path.basename(apiFunctions[0]).replace('.zip', '');
+
+        await extract(apiFunctions[0], {
+          dir: path.join(normalizePathnameForWindows(netlifyFunctionsOutputUrl), name)
+        });
+
+        const assets = await glob.promise(path.join(normalizePathnameForWindows(netlifyFunctionsOutputUrl), `/${name}/*`));
+        const exists = assets.find((asset) => {
+          const name = asset.split('/').pop();
+          return name.startsWith('card') && name.endsWith('.js');
+        });
+
+        expect(!!exists).to.equal(true);
+      });
     });
 
     describe('Submit JSON API Route adapter', function() {
diff --git a/packages/plugin-adapter-vercel/test/cases/build.default/build.default.spec.js b/packages/plugin-adapter-vercel/test/cases/build.default/build.default.spec.js
index 2c6f6423..e4ddf27a 100644
--- a/packages/plugin-adapter-vercel/test/cases/build.default/build.default.spec.js
+++ b/packages/plugin-adapter-vercel/test/cases/build.default/build.default.spec.js
@@ -149,6 +149,16 @@ describe('Build Greenwood With: ', function() {
         expect(headers.get('content-type')).to.be.equal('application/json');
         expect(JSON.parse(body).message).to.be.equal(`Hello ${param}!`);
       });
+
+      it('should not have a shared asset for the card component', async () => {
+        const assets = await glob.promise(path.join(normalizePathnameForWindows(vercelFunctionsOutputUrl), '/api/greeting.func/*'));
+        const exists = assets.find((asset) => {
+          const name = asset.split('/').pop();
+          return name.startsWith('card') && name.endsWith('.js');
+        });
+
+        expect(!!exists).to.equal(false);
+      });
     });
 
     describe('Fragments API Route adapter', function() {
@@ -183,6 +193,16 @@ describe('Build Greenwood With: ', function() {
         expect(cardTags.length).to.be.equal(2);
         expect(headers.get('content-type')).to.be.equal('text/html');
       });
+
+      it('should have a shared asset for the card component', async () => {
+        const assets = await glob.promise(path.join(normalizePathnameForWindows(vercelFunctionsOutputUrl), '/api/fragment.func/*'));
+        const exists = assets.find((asset) => {
+          const name = asset.split('/').pop();
+          return name.startsWith('card') && name.endsWith('.js');
+        });
+
+        expect(!!exists).to.equal(true);
+      });
     });
 
     describe('Submit JSON API Route adapter', function() {