Custom rendering
Overview
To set up custom rendering of pages or other content for an app, you can use a render route. This is a server-rendered route which can return HTML or other content.
This page covers advanced customization using a TS/JS serverEntry (or renderScript) file, which can return any content type and optionally customize headers or other properties of the response.
If you just need to use an HTML file for a page in the app, and it doesn't require additional modification, see the main routing docs.
Current limitations
As of writing, if your serverEntry is a TS/JS file, you must re-run cloudpack start to see changes. This is an issue we'll be attempting to address soon (tracking issue). Basically the problem is that once a file has been loaded with import in Node, there's not a straightforward way to invalidate that and force Node to reload the file instead of using the cached content.
Migration
We're currently working on implementing full SSR support (tracking issue) including TS transpilation. Unfortunately, this requires some breaking changes from the initial renderScript/RenderFunction API.
For any of the changes below, please talk to the team if you need support for an additional property or scenario!
renderScript has been replaced by serverEntry. The basic API is the same, but TS is now supported. If serverEntry is a JS or TS file, it will be bundled before rendering; this ensures that imports into internal packages that might not have been transpiled yet are supported. (Please talk to the team if this creates a problem for your scenario.) Due to the lack of native import map support in Node, we currently generate a single production mode bundle for the serverEntry rather than Cloudpack's usual "library mode" per-package approach.
Future: serverEntry will run in a separate process. This provides isolation and enables us to use a custom Node loader with import map support, which is needed for library mode (per-package bundles) on the server. This is still experimental, but you can try it out with the enableSSR feature flag (which also enforces the deprecations outlined below).
Deprecated RenderFunctionOptions properties: Running in a separate process requires updating the RenderFunction API to remove non-serializable objects from RenderFunctionOptions. The following properties are deprecated (but still available for now unless the enableSSR feature is enabled):
reqis replaced byrequestInfowhich includes a subset ofRequestproperties.resis replaced by returned object properties.sessionis replaced by various top-level properties.
No returning null: Returning null from the RenderFunction (indicating the request was fully handled) is no longer supported, since fully handling the request in the SSR thread won't be possible. If you were using custom handling to avoid auto-injected scripts, set injectScripts: false on the returned object.
If there's a scenario where access to the original Request/Response objects is critical, we might consider adding middleware support.
Render route configuration
See the main routing docs for more details about possible route types and configuration, but in summary:
All route types are configured under the routes property of cloudpack.config.json.
{
"$schema": "https://unpkg.com/@ms-cloudpack/cli/schema/AppConfig.json",
"routes": []
}A render route is a server-rendered route which can return HTML or other content. Its properties are as follows:
| Property | Type | Description |
|---|---|---|
match (required) | string | string[] | See match. |
serverEntry | string | Path to a TS, JS, or HTML file (relative to the app path) to render the route. Also supports exports map keys. A TS/JS file will be bundled before rendering, and must have a function as the default export (module.exports for CJS) or a named export render. |
renderScript (deprecated) | string | Like serverEntry but only supports JS or HTML, and won't be bundled if JS. Please talk to the team if you have a scenario where bundling the script causes a problem. |
entry | string | string[] | Source file path(s) to bundle and include in the HTML, e.g. ./src/index.tsx. This overwrites any original exports map for the package. |
Note that if any render (or bootstrap) route specifies an entry, this will overwrite any original exports map for the package. (Please talk to the team if this creates a problem for your scenario.)
As noted above, if you make changes to the serverEntry or anything it references, it's currently necessary to re-run cloudpack start.
Custom HTML
For basic pages, you can specify an HTML file as the serverEntry, but sometimes it's necessary to programmatically generate the HTML using a TS/JS file which exports a custom render function.
Note that as with the basic HTML rendering options, Cloudpack will bundle any provided entry files and automatically inject the bundled modules as script tags. It will also inject the import map and Cloudpack setup scripts, including the status overlay. To disable this behavior, set injectScripts: false on the returned object. (The relevant scripts and import map are provided in the options, so you can add them manually if appropriate.)
Example: Customizing HTML and headers
Suppose you have a render route like this for the root page of the app.
{
"match": "/",
// Renders the basic HTML for the page (see below)
"serverEntry": "./scripts/renderPage.ts",
// Client-side renders the app with React (omitted)
"entry": "./src/App.tsx",
}./scripts/renderPage.ts looks something like this. See RenderFunction for details about the available options and returned values. .js/.cjs/.mjs files are also supported. (If using a JS file, and the project uses CommonJS, the function would be assigned to module.exports.)
import type { RenderFunction } from '@ms-cloudpack/cli'
import fs from 'fs'
import path from 'path'
export const render: RenderFunction = (options) => {
// All the logic here is rather contrived, but it demonstrates some of
// the things that are possible.
console.log(`Rendering ${options.requestInfo.url}`)
// Read and customize the HTML. In reality this might render a template, or
// modify parts of the HTML that are needed for other ways the app is served
// but create problems for Cloudpack.
const htmlPath = path.join(options.appPath, 'index.html')
const html = fs.readFileSync(htmlPath, 'utf-8')
return {
content: html.replace('<!-- placeholder -->', 'Hello, world!'),
// Set a custom header
headers: { 'Some-Header': 'test' },
}
}Example: React server-side rendering (SSR)
Cloudpack supports React server-side rendering and hydration by configuring a serverEntry and entry similar to the following in cloudpack.config.json:
{
"routes": [
{
"match": "/",
"entry": "./src/index.client.tsx",
"serverEntry": "./src/index.server.tsx"
}
]
}Server part (serverEntry), src/index.server.tsx:
import type { RenderFunction } from '@ms-cloudpack/cli'
import React from 'react'
import { renderToString } from 'react-dom/server'
// This is a React component with your app code
import { App } from './App.js'
export const render: RenderFunction = async function () {
const appHTML = renderToString(<App />)
return `<html>
<head><title>My App</title></head>
<body><div id="root">${appHTML}</div></body>
</html>`
}Client part (entry), src/index.client.ts:
import React from 'react'
import { hydrate } from 'react-dom'
import { App } from './App.js'
const root = document.getElementById('root')
hydrate(<App />, root)Example: Reusing a renderer for multiple pages
Sometimes you might want to customize multiple pages' HTML in a similar way. This example uses .ejs HTML template files corresponding to page entry point .tsx files, but the same idea applies for other HTML generation methods.
The file structure for the example is like this:
src/
pages/
bar.ejs
bar.tsx
foo.ejs
foo.tsx
scripts/
renderPages.jsYou'd need multiple routes, since each path uses a different entry file, but you could reuse the same serverEntry:
{
"routes": [
{
"match": "/bar",
"entry": "./src/pages/bar.tsx",
"serverEntry": "./scripts/renderPages.ts"
},
{
"match": "/foo",
"entry": "./src/pages/foo.tsx",
"serverEntry": "./scripts/renderPages.ts"
}
]
}./scripts/renderPages.ts looks something like this. See RenderFunction for the detailed options.
import type { RenderFunction } from '@ms-cloudpack/cli'
import fs from 'fs'
import path from 'path'
import _ from 'lodash'
export const render: RenderFunction = (options) => {
const { baseUrl, requestInfo, appPath } = options
// Trim any query or hash from the request path and get the last segment,
// and use that to calculate the template path
const requestPath = path.basename(new URL(requestInfo.url, baseUrl).pathname)
const templatePath = path.join(appPath, 'src/pages', requestPath + '.ejs')
if (!fs.existsSync(templatePath)) {
// The custom renderer can also return an error
return { statusCode: 404, content: 'Page does not exist' }
}
// Read the template file and render it
const template = fs.readFileSync(templatePath, 'utf8')
const compiled = _.template(template)
// This could be any actual data used by the template
const data = { customData: 'some value' }
return compiled(data)
}Other content types
If you'd like to return non-HTML content, you can do this with a serverEntry and render function which returns a result object.
Returning an object including contentType is required for non-HTML responses.
Example: Rendering JS
Here's a very basic render function for a JS request.
import type { RenderFunction } from '@ms-cloudpack/cli'
const render: RenderFunction = (options) => {
return {
contentType: 'text/javascript',
content: `console.log("Hello from ${options.requestInfo.url}")`,
}
}Advanced scenarios
To further customize the response, you can include additional properties in the returned object. Please talk to the team if you require additional customization.
Types
The following types can be imported from @ms-cloudpack/cli (see RenderFunction.ts and Route.ts for the source).
RenderFunction
This custom server render function can be used to modify or override the default response for a route. A JS/TS file specified as a route's serverEntry must have a function of this type as its default export (or assign to module.exports if CJS) or a named export render.
type RenderFunction = (
options: RenderFunctionOptions,
) => Promise<RenderFunctionResult> | RenderFunctionResultRenderFunctionOptions
| Property | Type | Description |
|---|---|---|
route | RenderedRoute | Processed route being rendered. |
requestInfo | RequestInfo | |
baseUrl | string | Base URL of the app server, including port. |
appPath | string | Path to the root of the app package. |
definition | PackageJson | Contents of the app's package.json file. |
importMap | ImportMap | Import map object that will be used by the app at runtime (auto-injected). |
overlayScript | string | URL for the Cloudpack overlay script bundle (auto-injected). |
entryScripts | string[] | Additional script bundle URLs from route.entry (auto-injected). |
inlineScripts | string[] | Inline script tag contents used for Cloudpack initialization (auto-injected). |
req | Request | Deprecated in favor of requestInfo. |
res | Response | Deprecated in favor of returned properties. |
session | Session | Deprecated in favor of top-level properties. |
RenderedRoute
This is the processed version of a render route object.
| Property | Type | Description |
|---|---|---|
match (required) | string | string[] | Same as in original route. |
renderScript | string | Same as in original route. |
serverEntry | ExpandedSourcePath | Expanded version of the original route's serverEntry. |
entry | ExpandedSourcePath[] | Expanded version of the original route's entry (see below). |
ExpandedSourcePath
| Property | Type | Description |
|---|---|---|
sourcePath (required) | string | Relative path to the source file (e.g. './src/index.ts'). |
importPath (required) | string | Exports map key for the file (e.g. '.' or './lib/index.js'). |
RequestInfo
This is mostly a serializable subset of Express Request properties (which include some Node built-in properties). If you need an additional property, please talk to the team.
| Property | Type | Description |
|---|---|---|
url | string | URL relative to RenderFunctionOptions.baseUrl |
originalUrl | string | Complete original URL |
hostname | string | Hostname derived from the Host HTTP header |
params | see link | Named route parameters |
query | see link | Query params as parsed by the default qs parser |
headers | Record<string, string> | Request headers |
isEncrypted | boolean | Whether the request is encrypted per req.socket.encrypted (req.connection.encrypted) |
RenderFunctionResult
A render function can return one of the following (synchronously or async):
- String of HTML content
- Result object if returning non-HTML content and/or additional properties
- (Returning
nullis deprecated.)
The result object has the following properties. Please talk to the team if you require additional customization of the response.
| Property | Type | Default | Description |
|---|---|---|---|
content | string | object | Content of the response. This is required except for statuses such as 304 without a body. If this is non-HTML, contentType must be set. | |
statusCode | number | 200 | HTTP status code for the response. |
contentType | string | 'text/html' | content-type for the response. Required if content is provided and not HTML. |
headers | Record<string, string> | Extra headers to set on the response. | |
injectScripts | boolean | true | For HTML responses, set this to false to skip injecting scripts. Ignored for non-HTML responses. |