new asset builder flow
makes use of react suspense to asynchronously build assets on the fly within components (!!!)
This commit is contained in:
@@ -5,13 +5,14 @@
|
||||
"scripts": {
|
||||
"prepare": "node ./scripts/fix-heroicons.js",
|
||||
"serve": "esmo --experimental-import-meta-resolve --experimental-json-modules --no-warnings --enable-source-maps src/main.tsx | pino-colada",
|
||||
"dev": "nodemon --exec \"pnpm serve\" --watch src --ext ts,tsx,md,css",
|
||||
"dev": "nodemon --inspect --exec \"pnpm serve\" --watch src --ext ts,tsx,md,css",
|
||||
"start": "NODE_ENV=production pnpm serve",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroicons/react": "^1.0.5",
|
||||
"@tailwindcss/typography": "^0.5.0",
|
||||
"alpinejs": "^3.7.1",
|
||||
"clsx": "^1.1.1",
|
||||
"compression": "^1.7.4",
|
||||
"esbuild": "^0.14.10",
|
||||
@@ -46,6 +47,7 @@
|
||||
"browser-sync": "^2.27.7",
|
||||
"execa": "^6.0.0",
|
||||
"nodemon": "^2.0.15",
|
||||
"react-ssr-prepass": "^1.5.0",
|
||||
"rxjs": "^7.5.1",
|
||||
"tsup": "^5.11.10",
|
||||
"type-fest": "^2.8.0",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user