diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index c093cbe..2385935 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -14,7 +14,7 @@ concurrency: cancel-in-progress: false env: - BUILD_PATH: spa + BUILD_PATH: static jobs: build: @@ -30,11 +30,11 @@ jobs: id: pages uses: actions/configure-pages@v5 - run: BASE_URL="/experiment" bun run build:spa - - run: cp ./spa/index.html ./spa/404.html + - run: cp ./static/index.html ./static/404.html - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: - path: spa + path: static deploy: environment: diff --git a/.gitignore b/.gitignore index 5ba344c..6832714 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ .DS_Store node_modules build -spa +static *.bun-build magick diff --git a/docs/architecture.md b/docs/architecture.md index 26844e0..81b232f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture -This project uses custom architecture I refer to as `entangled atoms` which extends [jōtai](https://jotai.org/) atoms to synchronize state across different [realms](https://262.ecma-international.org/#realm). This allows for end-to-end isomorphic state management where same primitives are used to manage state everywhere. Unconventional nature of this approach led me to design my own boilerplate; after trying out different runtimes I decided to make use of [Bun](https://bun.sh/) which is very fast and offers neat features like macros and programmatically accessible bundler. +This project uses custom architecture I refer to as `entangled atoms` which extends [jōtai](https://jotai.org/) atoms to synchronize state across different [realms](https://262.ecma-international.org/#realm). This allows for end-to-end isomorphic state management where same primitives are used to manage state everywhere. Unconventional nature of this approach led me to design my own boilerplate; after trying out different runtimes I decided to make use of [Bun](https://bun.sh/) which is very fast. There is no Next.js, Remix, Vite, Webpack or Babel here; my aim is simplicity and minimalism in terms of requirements and general architecture. Currently, boilerplate implements full streaming Server-Side Rendering(SSR) with transparent bundling, hydration and Server-Sent Events(SSE) for state sync. diff --git a/scripts/build.bin.ts b/scripts/build.bin.ts index 33fdeaf..7b2db1b 100644 --- a/scripts/build.bin.ts +++ b/scripts/build.bin.ts @@ -1,15 +1,11 @@ import { $ } from "bun"; -import { VERSION } from "../src/const/dynamic"; - -const TARGETS = ["linux-x64", "linux-arm64", "windows-x64", "darwin-x64", "darwin-arm64"]; +import { revisionAtom } from "../src/atoms/common"; +import { store } from "../src/store"; $`rm -rf ./build`; -// Build all targets -await Promise.all( - TARGETS.map( - (target) => - $`bun build --compile --minify --target=bun-${target}-modern ./src/entry/server.tsx --outfile ./build/experiment-${VERSION}-${target}`, - ), -); +const revision = await store.get(revisionAtom); +for (const target of ["linux-x64", "linux-arm64", "windows-x64", "darwin-x64", "darwin-arm64"]) { + await $`bun build --compile --minify --target=bun-${target}-modern ./src/entry/server.tsx --outfile ./build/experiment-${revision}-${target}`; +} diff --git a/scripts/build.spa.tsx b/scripts/build.spa.tsx index 8130e01..d77b94d 100644 --- a/scripts/build.spa.tsx +++ b/scripts/build.spa.tsx @@ -1,12 +1,12 @@ import { $ } from "bun"; -import { name, description, iconResolutions } from "../src/const"; +import { name, description, iconResolutions, staticDir } from "../src/const"; import { getHtml } from "../src/entry/_handlers"; import { getManifest } from "../src/feature/pwa/manifest"; import { ROUTES } from "../src/feature/router"; import { assignToWindow } from "../src/utils/hydration"; // why does this work but running it from Bun.build fails? #justbunthings -await $`bun build ./src/entry/client.tsx --outdir ./spa --minify`; +await $`bun build ./src/entry/client.tsx --outdir ./${staticDir} --minify`; // const buildResult = await Bun.build({ // entrypoints: ["./src/entry/client.tsx"], // outdir: "./spa", @@ -29,14 +29,14 @@ for (const route of ROUTES) { baseUrl, ); - await Bun.write(`./spa/${pathname}.html`, html); + await Bun.write(`./${staticDir}/${pathname}.html`, html); } await Bun.write( - "./spa/manifest.json", + `./${staticDir}/manifest.json`, JSON.stringify(getManifest(name, description, iconResolutions, baseUrl), null, 2), ); for (const res of iconResolutions) { - await $`cp ./.github/assets/experiment-${res}.png ./spa`; + await $`cp ./.github/assets/experiment-${res}.png ./${staticDir}`; } diff --git a/src/atoms/common.ts b/src/atoms/common.ts index d38624a..8969124 100644 --- a/src/atoms/common.ts +++ b/src/atoms/common.ts @@ -7,7 +7,7 @@ import { Result } from "true-myth"; import { createFileStorage, getStoragePath, resolve, spawn } from "../utils"; import { divergentAtom, entangledAtom } from "../utils/entanglement"; import { getRealm, hasBackend } from "../utils/realm"; -import { author } from "../const"; +import { author, version } from "../const"; import type { _Message, SerialExperiment, ExperimentWithMeta, Message } from "../types"; import { modelLabels, type ProviderType } from "../feature/inference/types"; @@ -257,3 +257,21 @@ export const localCertAndKeyAtom = atom(async () => { } return Result.ok({ key: `${getStoragePath()}/cert.key`, cert: `${getStoragePath()}/key.cert` }); }); + +export const revisionAtom = entangledAtom( + "revision", + atom(async (get) => { + const result = await spawn("git", ["rev-parse", "HEAD"]); + const hash = result.map((hash) => hash.slice(0, 4)); + const revision = [version, hash.unwrapOr(undefined)].filter(Boolean).join("-"); + return revision; + }), +); + +export const debugAtom = entangledAtom( + "debug", + atom(() => { + if (getRealm() !== "server") return false; + return process.env.DEBUG === "true"; + }), +); diff --git a/src/atoms/server.ts b/src/atoms/server.ts new file mode 100644 index 0000000..3352fe2 --- /dev/null +++ b/src/atoms/server.ts @@ -0,0 +1,21 @@ +import { atom } from "jotai"; +import { getRealm } from "../utils/realm"; +import { Maybe } from "true-myth"; +import { resolve, spawn } from "../utils"; +import { clientFile, staticDir } from "../const"; + +export const clientScriptAtom = atom(async () => { + if (getRealm() !== "server") return Maybe.nothing(); + const result = await spawn("bun", ["build", "./src/entry/client.tsx", "--outdir", `./${staticDir}`, "--minify"]); + if (result.isErr) { + console.error(result.error); + return Maybe.nothing(); + } + const fs = await resolve("fs/promises"); + const readFile = fs.map((fs) => fs.readFile); + if (readFile.isOk) { + const file = await readFile.value(`./${staticDir}/${clientFile}`, "utf8"); + return Maybe.just(file); + } + return Maybe.nothing(); +}); \ No newline at end of file diff --git a/src/const/_macro.ts b/src/const/_macro.ts deleted file mode 100644 index 957c76c..0000000 --- a/src/const/_macro.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { $ } from "bun"; - -export async function getReleaseHash() { - const hash = await $`git rev-parse HEAD`.text(); - return hash.slice(0, 8); -} - -export function getDebug() { - return process.env.DEBUG === "true"; -} diff --git a/src/const/dynamic.ts b/src/const/dynamic.ts deleted file mode 100644 index c30d5d6..0000000 --- a/src/const/dynamic.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { version } from "."; -// import { getDebug, getReleaseHash } from "./_macro" with { type: "macro" }; - -// export const DEBUG = getDebug(); -// const hash = await getReleaseHash(); -// export const VERSION = `${version}-${hash}`; - -export const DEBUG = false; -export const VERSION = version; diff --git a/src/const/index.ts b/src/const/index.ts index 9c79cbb..d9d90ad 100644 --- a/src/const/index.ts +++ b/src/const/index.ts @@ -1,6 +1,7 @@ import project from "../../package.json"; export const clientFile = "/client.js"; +export const staticDir = "static"; export const schema = "http"; export const hostname = "localhost"; diff --git a/src/entry/_handlers.tsx b/src/entry/_handlers.tsx index 0121c66..b822ba2 100644 --- a/src/entry/_handlers.tsx +++ b/src/entry/_handlers.tsx @@ -6,10 +6,11 @@ import { log } from "../utils/logger"; import { Shell } from "../root"; import { publish, subscribe, type Update } from "../utils/æther"; import { eventStream } from "../utils/eventStream"; -import { getClientAsString } from "./_macro" with { type: "macro" }; import { getManifest } from "../feature/pwa/manifest"; import { clientFile, description, iconResolutions, name } from "../const"; import type { Nullish } from "../types"; +import { clientScriptAtom } from "../atoms/server"; +import { store } from "../store"; export const getHtml = (location: string, additionalScripts?: Array, baseUrl?: string) => { const html = renderToString( @@ -34,10 +35,10 @@ export const doStatic = async (request: Request) => { }); } if (url.pathname === clientFile) { - response = new Response(await getClientAsString(), { - headers: { - "Content-Type": "application/javascript", - }, + const clientScript = await store.get(clientScriptAtom); + response = clientScript.match({ + Just: (script) => new Response(script, { headers: { "Content-Type": "application/javascript" } }), + Nothing: () => new Response("KO", { status: 500 }), }); } if (response) { diff --git a/src/entry/_macro.ts b/src/entry/_macro.ts deleted file mode 100644 index 29922d9..0000000 --- a/src/entry/_macro.ts +++ /dev/null @@ -1,13 +0,0 @@ -export const getClientAsString = async (entry = "src/entry/client.tsx") => { - const { - outputs: [js, ...outputs], - } = await Bun.build({ - entrypoints: [entry], - throw: true, - }); - console.log(`Emitted 1+${outputs.length} files`); - if (outputs.length) { - console.log(outputs); - } - return js.text(); -}; diff --git a/src/entry/server.spa.tsx b/src/entry/server.spa.tsx index f1f65b2..8e18373 100644 --- a/src/entry/server.spa.tsx +++ b/src/entry/server.spa.tsx @@ -3,8 +3,9 @@ import { clientFile, hostname, port } from "../const"; import { FIXTURES, isFixture } from "./_fixtures"; import { assignToWindow, createHydrationScript } from "../utils/hydration"; import { getHtml } from "./_handlers"; -import { getClientAsString } from "./_macro" with { type: "macro" }; import { setRealm } from "../utils/realm"; +import { store } from "../store"; +import { clientScriptAtom } from "../atoms/server"; export default { development: true, @@ -14,8 +15,10 @@ export default { const url = new URL(req.url); console.log(url.pathname, clientFile); if (url.pathname === clientFile) { - return new Response(await getClientAsString(), { - headers: { "Content-Type": "application/javascript" }, + const clientScript = await store.get(clientScriptAtom); + return clientScript.match({ + Just: (script) => new Response(script, { headers: { "Content-Type": "application/javascript" } }), + Nothing: () => new Response("KO", { status: 500 }), }); } const fixture = url.searchParams.get("fixture"); diff --git a/src/entry/server.tsx b/src/entry/server.tsx index a9e60ec..34cf1ae 100644 --- a/src/entry/server.tsx +++ b/src/entry/server.tsx @@ -1,10 +1,9 @@ import { $, type Serve } from "bun"; import { hostname, port } from "../const"; -import { DEBUG } from "../const/dynamic"; import { createFetch } from "../utils/handler"; import { doPOST, doSSE, doStatic, doStreamingSSR } from "./_handlers"; import { store } from "../store"; -import { localCertAndKeyAtom } from "../atoms/common"; +import { debugAtom, localCertAndKeyAtom } from "../atoms/common"; process.env.REALM = "ssr"; @@ -13,7 +12,7 @@ const tls = (await store.get(localCertAndKeyAtom)) .unwrapOr(undefined); export default { - development: DEBUG, + development: store.get(debugAtom), hostname, port, fetch: createFetch(doSSE, doPOST, doStatic, doStreamingSSR), diff --git a/src/feature/router/navigation.tsx b/src/feature/router/navigation.tsx index c3c23b1..ed93b29 100644 --- a/src/feature/router/navigation.tsx +++ b/src/feature/router/navigation.tsx @@ -2,13 +2,12 @@ import styled from "@emotion/styled"; import { atom, useAtom } from "jotai"; import { NavLink, useLocation } from "react-router"; -import { TRIANGLE } from "../../const"; +import { TRIANGLE, version } from "../../const"; import { experimentsSidebarAtom, ROUTES } from "."; import { bs } from "../../style"; import { nonInteractive, widthAvailable } from "../../style/mixins"; import { portalIO } from "../../utils/portal"; -import { templatesAtom } from "../../atoms/common"; -import { VERSION } from "../../const/dynamic"; +import { revisionAtom, templatesAtom } from "../../atoms/common"; import { increaseSpecificity } from "../../style/utils"; export const [SidebarInput, SidebarOutput] = portalIO(); @@ -83,6 +82,7 @@ const SidebarComponent = () => { export const NavigationSidebar = () => { const [routes] = useAtom(routesAtom); const location = useLocation(); + const [revision] = useAtom(revisionAtom); return (
@@ -100,7 +100,7 @@ export const NavigationSidebar = () => { {(location.pathname === "/" || location.pathname.startsWith("/experiment")) && } ); diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 11a8c77..6a5d921 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -1,7 +1,8 @@ -import { DEBUG } from "../const/dynamic"; +import { store } from "../store"; +import { debugAtom } from "../atoms/common"; export const log = (...args: any[]) => { - if (DEBUG) { + if (store.get(debugAtom)) { const timestamp = new Date().toISOString(); console.log(timestamp, ...args); }