diff --git a/packages/cli/src/lib/execute-route-module.js b/packages/cli/src/lib/execute-route-module.js
index 483696fdc..1d3746ace 100644
--- a/packages/cli/src/lib/execute-route-module.js
+++ b/packages/cli/src/lib/execute-route-module.js
@@ -15,7 +15,7 @@ async function executeRouteModule({ moduleUrl, compilation, page = {}, prerender
data.html = html;
} else {
const module = await import(moduleUrl).then(module => module);
- const { prerender = false, getTemplate = null, getBody = null, getFrontmatter = null } = module;
+ const { prerender = false, getTemplate = null, getBody = null, getFrontmatter = null, isolation } = module;
if (module.default) {
const { html } = await renderToString(new URL(moduleUrl), false, request);
@@ -35,7 +35,10 @@ async function executeRouteModule({ moduleUrl, compilation, page = {}, prerender
data.frontmatter = await getFrontmatter(compilation, page);
}
+ // TODO cant we get these from just pulling from the file during the graph phase?
+ // https://github.com/ProjectEvergreen/greenwood/issues/991
data.prerender = prerender;
+ data.isolation = isolation;
}
return data;
diff --git a/packages/cli/src/lib/ssr-route-worker-isolation-mode.js b/packages/cli/src/lib/ssr-route-worker-isolation-mode.js
new file mode 100644
index 000000000..831fa8e71
--- /dev/null
+++ b/packages/cli/src/lib/ssr-route-worker-isolation-mode.js
@@ -0,0 +1,14 @@
+// https://github.com/nodejs/modules/issues/307#issuecomment-858729422
+import { parentPort } from 'worker_threads';
+
+async function executeModule({ routeModuleUrl, request, compilation }) {
+ const { handler } = await import(routeModuleUrl);
+ const response = await handler(request, compilation);
+ const html = await response.text();
+
+ parentPort.postMessage(html);
+}
+
+parentPort.on('message', async (task) => {
+ await executeModule(task);
+});
\ No newline at end of file
diff --git a/packages/cli/src/lifecycles/config.js b/packages/cli/src/lifecycles/config.js
index e9b9dd400..e15fcc998 100644
--- a/packages/cli/src/lifecycles/config.js
+++ b/packages/cli/src/lifecycles/config.js
@@ -50,6 +50,7 @@ const defaultConfig = {
plugins: greenwoodPlugins,
markdown: { plugins: [], settings: {} },
prerender: false,
+ isolation: false,
pagesDirectory: 'pages',
templatesDirectory: 'templates'
};
@@ -76,7 +77,7 @@ const readAndMergeConfig = async() => {
if (hasConfigFile) {
const userCfgFile = (await import(configUrl)).default;
- const { workspace, devServer, markdown, optimization, plugins, port, prerender, basePath, staticRouter, pagesDirectory, templatesDirectory, interpolateFrontmatter } = userCfgFile;
+ const { workspace, devServer, markdown, optimization, plugins, port, prerender, basePath, staticRouter, pagesDirectory, templatesDirectory, interpolateFrontmatter, isolation } = userCfgFile;
// workspace validation
if (workspace) {
@@ -223,6 +224,14 @@ const readAndMergeConfig = async() => {
customConfig.prerender = false;
}
+ if (isolation !== undefined) {
+ if (typeof isolation === 'boolean') {
+ customConfig.isolation = isolation;
+ } else {
+ reject(`Error: greenwood.config.js isolation must be a boolean; true or false. Passed value was typeof: ${typeof staticRouter}`);
+ }
+ }
+
if (staticRouter !== undefined) {
if (typeof staticRouter === 'boolean') {
customConfig.staticRouter = staticRouter;
diff --git a/packages/cli/src/lifecycles/graph.js b/packages/cli/src/lifecycles/graph.js
index ae82285e6..557d6c0f9 100644
--- a/packages/cli/src/lifecycles/graph.js
+++ b/packages/cli/src/lifecycles/graph.js
@@ -22,7 +22,8 @@ const generateGraph = async (compilation) => {
data: {},
imports: [],
resources: [],
- prerender: true
+ prerender: true,
+ isolation: false
}];
const walkDirectoryForPages = async function(directory, pages = []) {
@@ -49,6 +50,7 @@ const generateGraph = async (compilation) => {
let customData = {};
let filePath;
let prerender = true;
+ let isolation = false;
/*
* check if additional nested directories exist to correctly determine route (minus filename)
@@ -131,6 +133,7 @@ const generateGraph = async (compilation) => {
worker.on('message', async (result) => {
prerender = result.prerender;
+ isolation = result.isolation ?? isolation;
if (result.frontmatter) {
result.frontmatter.imports = result.frontmatter.imports || [];
@@ -201,6 +204,7 @@ const generateGraph = async (compilation) => {
* title: a default value that can be used for
* isSSR: if this is a server side route
* prerednder: if this should be statically exported
+ * isolation: if this should be run in isolated mode
*/
pages.push({
data: customData || {},
@@ -220,7 +224,8 @@ const generateGraph = async (compilation) => {
template,
title,
isSSR: !isStatic,
- prerender
+ prerender,
+ isolation
});
}
}
@@ -240,27 +245,34 @@ const generateGraph = async (compilation) => {
apis = await walkDirectoryForApis(filenameUrlAsDir, apis);
} else {
const extension = filenameUrl.pathname.split('.').pop();
- const relativeApiPath = filenameUrl.pathname.replace(userWorkspace.pathname, '/');
- const route = `${basePath}${relativeApiPath.replace(`.${extension}`, '')}`;
if (extension !== 'js') {
console.warn(`${filenameUrl} is not a JavaScript file, skipping...`);
- } else {
- /*
- * API Properties (per route)
- *----------------------
- * filename: base filename of the page
- * outputPath: the filename to write to when generating a build
- * path: path to the file relative to the workspace
- * route: URL route for a given page on outputFilePath
- */
- apis.set(route, {
- filename: filename,
- outputPath: `/api/${filename}`,
- path: relativeApiPath,
- route
- });
+ return;
}
+
+ const relativeApiPath = filenameUrl.pathname.replace(userWorkspace.pathname, '/');
+ const route = `${basePath}${relativeApiPath.replace(`.${extension}`, '')}`;
+ // TODO should this be run in isolation like SSR pages?
+ // https://github.com/ProjectEvergreen/greenwood/issues/991
+ const { isolation } = await import(filenameUrl).then(module => module);
+
+ /*
+ * API Properties (per route)
+ *----------------------
+ * filename: base filename of the page
+ * outputPath: the filename to write to when generating a build
+ * path: path to the file relative to the workspace
+ * route: URL route for a given page on outputFilePath
+ * isolation: if this should be run in isolated mode
+ */
+ apis.set(route, {
+ filename: filename,
+ outputPath: `/api/${filename}`,
+ path: relativeApiPath,
+ route,
+ isolation
+ });
}
}
diff --git a/packages/cli/src/lifecycles/serve.js b/packages/cli/src/lifecycles/serve.js
index 678750374..903af038c 100644
--- a/packages/cli/src/lifecycles/serve.js
+++ b/packages/cli/src/lifecycles/serve.js
@@ -2,9 +2,10 @@ import fs from 'fs/promises';
import { hashString } from '../lib/hashing-utils.js';
import Koa from 'koa';
import { koaBody } from 'koa-body';
-import { checkResourceExists, mergeResponse, transformKoaRequestIntoStandardRequest } from '../lib/resource-utils.js';
+import { checkResourceExists, mergeResponse, transformKoaRequestIntoStandardRequest, requestAsObject } from '../lib/resource-utils.js';
import { Readable } from 'stream';
import { ResourceInterface } from '../lib/resource-interface.js';
+import { Worker } from 'worker_threads';
async function getDevServer(compilation) {
const app = new Koa();
@@ -282,6 +283,7 @@ async function getStaticServer(compilation, composable) {
async function getHybridServer(compilation) {
const { graph, manifest, context, config } = compilation;
const { outputDir } = context;
+ const isolationMode = config.isolation;
const app = await getStaticServer(compilation, true);
app.use(koaBody());
@@ -294,17 +296,83 @@ async function getHybridServer(compilation) {
const request = transformKoaRequestIntoStandardRequest(url, ctx.request);
if (!config.prerender && matchingRoute.isSSR && !matchingRoute.prerender) {
- const { handler } = await import(new URL(`./__${matchingRoute.filename}`, outputDir));
- const response = await handler(request, compilation);
+ let html;
+
+ if (matchingRoute.isolation || isolationMode) {
+ await new Promise(async (resolve, reject) => {
+ const worker = new Worker(new URL('../lib/ssr-route-worker-isolation-mode.js', import.meta.url));
+ // TODO "faux" new Request here, a better way?
+ const request = await requestAsObject(new Request(url));
+
+ worker.on('message', async (result) => {
+ html = result;
+
+ resolve();
+ });
+ worker.on('error', reject);
+ worker.on('exit', (code) => {
+ if (code !== 0) {
+ reject(new Error(`Worker stopped with exit code ${code}`));
+ }
+ });
+
+ worker.postMessage({
+ routeModuleUrl: new URL(`./__${matchingRoute.filename}`, outputDir).href,
+ request,
+ compilation: JSON.stringify(compilation)
+ });
+ });
+ } else {
+ const { handler } = await import(new URL(`./__${matchingRoute.filename}`, outputDir));
+ const response = await handler(request, compilation);
- ctx.body = Readable.from(response.body);
+ html = Readable.from(response.body);
+ }
+
+ ctx.body = html;
ctx.set('Content-Type', 'text/html');
ctx.status = 200;
} else if (isApiRoute) {
const apiRoute = manifest.apis.get(url.pathname);
- const { handler } = await import(new URL(`.${apiRoute.path}`, outputDir));
- const response = await handler(request);
- const { body, status, headers, statusText } = response;
+ let body, status, headers, statusText;
+
+ if (apiRoute.isolation || isolationMode) {
+ await new Promise(async (resolve, reject) => {
+ const worker = new Worker(new URL('../lib/api-route-worker.js', import.meta.url));
+ // TODO "faux" new Request here, a better way?
+ const req = await requestAsObject(request);
+
+ worker.on('message', async (result) => {
+ const responseAsObject = result;
+
+ body = responseAsObject.body;
+ status = responseAsObject.status;
+ headers = new Headers(responseAsObject.headers);
+ statusText = responseAsObject.statusText;
+
+ resolve();
+ });
+ worker.on('error', reject);
+ worker.on('exit', (code) => {
+ if (code !== 0) {
+ reject(new Error(`Worker stopped with exit code ${code}`));
+ }
+ });
+
+ worker.postMessage({
+ href: new URL(`.${apiRoute.path}`, outputDir).href,
+ request: req
+ });
+ });
+ } else {
+ const { handler } = await import(new URL(`.${apiRoute.path}`, outputDir));
+ const response = await handler(request);
+
+ body = response.body;
+ status = response.status;
+ headers = response.headers;
+ statusText = response.statusText;
+ }
ctx.body = body ? Readable.from(body) : null;
ctx.status = status;
diff --git a/packages/cli/test/cases/build.config.error-isolation/build.config.error-isolation.spec.js b/packages/cli/test/cases/build.config.error-isolation/build.config.error-isolation.spec.js
new file mode 100644
index 000000000..b262cc677
--- /dev/null
+++ b/packages/cli/test/cases/build.config.error-isolation/build.config.error-isolation.spec.js
@@ -0,0 +1,49 @@
+/*
+ * Use Case
+ * Run Greenwood build command with a bad value for isolation mode in a custom config.
+ *
+ * User Result
+ * Should throw an error.
+ *
+ * User Command
+ * greenwood build
+ *
+ * User Config
+ * {
+ * isolation: {}
+ * }
+ *
+ * User Workspace
+ * Greenwood default
+ */
+import chai from 'chai';
+import path from 'path';
+import { Runner } from 'gallinago';
+import { fileURLToPath, URL } from 'url';
+
+const expect = chai.expect;
+
+describe('Build Greenwood With: ', function() {
+ const cliPath = path.join(process.cwd(), 'packages/cli/src/index.js');
+ const outputPath = fileURLToPath(new URL('.', import.meta.url));
+ let runner;
+
+ before(function() {
+ this.context = {
+ publicDir: path.join(outputPath, 'public')
+ };
+ runner = new Runner();
+ });
+
+ describe('Custom Configuration with a bad value for Isolation', function() {
+ it('should throw an error that isolation must be a boolean', function() {
+ try {
+ runner.setup(outputPath);
+ runner.runCommand(cliPath, 'build');
+ } catch (err) {
+ expect(err).to.contain('Error: greenwood.config.js isolation must be a boolean; true or false. Passed value was typeof: object');
+ }
+ });
+ });
+
+});
\ No newline at end of file
diff --git a/packages/cli/test/cases/build.config.error-isolation/greenwood.config.js b/packages/cli/test/cases/build.config.error-isolation/greenwood.config.js
new file mode 100644
index 000000000..211201604
--- /dev/null
+++ b/packages/cli/test/cases/build.config.error-isolation/greenwood.config.js
@@ -0,0 +1,3 @@
+export default {
+ isolation: {}
+};
\ No newline at end of file
diff --git a/packages/cli/test/cases/serve.default.api/serve.default.api.spec.js b/packages/cli/test/cases/serve.default.api/serve.default.api.spec.js
index 3baa2e383..979721912 100644
--- a/packages/cli/test/cases/serve.default.api/serve.default.api.spec.js
+++ b/packages/cli/test/cases/serve.default.api/serve.default.api.spec.js
@@ -14,7 +14,7 @@
* User Workspace
* src/
* api/
- * fragment.js
+ * fragment.js (isolation mode)
* greeting.js
* missing.js
* nothing.js
diff --git a/packages/cli/test/cases/serve.default.api/src/api/fragment.js b/packages/cli/test/cases/serve.default.api/src/api/fragment.js
index ab5a5722c..992d3dd91 100644
--- a/packages/cli/test/cases/serve.default.api/src/api/fragment.js
+++ b/packages/cli/test/cases/serve.default.api/src/api/fragment.js
@@ -1,5 +1,7 @@
import { renderFromHTML } from 'wc-compiler';
+export const isolation = true;
+
export async function handler(request) {
const params = new URLSearchParams(request.url.slice(request.url.indexOf('?')));
const name = params.has('name') ? params.get('name') : 'World';
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 7368eb07b..d19428e48 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
@@ -25,7 +25,7 @@
* artists.js
* index.js
* post.js
- * users.js
+ * users.js (isolation = true)
* templates/
* app.html
*/
diff --git a/packages/cli/test/cases/serve.default.ssr/src/pages/users.js b/packages/cli/test/cases/serve.default.ssr/src/pages/users.js
index f2eeafaf3..71a468f33 100644
--- a/packages/cli/test/cases/serve.default.ssr/src/pages/users.js
+++ b/packages/cli/test/cases/serve.default.ssr/src/pages/users.js
@@ -17,4 +17,6 @@ export default class UsersPage extends HTMLElement {
${html}
`;
}
-}
\ No newline at end of file
+}
+
+export const isolation = true;
\ No newline at end of file
diff --git a/packages/plugin-renderer-lit/README.md b/packages/plugin-renderer-lit/README.md
index 0bdae3e78..7fa2b6f2e 100644
--- a/packages/plugin-renderer-lit/README.md
+++ b/packages/plugin-renderer-lit/README.md
@@ -88,6 +88,8 @@ customElements.define('artists-page', ArtistsPage);
export const tagName = 'artists-page';
```
+> _By default, this plugin sets `isolation` mode to `true` for all SSR pages. See the [isolation configuration](https://www.greenwoodjs.io/docs/configuration/#isolation) docs for more information._
+
## Caveats
There are a few considerations to take into account when using a `LitElement` as your page component:
diff --git a/packages/plugin-renderer-lit/src/execute-route-module.js b/packages/plugin-renderer-lit/src/execute-route-module.js
index 5e73f7505..10a7d0149 100644
--- a/packages/plugin-renderer-lit/src/execute-route-module.js
+++ b/packages/plugin-renderer-lit/src/execute-route-module.js
@@ -38,7 +38,13 @@ async function executeRouteModule({ moduleUrl, compilation, page, prerender, htm
data.html = await getTemplateResultString(templateResult);
} else {
const module = await import(moduleUrl).then(module => module);
- const { getTemplate = null, getBody = null, getFrontmatter = null } = module;
+ const { getTemplate = null, getBody = null, getFrontmatter = null, isolation = true } = module;
+
+ // TODO cant we get these from just pulling from the file during the graph phase?
+ // https://github.com/ProjectEvergreen/greenwood/issues/991
+ if (isolation) {
+ data.isolation = true;
+ }
if (module.default && module.tagName) {
const { tagName } = module;
diff --git a/packages/plugin-renderer-lit/test/cases/serve.default/serve.default.spec.js b/packages/plugin-renderer-lit/test/cases/serve.default/serve.default.spec.js
index b36cf230d..2f776bc7c 100644
--- a/packages/plugin-renderer-lit/test/cases/serve.default/serve.default.spec.js
+++ b/packages/plugin-renderer-lit/test/cases/serve.default/serve.default.spec.js
@@ -19,7 +19,7 @@
* greeting.js
* pages/
* artists.js
- * users.js
+ * users.js (isolation = false)
* templates/
* app.html
*/
diff --git a/packages/plugin-renderer-lit/test/cases/serve.default/src/pages/users.js b/packages/plugin-renderer-lit/test/cases/serve.default/src/pages/users.js
index f6fe81ebf..71a9515e5 100644
--- a/packages/plugin-renderer-lit/test/cases/serve.default/src/pages/users.js
+++ b/packages/plugin-renderer-lit/test/cases/serve.default/src/pages/users.js
@@ -20,5 +20,6 @@ class UsersComponent extends LitElement {
customElements.define('app-users', UsersComponent);
+export const isolation = false;
export const tagName = 'app-users';
export default UsersComponent;
\ No newline at end of file
diff --git a/www/pages/docs/api-routes.md b/www/pages/docs/api-routes.md
index 4e248c192..b05f28d8e 100644
--- a/www/pages/docs/api-routes.md
+++ b/www/pages/docs/api-routes.md
@@ -75,4 +75,14 @@ export async function handler(request) {
return new Response(html, { headers });
}
-```
\ No newline at end of file
+```
+
+### Isolation
+
+To execute an API route in its own request context when running `greenwood serve`, you can export an `isolation` option from your page set to `true`.
+
+```js
+export const isolation = true;
+```
+
+> For more information and how you can enable this for all pages, please see the [isolation configuration](/docs/configuration/#isolation) docs.
\ No newline at end of file
diff --git a/www/pages/docs/configuration.md b/www/pages/docs/configuration.md
index 1575d5013..177cb1d47 100644
--- a/www/pages/docs/configuration.md
+++ b/www/pages/docs/configuration.md
@@ -31,7 +31,8 @@ export default {
plugins: [],
workspace: new URL('./src/', import.meta.url),
pagesDirectory: 'pages', // e.g. src/pages
- templatesDirectory: 'templates' // e.g. src/templates
+ templatesDirectory: 'templates', // e.g. src/templates
+ isolation: false
};
```
@@ -127,6 +128,36 @@ Lorum Ipsum.