Skip to content

Commit

Permalink
Updated SSR worfklow for better readability and implemented some addi…
Browse files Browse the repository at this point in the history
…tional error handling when serving / streaming static assets (#5)
  • Loading branch information
zachsa committed Aug 23, 2022
1 parent 048b326 commit 8342264
Show file tree
Hide file tree
Showing 11 changed files with 237 additions and 205 deletions.
1 change: 1 addition & 0 deletions web/jspm-node.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const generator = new Generator({
'koa-bodyparser',
'http-errors',
'pg',
'mime'
],
})

Expand Down
256 changes: 137 additions & 119 deletions web/package-lock.json

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"dependencies": {
"@apollo/client": "^3.6.9",
"@arcgis/core": "^4.24.7",
"@emotion/cache": "^11.10.1",
"@emotion/cache": "^11.10.2",
"@emotion/react": "^11.10.0",
"@emotion/server": "^11.10.0",
"@emotion/styled": "^11.10.0",
Expand All @@ -21,7 +21,8 @@
"@node-loader/import-maps": "^1.1.0",
"@rollup/plugin-replace": "^4.0.0",
"@swc/cli": "^0.1.57",
"@swc/core": "^1.2.241",
"@swc/core": "^1.2.242",
"@types/react": "^18.0.17",
"apollo-server-core": "^3.10.1",
"apollo-server-koa": "^3.10.1",
"browserslist": "^4.21.3",
Expand All @@ -38,6 +39,7 @@
"koa-static": "^5.0.0",
"make-fetch-happen": "^10.2.1",
"maplibre-gl": "2.3.0",
"mime": "^3.0.0",
"mkdirp": "^1.0.4",
"mongodb": "^4.9.0",
"pg": "^8.7.3",
Expand Down
1 change: 1 addition & 0 deletions web/somisana.web.d.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
declare module 'cookie'
declare module 'make-fetch-happen'
4 changes: 3 additions & 1 deletion web/ssr/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ export default ({ children, ctx, emotionCache }) => {
cookie={cookie}
acceptLanguage={language}
emotionCache={emotionCache}
Router={props => <StaticRouter location={ctx.request.url} context={{}} {...props} />}
Router={(props: React.FC) => (
<StaticRouter location={ctx.request.url} context={{}} {...props} />
)}
apolloClient={apolloClient}
>
{children}
Expand Down
11 changes: 11 additions & 0 deletions web/ssr/_register-assets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { readdir } from 'fs/promises'
import dirname from '../server/lib/dirname'
import { join, normalize } from 'path'

const __dirname = dirname(import.meta)

export const assetsPath = normalize(join(__dirname, '../.client'))

export const htmlFiles = await readdir(normalize(join(__dirname, '../client/html')))
.then((files: Array<string>) => files.filter((f: string) => f.includes('.html')))
.then((files: Array<string>) => files.map((f: string) => f.replace('.html', '')))
41 changes: 41 additions & 0 deletions web/ssr/_render-html.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { createEmotionCache } from '../common/app'
import createEmotionServer from '@emotion/server/create-instance'
import Layout from './_layout'
import { renderToString } from 'react-dom/server'
import { join, normalize } from 'path'
import fs from 'fs/promises'

const INDEX_NAME = 'somisana'

export default async (ctx, htmlFiles, assetsPath) => {
ctx.set('Content-type', 'text/html')
const entry = ctx.request.url.replace('.html', '').replace('/', '')
const page = entry ? (htmlFiles.includes(entry) ? entry : INDEX_NAME) : INDEX_NAME

const htmlUtf8 = await fs.readFile(normalize(join(assetsPath, `${page}.html`)), {
encoding: 'utf-8',
})

const SsrEntry = await import(normalize(join(assetsPath, `ssr.${page}.js`))).then(
({ default: C }) => C
)

const emotionCache = createEmotionCache()
const { extractCriticalToChunks, constructStyleTagsFromChunks } =
createEmotionServer(emotionCache)

const html = renderToString(
<Layout ctx={ctx} emotionCache={emotionCache}>
<SsrEntry />
</Layout>
)

const emotionChunks = extractCriticalToChunks(html)
const emotionCss = constructStyleTagsFromChunks(emotionChunks)

const result = htmlUtf8
.replace('</title>', `</title>${emotionCss}`)
.replace('<div id="root"></div>', `<div id="root">${html}</div>`)

ctx.body = result
}
4 changes: 0 additions & 4 deletions web/ssr/_serve.ts

This file was deleted.

14 changes: 14 additions & 0 deletions web/ssr/_stream-file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { open } from 'fs/promises'

export default async (ctx: any, contentType: string, filePath: string) => {
let fd: any
try {
fd = await open(filePath)
} catch (error) {
ctx.status = 404
return
}

ctx.set('Content-type', contentType)
ctx.body = fd.createReadStream()
}
7 changes: 7 additions & 0 deletions web/ssr/_url-rewrite.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export default (path: string) =>
path.replace(/(explore)\/(.*)/, (match, g1, g2) => {
if (path.endsWith('.js')) {
return path.replace(`${g1}/`, '')
}
return match.replace(`${g1}/`, 'esri-atlas').replace(g2, '')
})
97 changes: 18 additions & 79 deletions web/ssr/index.tsx
Original file line number Diff line number Diff line change
@@ -1,84 +1,23 @@
import { join, normalize } from 'path'
import fs from 'fs/promises'
import dirname from '../server/lib/dirname'
import { createEmotionCache } from '../common/app'
import createEmotionServer from '@emotion/server/create-instance'
import Layout from './_layout'
import serve from './_serve'
import streamFile from './_stream-file'
import renderHTML from './_render-html'
import mime from 'mime'
import rewrite from './_url-rewrite'
import { assetsPath, htmlFiles } from './_register-assets'

/**
* Emotion doesn't support stream-rendering yet
* Once it does, please update
*/
import { renderToString } from 'react-dom/server'

const INDEX_NAME = 'somisana'
const __dirname = dirname(import.meta)
const files = normalize(join(__dirname, '../.client'))
const APP_ENTRIES = await fs
.readdir(normalize(join(__dirname, '../client/html')))
.then(files => files.filter(f => f.includes('.html')))
.then(files => files.map(f => f.replace('.html', '')))

const rewrite = str =>
str.replace(/(explore)\/(.*)/, (match, g1, g2) => {
if (str.endsWith('.js')) {
return str.replace(`${g1}/`, '')
}
return match.replace(`${g1}/`, 'esri-atlas').replace(g2, '')
})

export default async ctx => {
export default async (ctx: any) => {
const url = rewrite(ctx.request.url)

if (url.endsWith('.txt')) {
ctx.set('Content-type', 'text/plain')
ctx.body = serve(ctx, { files, url })
} else if (url.endsWith('.ico')) {
ctx.set('Content-type', 'image/x-icon')
ctx.body = serve(ctx, { files, url })
} else if (url.endsWith('.js')) {
ctx.set('Content-type', 'application/javascript; charset=utf-8')
ctx.body = serve(ctx, { files, url })
} else if (url.endsWith('.png')) {
ctx.set('Content-type', 'image/png')
ctx.body = serve(ctx, { files, url })
} else if (url.endsWith('.css')) {
ctx.set('Content-type', 'text/css')
ctx.body = serve(ctx, { files, url })
} else if (url.endsWith('site.webmanifest')) {
ctx.set('Content-type', 'application/json; charset=utf-8')
ctx.body = serve(ctx, { files, url })
} else {
ctx.set('Content-type', 'text/html')
const entry = ctx.request.url.replace('.html', '').replace('/', '')
const page = entry ? (APP_ENTRIES.includes(entry) ? entry : INDEX_NAME) : INDEX_NAME

const htmlUtf8 = await fs.readFile(normalize(join(files, `${page}.html`)), {
encoding: 'utf-8',
})

const SsrEntry = await import(normalize(join(files, `ssr.${page}.js`))).then(
({ default: C }) => C
)

const emotionCache = createEmotionCache()
const { extractCriticalToChunks, constructStyleTagsFromChunks } =
createEmotionServer(emotionCache)

const html = renderToString(
<Layout ctx={ctx} emotionCache={emotionCache}>
<SsrEntry />
</Layout>
)

const emotionChunks = extractCriticalToChunks(html)
const emotionCss = constructStyleTagsFromChunks(emotionChunks)

const result = htmlUtf8
.replace('</title>', `</title>${emotionCss}`)
.replace('<div id="root"></div>', `<div id="root">${html}</div>`)

ctx.body = result
const contentType = mime.getType(url)
const assetPath = normalize(join(assetsPath, url))

switch (contentType) {
case 'text/html':
case null:
await renderHTML(ctx, htmlFiles, assetsPath)
break

default:
await streamFile(ctx, contentType, assetPath)
break
}
}

0 comments on commit 8342264

Please sign in to comment.