new asset builder flow

makes use of react suspense to asynchronously build assets on the fly within components (!!!)
This commit is contained in:
MapleLeaf
2022-01-07 14:28:47 -06:00
parent 0a4a8d87d3
commit b93919edd8
7 changed files with 114 additions and 75 deletions

View File

@@ -1,17 +1,16 @@
import { RequestHandler } from "express"
import express, { RequestHandler } from "express"
import { createHash } from "node:crypto"
import { readFileSync } from "node:fs"
import { mkdir, stat, writeFile } from "node:fs/promises"
import { dirname, extname, join, parse } from "node:path"
import { mkdir, rm, writeFile } from "node:fs/promises"
import { dirname, join, parse } from "node:path"
export type Asset = {
file: string
type Asset = {
inputFile: string
outputFile: string
url: string
content: Buffer
}
export type AssetTransformer = {
transform: (asset: Asset) => Promise<AssetTransformResult | undefined>
transform: (inputFile: string) => Promise<AssetTransformResult | undefined>
}
export type AssetTransformResult = {
@@ -20,68 +19,75 @@ export type AssetTransformResult = {
}
export class AssetBuilder {
// map of asset urls to asset objects
private library = new Map<string, Asset>()
constructor(
private constructor(
private cacheFolder: string,
private transformers: AssetTransformer[],
) {}
// accepts a path to a file, then returns a url to where the built file will be served
// the url will include a hash of the file contents
file(file: string | URL): string {
if (file instanceof URL) {
file = file.pathname
}
static async create(cacheFolder: string, transformers: AssetTransformer[]) {
await rm(cacheFolder, { recursive: true })
return new AssetBuilder(cacheFolder, transformers)
}
const existing = this.library.get(file)
async build(inputFile: string | URL, name?: string): Promise<string> {
inputFile = normalizeAsFilePath(inputFile)
const existing = this.library.get(inputFile)
if (existing) {
return existing.url
}
const content = readFileSync(file)
const hash = createHash("sha256").update(content).digest("hex").slice(0, 8)
const transformResult = await this.transform(inputFile)
const hash = createHash("sha256")
.update(transformResult.content)
.digest("hex")
.slice(0, 8)
const parsedInputFile = parse(inputFile)
const url = `/${name || parsedInputFile.name}.${hash}${parsedInputFile.ext}`
const outputFile = join(this.cacheFolder, url)
await ensureWrite(outputFile, transformResult.content)
this.library.set(inputFile, { inputFile, outputFile, url })
const { name, ext } = parse(file)
const url = `/${name}.${hash}${ext}`
this.library.set(url, { file, url, content })
return url
}
middleware(): RequestHandler {
return async (req, res, next) => {
try {
const asset = this.library.get(req.path)
if (!asset) return next()
local(inputFile: string | URL, name?: string): string {
inputFile = normalizeAsFilePath(inputFile)
const file = join(this.cacheFolder, asset.url)
const extension = extname(file)
const stats = await stat(file).catch(() => undefined)
if (!stats?.isFile()) {
const transformResult = await this.transform(asset)
if (!transformResult) return next()
await mkdir(dirname(file), { recursive: true })
await writeFile(file, transformResult.content)
}
res
.status(200)
.type(extension.endsWith("tsx") ? "text/javascript" : extension)
.header("Cache-Control", "public, max-age=604800, immutable")
.sendFile(file)
} catch (error) {
next(error)
}
const asset = this.library.get(inputFile)
if (asset) {
return asset.url
}
throw this.build(inputFile, name)
}
private async transform(asset: Asset) {
middleware(): RequestHandler {
return express.static(this.cacheFolder, {
immutable: true,
maxAge: "1y",
})
}
private async transform(inputFile: string) {
for (const transformer of this.transformers) {
const result = await transformer.transform(asset)
const result = await transformer.transform(inputFile)
if (result) return result
}
throw new Error(`No transformers found for ${inputFile}`)
}
}
async function ensureWrite(file: string, content: string) {
await mkdir(dirname(file), { recursive: true })
await writeFile(file, content)
}
function normalizeAsFilePath(file: string | URL) {
return new URL(file, "file:").pathname
}

View File

@@ -1,19 +1,12 @@
import { build } from "esbuild"
import { readFile } from "node:fs/promises"
import { dirname } from "node:path"
import { AssetTransformer } from "./asset-builder.js"
export const transformEsbuild: AssetTransformer = {
async transform(asset) {
if (asset.file.match(/\.tsx?$/)) {
async transform(inputFile) {
if (inputFile.match(/\.[jt]sx?$/)) {
const scriptBuild = await build({
entryPoints: [inputFile],
bundle: true,
stdin: {
contents: await readFile(asset.file, "utf-8"),
sourcefile: asset.file,
loader: "tsx",
resolveDir: dirname(asset.file),
},
target: ["chrome89", "firefox89"],
format: "esm",
write: false,

View File

@@ -1,14 +1,17 @@
import { readFile } from "fs/promises"
import postcss from "postcss"
import tailwindcss from "tailwindcss"
import { AssetTransformer } from "./asset-builder.js"
export const transformPostCss: AssetTransformer = {
async transform(asset) {
if (!asset.file.match(/\.css$/)) return
async transform(inputFile) {
if (!inputFile.match(/\.css$/)) return
const result = await postcss(tailwindcss).process(
await readFile(inputFile),
{ from: inputFile },
)
const result = await postcss(tailwindcss).process(asset.content, {
from: asset.file,
})
return {
content: result.css,
type: "text/css",

View File

@@ -7,6 +7,9 @@ const tailwindCssPath = new URL(
await import.meta.resolve!("tailwindcss/tailwind.css"),
).pathname
const alpineJs = new URL(await import.meta.resolve!("alpinejs/dist/cdn.js"))
.pathname
export function Html({
title = "Reacord",
description = packageJson.description,
@@ -35,13 +38,15 @@ export function Html({
as="style"
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@500&family=Rubik:ital,wght@0,300;0,400;0,500;1,300;1,400;1,500&display=swap"
/>
<link rel="stylesheet" href={assets.file(tailwindCssPath)} />
<link rel="stylesheet" href={assets.local(tailwindCssPath)} />
<link
rel="stylesheet"
href={assets.file(new URL("ui/prism-theme.css", import.meta.url))}
href={assets.local(new URL("ui/prism-theme.css", import.meta.url))}
/>
<title>{title}</title>
<script defer src={assets.local(alpineJs, "alpine")} />
</head>
<body>{children}</body>
</html>

View File

@@ -7,6 +7,7 @@ import pino from "pino"
import pinoHttp from "pino-http"
import * as React from "react"
import { renderToStaticMarkup } from "react-dom/server.js"
import ssrPrepass from "react-ssr-prepass"
import { AssetBuilderProvider } from "./asset-builder/asset-builder-context.js"
import { AssetBuilder } from "./asset-builder/asset-builder.js"
import { transformEsbuild } from "./asset-builder/transform-esbuild.js"
@@ -20,14 +21,15 @@ import { Landing } from "./landing/landing"
const logger = pino()
const port = process.env.PORT || 3000
const assets = new AssetBuilder(fromProjectRoot(".asset-cache"), [
const assets = await AssetBuilder.create(fromProjectRoot(".asset-cache"), [
transformEsbuild,
transformPostCss,
])
export function sendJsx(res: Response, jsx: React.ReactElement) {
res.set("Content-Type", "text/html")
res.send(`<!DOCTYPE html>\n${renderToStaticMarkup(jsx)}`)
async function render(res: Response, element: React.ReactElement) {
element = <React.Suspense fallback={null}>{element}</React.Suspense>
await ssrPrepass(element)
res.type("html").send(`<!DOCTYPE html>\n${renderToStaticMarkup(element)}`)
}
const errorHandler: ErrorRequestHandler = (error, request, response, next) => {
@@ -44,7 +46,7 @@ const router = Router()
const { html, data } = await renderMarkdownFile(
new URL(`guides/${req.params[0]}.md`, import.meta.url).pathname,
)
sendJsx(
await render(
res,
<AssetBuilderProvider value={assets}>
<Html title={`${data.title} | Reacord`} description={data.description}>
@@ -54,8 +56,8 @@ const router = Router()
)
})
.get("/", (req, res) => {
sendJsx(
.get("/", async (req, res) => {
await render(
res,
<AssetBuilderProvider value={assets}>
<Html>
@@ -70,7 +72,7 @@ const router = Router()
const server = express()
.use(router)
.listen(port, () => {
logger.info(`Server is running on https://localhost:${port}`)
logger.info(`Server is running on http://localhost:${port}`)
})
const terminator = httpTerminator.createHttpTerminator({ server })