From b0147ce9cc0a1f3e9c83d1f6bb8abf0a4a56502b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Ogle?= Date: Tue, 11 Aug 2026 12:38:35 -0500 Subject: [PATCH] Rename errgo.ts to result.ts and move assets/ to ESM The vendored `errgo.ts` name said nothing about what the module does. Every export in it is about the `Result` tuple, and its siblings in assets/js/ are named as nouns-by-concern (theme, dates, routes, sentry), so `result.ts` fits both. Upstream still calls the package `errgo`; only the local filename differs, and CLAUDE.md records that. While updating the `test:unit` script for the rename it was hardcoded to a single file, so it now globs `js/**/*.test.ts`. The glob is scoped to js/ rather than relying on bare `node --test` discovery, which today only skips the Playwright suite because those files happen to end in .spec.ts. That script also carried --disable-warning=MODULE_TYPELESS_PACKAGE_JSON, suppressing a warning Node emits once per test file because assets/ had no "type" field. Rather than keep suppressing it, assets/ is now "type": "module" and the five CommonJS build scripts are explicitly .cjs. This removes the reparse overhead, makes the module system of each file explicit, and defuses the trap where Node's own warning text tells you to add "type": "module" without mentioning it breaks all five scripts. Two things the move required: * watch-ssr-pages resolved the generator via path.join at runtime, so the .cjs rename had to be reflected there or the dev watcher would have silently stopped regenerating the page registry. * e2e/global-setup.ts used __dirname, which does not exist in ESM. Playwright loads it as ESM under "type": "module", so global setup threw ReferenceError and would have failed the entire e2e suite. It now uses import.meta.dirname. Verified: mix precommit, 21/21 Playwright e2e, full mix assets.deploy (exercising upload-sourcemaps and compress-assets), both dev watchers regenerating, and priv/ssr.js still loading as CommonJS. --- CLAUDE.md | 6 +++--- Dockerfile | 2 +- .../build/{compress-assets.js => compress-assets.cjs} | 0 .../{generate-ssr-pages.js => generate-ssr-pages.cjs} | 4 ++-- .../{upload-sourcemaps.js => upload-sourcemaps.cjs} | 0 assets/build/{watch-routes.js => watch-routes.cjs} | 0 .../build/{watch-ssr-pages.js => watch-ssr-pages.cjs} | 2 +- assets/e2e/global-setup.ts | 2 +- assets/js/a11y-audit.ts | 2 +- assets/js/app.tsx | 2 +- assets/js/components/LocaleSync.tsx | 2 +- assets/js/i18n/index.ts | 2 +- assets/js/{errgo.test.ts => result.test.ts} | 2 +- assets/js/{errgo.ts => result.ts} | 0 assets/js/ssr.tsx | 2 +- assets/package.json | 5 +++-- config/dev.exs | 4 ++-- docs/frontend-pages.md | 4 ++-- lib/mix/tasks/routes_gen.ex | 2 +- mix.exs | 10 +++++----- 20 files changed, 27 insertions(+), 26 deletions(-) rename assets/build/{compress-assets.js => compress-assets.cjs} (100%) rename assets/build/{generate-ssr-pages.js => generate-ssr-pages.cjs} (98%) rename assets/build/{upload-sourcemaps.js => upload-sourcemaps.cjs} (100%) rename assets/build/{watch-routes.js => watch-routes.cjs} (100%) rename assets/build/{watch-ssr-pages.js => watch-ssr-pages.cjs} (91%) rename assets/js/{errgo.test.ts => result.test.ts} (94%) rename assets/js/{errgo.ts => result.ts} (100%) diff --git a/CLAUDE.md b/CLAUDE.md index a2efe7f..d11fc4b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,12 +27,12 @@ These are the rules that must not be forgotten or looked up — they're the ones **Inertia (this project's frontend)** - Use `render_inertia/2|3`, never `render/3`, for Inertia pages. Don't use LiveView for Inertia pages -- Page components are **always** `.tsx` (TypeScript), under `assets/js/pages/` in one of two strategy dirs: **`pages/ssr/`** (server-rendered: public / SEO / first-paint pages) or **`pages/client/`** (client-only: authenticated, interactive, heavy pages — kept out of the Node SSR bundle). The strategy dir is stripped from the Inertia page name, so `render_inertia("Dashboard")` maps to `pages/client/Dashboard.tsx`. The `_pages.ts` / `_ssr_pages.ts` registries are auto-generated from these dirs by `build/generate-ssr-pages.js` — never edit them by hand. New public page → `ssr/`; new authed page → `client/` +- Page components are **always** `.tsx` (TypeScript), under `assets/js/pages/` in one of two strategy dirs: **`pages/ssr/`** (server-rendered: public / SEO / first-paint pages) or **`pages/client/`** (client-only: authenticated, interactive, heavy pages — kept out of the Node SSR bundle). The strategy dir is stripped from the Inertia page name, so `render_inertia("Dashboard")` maps to `pages/client/Dashboard.tsx`. The `_pages.ts` / `_ssr_pages.ts` registries are auto-generated from these dirs by `build/generate-ssr-pages.cjs` — never edit them by hand. New public page → `ssr/`; new authed page → `client/` - Layout components live in `assets/js/layouts/` (e.g. `AppLayout`, `AuthLayout`) - Reusable UI primitives live flat in `assets/js/components/` (`Button`, `Spinner`, `Select`, `DropdownMenu`, `AlertDialog`, …) — no sub-folders - Forms use Inertia's `useForm` hook; errors come from `assign_errors(conn, changeset)` on the server -- **No raw `try/catch` for application async work — wrap every Promise in `go()` from `assets/js/errgo.ts`** (sync work uses `goSync`). It returns an error-first tuple, `[error, value]`, which forces every call site to acknowledge the failure path explicitly and prevents the "swallow the error and move on" pattern that hides real bugs. The same applies to dynamic `import()`, `fetch()`, JSON parsing, and vendor SDK calls. Raw catches are limited to Errgo's own implementation, React error boundaries, and CommonJS build-tool process boundaries that cannot import the TypeScript utility without a runtime transpiler -- `assets/js/errgo.ts` is vendored byte-for-byte from `andreogle/errgo` at commit `aaa1d5153a270cde4aa808369bd486ddbe263a38`. Update it by copying that source file, not by installing a package or editing the vendored implementation locally +- **No raw `try/catch` for application async work — wrap every Promise in `go()` from `assets/js/result.ts`** (sync work uses `goSync`). It returns an error-first tuple, `[error, value]`, which forces every call site to acknowledge the failure path explicitly and prevents the "swallow the error and move on" pattern that hides real bugs. The same applies to dynamic `import()`, `fetch()`, JSON parsing, and vendor SDK calls. Raw catches are limited to `result.ts`'s own implementation, React error boundaries, and CommonJS build-tool process boundaries that cannot import the TypeScript utility without a runtime transpiler +- `assets/js/result.ts` is vendored byte-for-byte from `andreogle/errgo` at commit `aaa1d5153a270cde4aa808369bd486ddbe263a38` (upstream calls the package `errgo`; only the local filename differs). Update it by copying that source file, not by installing a package or editing the vendored implementation locally - **Don't inline a multi-line function into `go(...)` / `goSync(...)`** — extract it to a named function above the call and pass it by name (`const [error, value] = await go(renderApp)`), so the call site stays a scannable one-liner. Small one-line callbacks (`go(() => i18n.changeLanguage(locale))`) are fine to inline. Mirrors the Elixir `with`-clause rule below - **Multi-line arrow/function bodies use explicit braces and `return`** — never a multi-line implicit return (it's too easy to lose track of what's returned, or drop the `return` when editing, and get weird behaviour). When you convert an implicit return to a block body, **keep the `return`** so the returned value is preserved — only drop it when the value is genuinely unused. And if the body fits on one line within the 120-col width, prefer collapsing to a single-line implicit return rather than a block. Single-line implicit returns (`(x) => x.id`) and idiomatic multi-line JSX render-props wrapped in parens (`({ Component }) => ( )`) are fine - Never edit `assets/js/_pages.ts`, `assets/js/_ssr_pages.ts`, or `assets/js/routes.ts` — they're auto-generated diff --git a/Dockerfile b/Dockerfile index 0c0345e..c8d9daa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -66,7 +66,7 @@ COPY assets assets # Build browser bundle + SSR bundle + digest, then compile the app. # -# Source maps: assets.deploy runs build/upload-sourcemaps.js, which ships +# Source maps: assets.deploy runs build/upload-sourcemaps.cjs, which ships # maps to Sentry only when SENTRY_AUTH_TOKEN/SENTRY_ORG/SENTRY_PROJECT are # set in the build env, and otherwise skips (so local/CI builds without # Sentry creds still succeed). These are declared as ARGs so Render's build diff --git a/assets/build/compress-assets.js b/assets/build/compress-assets.cjs similarity index 100% rename from assets/build/compress-assets.js rename to assets/build/compress-assets.cjs diff --git a/assets/build/generate-ssr-pages.js b/assets/build/generate-ssr-pages.cjs similarity index 98% rename from assets/build/generate-ssr-pages.js rename to assets/build/generate-ssr-pages.cjs index 535baae..a33fe2d 100644 --- a/assets/build/generate-ssr-pages.js +++ b/assets/build/generate-ssr-pages.cjs @@ -77,7 +77,7 @@ const serverRenderedSet = serverRendered.length ? `new Set([\n${serverRendered.map((n) => ` '${n}',`).join('\n')}\n])` : 'new Set()'; -const clientContent = `// Auto-generated by build/generate-ssr-pages.js — do not edit manually +const clientContent = `// Auto-generated by build/generate-ssr-pages.cjs — do not edit manually // Lazy client page loaders keyed by Inertia page name (the ssr/ or client/ // strategy directory is stripped). Lazy import() so esbuild code-splits one @@ -120,7 +120,7 @@ const clientOnlySet = clientOnly.length ? `new Set([\n${clientOnly.map((n) => ` '${n}',`).join('\n')}\n])` : 'new Set()'; -const ssrContent = `// Auto-generated by build/generate-ssr-pages.js — do not edit manually +const ssrContent = `// Auto-generated by build/generate-ssr-pages.cjs — do not edit manually ${ssrImports} // Server-rendered pages (those under pages/ssr/), keyed by Inertia page name. diff --git a/assets/build/upload-sourcemaps.js b/assets/build/upload-sourcemaps.cjs similarity index 100% rename from assets/build/upload-sourcemaps.js rename to assets/build/upload-sourcemaps.cjs diff --git a/assets/build/watch-routes.js b/assets/build/watch-routes.cjs similarity index 100% rename from assets/build/watch-routes.js rename to assets/build/watch-routes.cjs diff --git a/assets/build/watch-ssr-pages.js b/assets/build/watch-ssr-pages.cjs similarity index 91% rename from assets/build/watch-ssr-pages.js rename to assets/build/watch-ssr-pages.cjs index 77bf814..cc013dd 100644 --- a/assets/build/watch-ssr-pages.js +++ b/assets/build/watch-ssr-pages.cjs @@ -6,7 +6,7 @@ const path = require('node:path'); const { execFileSync } = require('node:child_process'); const PAGES_DIR = path.join(__dirname, '..', 'js', 'pages'); -const GENERATE = path.join(__dirname, 'generate-ssr-pages.js'); +const GENERATE = path.join(__dirname, 'generate-ssr-pages.cjs'); function regenerate() { try { diff --git a/assets/e2e/global-setup.ts b/assets/e2e/global-setup.ts index 13cb254..bb6088e 100644 --- a/assets/e2e/global-setup.ts +++ b/assets/e2e/global-setup.ts @@ -13,6 +13,6 @@ import path from 'node:path'; * (assets/e2e -> repo root). */ export default function globalSetup() { - const projectRoot = path.resolve(__dirname, '..', '..'); + const projectRoot = path.resolve(import.meta.dirname, '..', '..'); execSync('mix run priv/repo/e2e.exs', { cwd: projectRoot, stdio: 'inherit' }); } diff --git a/assets/js/a11y-audit.ts b/assets/js/a11y-audit.ts index c0ef4f9..3a98237 100644 --- a/assets/js/a11y-audit.ts +++ b/assets/js/a11y-audit.ts @@ -1,6 +1,6 @@ import { router } from '@inertiajs/react'; import axe from 'axe-core'; -import { go } from './errgo'; +import { go } from './result'; /** * Development-only accessibility auditing with axe-core. diff --git a/assets/js/app.tsx b/assets/js/app.tsx index 0fdad91..ed23c60 100644 --- a/assets/js/app.tsx +++ b/assets/js/app.tsx @@ -11,7 +11,7 @@ import ErrorBoundary from './components/ErrorBoundary'; import { syncLocale } from './components/LocaleSync'; import Toaster from './components/Toaster'; import { toast } from './components/toast'; -import { go } from './errgo'; +import { go } from './result'; import { startThemeWatcher } from './theme'; interface Flash { diff --git a/assets/js/components/LocaleSync.tsx b/assets/js/components/LocaleSync.tsx index c47dc49..0822d2e 100644 --- a/assets/js/components/LocaleSync.tsx +++ b/assets/js/components/LocaleSync.tsx @@ -1,6 +1,6 @@ import { router } from '@inertiajs/react'; -import { go } from '../errgo'; import i18n from '../i18n'; +import { go } from '../result'; /** * Sets i18next language and `` from initial page props, and keeps diff --git a/assets/js/i18n/index.ts b/assets/js/i18n/index.ts index da91887..5eb43cf 100644 --- a/assets/js/i18n/index.ts +++ b/assets/js/i18n/index.ts @@ -1,6 +1,6 @@ import i18n from 'i18next'; import { initReactI18next } from 'react-i18next'; -import { go } from '../errgo'; +import { go } from '../result'; import en from './locales/en'; import es from './locales/es'; diff --git a/assets/js/errgo.test.ts b/assets/js/result.test.ts similarity index 94% rename from assets/js/errgo.test.ts rename to assets/js/result.test.ts index f7b32f4..8b9b178 100644 --- a/assets/js/errgo.test.ts +++ b/assets/js/result.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { go, goSync } from './errgo.ts'; +import { go, goSync } from './result.ts'; test('go returns an error-first tuple for resolved and rejected operations', async () => { const success = await go(() => Promise.resolve('ready')); diff --git a/assets/js/errgo.ts b/assets/js/result.ts similarity index 100% rename from assets/js/errgo.ts rename to assets/js/result.ts diff --git a/assets/js/ssr.tsx b/assets/js/ssr.tsx index 4dbb73c..5436f66 100644 --- a/assets/js/ssr.tsx +++ b/assets/js/ssr.tsx @@ -6,8 +6,8 @@ import ReactDOMServer from 'react-dom/server'; import pages, { ssrClientOnly } from './_ssr_pages.ts'; import { AppProviders } from './app-providers'; import Toaster from './components/Toaster'; -import { go } from './errgo'; import i18n from './i18n'; +import { go } from './result'; // Sentry for the SSR Node workers (errors only — no tracing). The DSN is // inherited from the BEAM's environment; falls back to the frontend DSN diff --git a/assets/package.json b/assets/package.json index 9051bb1..bc6868f 100644 --- a/assets/package.json +++ b/assets/package.json @@ -1,4 +1,5 @@ { + "type": "module", "engines": { "npm": ">=11.10.0" }, @@ -10,8 +11,8 @@ "fmt": "npm run lint:fix && biome format --write", "lint": "biome check build/ css/ e2e/ js/", "lint:fix": "biome check --write build/ css/ e2e/ js/", - "test:unit": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test js/errgo.test.ts", - "typecheck": "node build/generate-ssr-pages.js && tsc -p tsconfig.json && tsc -p tsconfig.e2e.json" + "test:unit": "node --test 'js/**/*.test.ts'", + "typecheck": "node build/generate-ssr-pages.cjs && tsc -p tsconfig.json && tsc -p tsconfig.e2e.json" }, "dependencies": { "@inertiajs/react": "^3.6.1", diff --git a/config/dev.exs b/config/dev.exs index 853f777..8930a76 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -37,8 +37,8 @@ watchers = # (Enum.map) and gives each watcher a unique child id (make_ref), so # both node processes start independently. One regenerates the page # registries, the other the typed route table. - node: ["build/watch-ssr-pages.js", cd: Path.expand("../assets", __DIR__)], - node: ["build/watch-routes.js", cd: Path.expand("../assets", __DIR__)], + node: ["build/watch-ssr-pages.cjs", cd: Path.expand("../assets", __DIR__)], + node: ["build/watch-routes.cjs", cd: Path.expand("../assets", __DIR__)], esbuild_ssr: {Esbuild, :install_and_run, [:elixir_react_starter_ssr, ~w(--watch)]}, tailwind: {Tailwind, :install_and_run, [:elixir_react_starter, ~w(--watch)]} ] diff --git a/docs/frontend-pages.md b/docs/frontend-pages.md index 988899d..233d193 100644 --- a/docs/frontend-pages.md +++ b/docs/frontend-pages.md @@ -40,7 +40,7 @@ Dockerfile). ## How it works under the hood -`build/generate-ssr-pages.js` scans both directories and emits two registries +`build/generate-ssr-pages.cjs` scans both directories and emits two registries (both gitignored — never edit them by hand): - **`_pages.ts`** — the **client** manifest. Every page (ssr *and* client) as a @@ -56,7 +56,7 @@ Dockerfile). The generator runs before both esbuild bundles in `mix assets.build` / `assets.deploy` (the client bundle imports `_pages.ts`, the SSR bundle imports -`_ssr_pages.ts`), and a dev watcher (`build/watch-ssr-pages.js`) regenerates +`_ssr_pages.ts`), and a dev watcher (`build/watch-ssr-pages.cjs`) regenerates them whenever a page file is added or removed. ## Adding a page diff --git a/lib/mix/tasks/routes_gen.ex b/lib/mix/tasks/routes_gen.ex index c0e0149..64e0733 100644 --- a/lib/mix/tasks/routes_gen.ex +++ b/lib/mix/tasks/routes_gen.ex @@ -16,7 +16,7 @@ defmodule Mix.Tasks.Routes.Gen do `--check` is wired into `mix precommit`; plain generation is wired into `mix assets.build` / `mix assets.deploy` and the dev watcher - (`assets/build/watch-routes.js`), so the file stays in sync automatically. + (`assets/build/watch-routes.cjs`), so the file stays in sync automatically. ## Scope diff --git a/mix.exs b/mix.exs index 6557d52..268c5eb 100644 --- a/mix.exs +++ b/mix.exs @@ -175,7 +175,7 @@ defmodule ElixirReactStarter.MixProject do # Generate the page registries (_pages.ts + _ssr_pages.ts) before # either esbuild run: the client bundle imports _pages.ts and the # SSR bundle imports _ssr_pages.ts. - "cmd node assets/build/generate-ssr-pages.js", + "cmd node assets/build/generate-ssr-pages.cjs", # Generate the typed frontend route table (routes.ts) from the router # so the two can't drift. Guarded by `routes.gen --check` in precommit. "routes.gen", @@ -187,20 +187,20 @@ defmodule ElixirReactStarter.MixProject do "cmd rm -rf priv/static/assets/chunks", # Generate the page registries before either esbuild run (see # assets.build above). - "cmd node assets/build/generate-ssr-pages.js", + "cmd node assets/build/generate-ssr-pages.cjs", # Generate the typed frontend route table (see assets.build above). "routes.gen", # External source maps so Sentry can de-minify production stack - # traces. upload-sourcemaps.js ships them to Sentry and deletes the + # traces. upload-sourcemaps.cjs ships them to Sentry and deletes the # .map files before phx.digest runs, so they're never served. ~s(esbuild elixir_react_starter --minify --sourcemap=external --define:process.env.NODE_ENV='"production"'), "esbuild elixir_react_starter_ssr", - "cmd node assets/build/upload-sourcemaps.js", + "cmd node assets/build/upload-sourcemaps.cjs", "phx.digest", # phx.digest writes `.gz` next to every asset; this step writes the # brotli sibling so Plug.Static can serve whichever the request # accepts. Keep it last so it sees both the hashed and plain files. - "cmd node assets/build/compress-assets.js" + "cmd node assets/build/compress-assets.cjs" ], "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"], "ecto.reset": ["ecto.drop", "ecto.setup"],