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. ``` +### Isolation Mode + +If running Greenwood as a server in production with the `greenwood serve` command, it may be desirable to isolate the server rendering of SSR pages and API routes from the global runtime (e.g. NodeJS) process. This is a common assumption for many Web Component libraries that may aim to more faithfully honor the browser's native specification on the server. + +Examples include: +- Custom Elements Registry - Per the spec, a custom element can only be defined once using `customElements.define`. +- DOM Shims - These often assume a globally unique runtime, and so issues can arise when these DOM globals are repeatedly loaded and initialized into the global space + +> See these discussions for more information +> - https://github.com/ProjectEvergreen/greenwood/discussions/1117 +> - https://github.com/ProjectEvergreen/wcc/discussions/145 + +As servers have to support multiple clients (as opposed to a browser tab only serving one client at a time), Greenwood offers an isolation mode that can be used to run SSR pages and API routes in their own context per request. + +#### Example + +To configure an entire project for this, simply set the flag in your _greenwood.config.js_ +```js +export default { + isolation: true // default value is false +}; +``` + +Optionally, you can opt-in on a per SSR page / API route basis by exporting an `isolation` option. +```js +// src/pages/products.js + +export const isolation = true; +``` + ### Markdown You can install and provide custom **unifiedjs** [presets](https://github.com/unifiedjs/unified#preset) and [plugins](https://github.com/unifiedjs/unified#plugin) to further customize and process your markdown past what [Greenwood does by default](https://github.com/ProjectEvergreen/greenwood/blob/release/0.10.0/packages/cli/src/transforms/transform.md.js#L68). After running an `npm install` you can provide their package names to Greenwood. diff --git a/www/pages/docs/server-rendering.md b/www/pages/docs/server-rendering.md index ca94e015e..411cc06fe 100644 --- a/www/pages/docs/server-rendering.md +++ b/www/pages/docs/server-rendering.md @@ -225,6 +225,17 @@ export const prerender = true; > You can enable this for all pages using the [prerender configuration](/docs/configuration/#prerender) option. +### Isolation + +To execute an SSR page 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. + + ### Custom Imports > ⚠️ _This feature is experimental._