diff --git a/packages/cli/src/commands/serve.js b/packages/cli/src/commands/serve.js index 4ed04faa1..04e5bdc57 100644 --- a/packages/cli/src/commands/serve.js +++ b/packages/cli/src/commands/serve.js @@ -6,8 +6,9 @@ const runProdServer = async (compilation) => { try { const port = compilation.config.port; + const hasApisDir = compilation.context.apisDir; const hasDynamicRoutes = compilation.graph.filter(page => page.isSSR && ((page.data.hasOwnProperty('static') && !page.data.static) || !compilation.config.prerender)); - const server = hasDynamicRoutes.length > 0 ? getHybridServer : getStaticServer; + const server = hasDynamicRoutes.length > 0 || hasApisDir ? getHybridServer : getStaticServer; (await server(compilation)).listen(port, () => { console.info(`Started server at localhost:${port}`); diff --git a/packages/cli/src/lifecycles/context.js b/packages/cli/src/lifecycles/context.js index 18589faa9..d52a3ccee 100644 --- a/packages/cli/src/lifecycles/context.js +++ b/packages/cli/src/lifecycles/context.js @@ -11,6 +11,7 @@ const initContext = async({ config }) => { try { const projectDirectory = process.cwd(); const userWorkspace = path.join(config.workspace); + const apisDir = path.join(userWorkspace, 'api/'); const pagesDir = path.join(userWorkspace, `${config.pagesDirectory}/`); const userTemplatesDir = path.join(userWorkspace, `${config.templatesDirectory}/`); @@ -18,6 +19,7 @@ const initContext = async({ config }) => { dataDir, outputDir, userWorkspace, + apisDir, pagesDir, userTemplatesDir, scratchDir, diff --git a/packages/cli/src/lifecycles/serve.js b/packages/cli/src/lifecycles/serve.js index 9265ecc20..6f83544e9 100644 --- a/packages/cli/src/lifecycles/serve.js +++ b/packages/cli/src/lifecycles/serve.js @@ -253,14 +253,23 @@ async function getStaticServer(compilation, composable) { async function getHybridServer(compilation) { const app = await getStaticServer(compilation, true); + const apiResource = compilation.config.plugins.filter((plugin) => { + return plugin.isGreenwoodDefaultPlugin + && plugin.type === 'resource' + && plugin.name.indexOf('plugin-api-routes') === 0; + }).map((plugin) => { + return plugin.provider(compilation); + })[0]; app.use(async (ctx) => { const url = ctx.request.url.replace(/\?(.*)/, ''); // get rid of things like query string parameters + const isApiRoute = await apiResource.shouldServe(url); const matchingRoute = compilation.graph.filter((node) => { return node.route === url; })[0] || { data: {} }; if (matchingRoute.isSSR && !matchingRoute.data.static) { + // TODO would be nice to pull these plugins once instead of one every request const headers = { request: { 'accept': 'text/html', 'content-type': 'text/html' }, response: { 'content-type': 'text/html' } @@ -312,6 +321,13 @@ async function getHybridServer(compilation) { ctx.status = 200; ctx.set('content-type', 'text/html'); ctx.body = body; + } else if (isApiRoute) { + // TODO just use response + const { body, resp } = await apiResource.serve(ctx.request.url); + + ctx.status = 200; + ctx.set('content-type', resp.headers.get('content-type')); + ctx.body = body; } }); diff --git a/packages/cli/src/plugins/resource/plugin-api-routes.js b/packages/cli/src/plugins/resource/plugin-api-routes.js new file mode 100644 index 000000000..cd4f1c85c --- /dev/null +++ b/packages/cli/src/plugins/resource/plugin-api-routes.js @@ -0,0 +1,53 @@ +/* + * + * Manages routing to API routes. + * + */ +import fs from 'fs'; +import { ResourceInterface } from '../../lib/resource-interface.js'; + +class ApiRoutesResource extends ResourceInterface { + constructor(compilation, options) { + super(compilation, options); + } + + async shouldServe(url) { + // TODO Could this existance check be derived from the graph instead? + // https://github.com/ProjectEvergreen/greenwood/issues/946 + return url.startsWith('/api') && fs.existsSync(this.compilation.context.apisDir, url); + } + + async serve(url) { + // TODO we assume host here, but eventually we will be getting a Request + // https://github.com/ProjectEvergreen/greenwood/issues/948 + const host = `https://localhost:${this.compilation.config.port}`; + let href = new URL(`${this.getBareUrlPath(url).replace('/api/', '')}.js`, `file://${this.compilation.context.apisDir}`).href; + + // https://github.com/nodejs/modules/issues/307#issuecomment-1165387383 + if (process.env.__GWD_COMMAND__ === 'develop') { // eslint-disable-line no-underscore-dangle + href = `${href}?t=${Date.now()}`; + } + + const { handler } = await import(href); + // TODO we need to pass in headers here + // https://github.com/ProjectEvergreen/greenwood/issues/948 + const req = new Request(new URL(`${host}${url}`)); + const resp = await handler(req); + const contents = resp.headers.get('content-type').indexOf('application/json') >= 0 + ? await resp.json() + : await resp.text(); + + return { + body: contents, + resp + }; + } +} + +const greenwoodApiRoutesPlugin = { + type: 'resource', + name: 'plugin-api-routes', + provider: (compilation, options) => new ApiRoutesResource(compilation, options) +}; + +export { greenwoodApiRoutesPlugin }; \ No newline at end of file diff --git a/packages/cli/test/cases/develop.default/develop.default.spec.js b/packages/cli/test/cases/develop.default/develop.default.spec.js index 6e1f5fc01..5c1666f0b 100644 --- a/packages/cli/test/cases/develop.default/develop.default.spec.js +++ b/packages/cli/test/cases/develop.default/develop.default.spec.js @@ -17,6 +17,8 @@ * * User Workspace * src/ + * api/ + * greeting.js * assets/ * data.json * favicon.ico @@ -1204,6 +1206,33 @@ describe('Develop Greenwood With: ', function() { done(); }); }); + + describe('Develop command with API specific behaviors', function() { + const name = 'Greenwood'; + let response = {}; + let data = {}; + + before(async function() { + response = await fetch(`${hostname}:${port}/api/greeting?name=${name}`); + data = await response.json(); + }); + + it('should return a 200 status', function(done) { + expect(response.ok).to.equal(true); + expect(response.status).to.equal(200); + done(); + }); + + it('should return the correct content type', function(done) { + expect(response.headers.get('content-type')).to.equal('application/json; charset=utf-8'); + done(); + }); + + it('should return the correct response body', function(done) { + expect(data.message).to.equal(`Hello ${name}!!!`); + done(); + }); + }); }); after(function() { diff --git a/packages/cli/test/cases/develop.default/src/api/greeting.js b/packages/cli/test/cases/develop.default/src/api/greeting.js new file mode 100644 index 000000000..f046e6243 --- /dev/null +++ b/packages/cli/test/cases/develop.default/src/api/greeting.js @@ -0,0 +1,11 @@ +export async function handler(request) { + const params = new URLSearchParams(request.url.slice(request.url.indexOf('?'))); + const name = params.has('name') ? params.get('name') : 'World'; + const body = { message: `Hello ${name}!!!` }; + + return new Response(JSON.stringify(body), { + headers: { + 'Content-Type': 'application/json' + } + }); +} \ 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 new file mode 100644 index 000000000..1e4fde3fd --- /dev/null +++ b/packages/cli/test/cases/serve.default.api/serve.default.api.spec.js @@ -0,0 +1,137 @@ +/* + * Use Case + * Run Greenwood serve command with no config. + * + * User Result + * Should start the production server and render a bare bones Greenwood build. + * + * User Command + * greenwood serve + * + * User Config + * N / A + * + * User Workspace + * src/ + * api/ + * greeting.js + */ +import chai from 'chai'; +import path from 'path'; +import { getSetupFiles, getOutputTeardownFiles } from '../../../../../test/utils.js'; +import request from 'request'; +import { runSmokeTest } from '../../../../../test/smoke-test.js'; +import { Runner } from 'gallinago'; +import { fileURLToPath, URL } from 'url'; + +const expect = chai.expect; + +// TODO why does this test keep stalling out and not closing the command? +describe('Serve Greenwood With: ', function() { + const LABEL = 'API Routes'; + const cliPath = path.join(process.cwd(), 'packages/cli/src/index.js'); + const outputPath = fileURLToPath(new URL('.', import.meta.url)); + const hostname = 'http://127.0.0.1:8080'; + let runner; + + before(function() { + this.context = { + hostname + }; + runner = new Runner(); + }); + + describe(LABEL, function() { + + before(async function() { + await runner.setup(outputPath, getSetupFiles(outputPath)); + + return new Promise(async (resolve) => { + setTimeout(() => { + resolve(); + }, 10000); + + await runner.runCommand(cliPath, 'serve'); + }); + }); + + runSmokeTest(['serve'], LABEL); + + describe('Serve command with API specific behaviors for a JSON API', function() { + const name = 'Greenwood'; + let response = {}; + + before(async function() { + // TODO not sure why native `fetch` doesn't seem to work here, just hangs the test runner + return new Promise((resolve, reject) => { + request.get(`${hostname}/api/greeting?name=${name}`, (err, res, body) => { + if (err) { + reject(); + } + + response = res; + response.body = JSON.parse(body); + + resolve(); + }); + }); + }); + + it('should return a 200 status', function(done) { + expect(response.statusCode).to.equal(200); + done(); + }); + + it('should return the correct content type', function(done) { + expect(response.headers['content-type']).to.equal('application/json; charset=utf-8'); + done(); + }); + + it('should return the correct response body', function(done) { + expect(response.body.message).to.equal(`Hello ${name}!!!`); + done(); + }); + }); + + describe('Serve command with API specific behaviors for an HTML ("fragment") API', function() { + const name = 'Greenwood'; + let response = {}; + + before(async function() { + // TODO not sure why native `fetch` doesn't seem to work here, just hangs the test runner + return new Promise((resolve, reject) => { + request.get(`${hostname}/api/fragment?name=${name}`, (err, res, body) => { + if (err) { + reject(); + } + + response = res; + response.body = body; + + resolve(); + }); + }); + }); + + it('should return a 200 status', function(done) { + expect(response.statusCode).to.equal(200); + done(); + }); + + it('should return the correct content type', function(done) { + expect(response.headers['content-type']).to.equal('text/html'); + done(); + }); + + it('should return the correct response body', function(done) { + expect(response.body).to.contain(`

Hello ${name}!!!

`); + done(); + }); + }); + }); + + after(function() { + runner.stopCommand(); + runner.teardown(getOutputTeardownFiles(outputPath)); + }); +}); \ No newline at end of file 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 new file mode 100644 index 000000000..5dc52e456 --- /dev/null +++ b/packages/cli/test/cases/serve.default.api/src/api/fragment.js @@ -0,0 +1,18 @@ +import { renderFromHTML } from 'wc-compiler'; + +export async function handler(request) { + const headers = new Headers(); + const params = new URLSearchParams(request.url.slice(request.url.indexOf('?'))); + const name = params.has('name') ? params.get('name') : 'World'; + const { html } = await renderFromHTML(` + + `, [ + new URL('../components/card.js', import.meta.url) + ]); + + headers.append('Content-Type', 'text/html'); + + return new Response(html, { + headers + }); +} \ No newline at end of file diff --git a/packages/cli/test/cases/serve.default.api/src/api/greeting.js b/packages/cli/test/cases/serve.default.api/src/api/greeting.js new file mode 100644 index 000000000..f046e6243 --- /dev/null +++ b/packages/cli/test/cases/serve.default.api/src/api/greeting.js @@ -0,0 +1,11 @@ +export async function handler(request) { + const params = new URLSearchParams(request.url.slice(request.url.indexOf('?'))); + const name = params.has('name') ? params.get('name') : 'World'; + const body = { message: `Hello ${name}!!!` }; + + return new Response(JSON.stringify(body), { + headers: { + 'Content-Type': 'application/json' + } + }); +} \ No newline at end of file diff --git a/packages/cli/test/cases/serve.default.api/src/components/card.js b/packages/cli/test/cases/serve.default.api/src/components/card.js new file mode 100644 index 000000000..1e916cfff --- /dev/null +++ b/packages/cli/test/cases/serve.default.api/src/components/card.js @@ -0,0 +1,11 @@ +export default class Card extends HTMLElement { + connectedCallback() { + const name = this.getAttribute('name'); + + this.innerHTML = ` +

Hello ${name}!!!

+ `; + } +} + +customElements.define('x-card', Card); \ No newline at end of file diff --git a/www/pages/docs/api-routes.md b/www/pages/docs/api-routes.md new file mode 100644 index 000000000..0f0d66807 --- /dev/null +++ b/www/pages/docs/api-routes.md @@ -0,0 +1,78 @@ +--- +label: 'API-routes' +menu: side +title: 'API Routes' +index: 9 +linkheadings: 3 +--- + +## API Routes + +Greenwood has support for API routes, which are just functions that run on the server, and take in a [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request), and return a [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response). + +### Usage + +API routes follow a file based routing convention, just like [pages](/docs/layouts/#pages). So this structure +```shell +src/ + api/ + greeting.js +``` + +Will yield an endpoint available at `/api/greeting`. + +Here is an example of that API, which ready a query parameter of `name` and returns a JSON response. + +```js +export async function handler(request) { + const params = new URLSearchParams(request.url.slice(request.url.indexOf('?'))); + const name = params.has('name') ? params.get('name') : 'World'; + const body = { message: `Hello ${name}!!!` }; + + return new Response(JSON.stringify(body), { + headers: { + 'Content-Type': 'application/json' + } + }); +} +``` + +### WCC + +As [WCC](https://github.com/ProjectEvergreen/wcc) already comes with Greenwood, it can be used to generate HTML on the fly for over the wire transmission to the browser. This is great for generating HTML "fragments" on the server side using Web Components, thus being able to leverage your components on the client _and_ the server! As the HTML is added the DOM, if the custom element definition has been loaded client side too, these components will hydrate automatically and become instantly interactive. (think of appending more items to a virtualized list). 🚀 + +An example of rendering a "card" component in an API route might look like look this. +```js +// card.js +export default class Card extends HTMLElement { + connectedCallback() { + const name = this.getAttribute('name'); + + this.innerHTML = ` +

Hello ${name}!!!

+ `; + } +} + +customElements.define('x-card', Card); +``` + +```js +// API route +import { renderFromHTML } from 'wc-compiler'; + +export async function handler(request) { + const headers = new Headers(); + const params = new URLSearchParams(request.url.slice(request.url.indexOf('?'))); + const name = params.has('name') ? params.get('name') : 'World'; + const { html } = await renderFromHTML(` + + `, [ + new URL('../components/card.js', import.meta.url) + ]); + + headers.append('Content-Type', 'text/html'); + + return new Response(html, { headers }); +} +``` \ No newline at end of file diff --git a/www/pages/docs/data.md b/www/pages/docs/data.md index a4a856b0f..d7ca68db7 100644 --- a/www/pages/docs/data.md +++ b/www/pages/docs/data.md @@ -2,7 +2,7 @@ label: 'data-sources' menu: side title: 'Data Sources' -index: 10 +index: 11 linkheadings: 3 --- diff --git a/www/pages/docs/menus.md b/www/pages/docs/menus.md index f13b77c00..2358c23f8 100644 --- a/www/pages/docs/menus.md +++ b/www/pages/docs/menus.md @@ -2,7 +2,7 @@ label: 'menus' menu: side title: 'Menus' -index: 9 +index: 10 linkheadings: 3 ---