@ainias42/express-app
v0.5.0
Published
```sh ea export ```
Readme
Express App
Static HTML Export
ea exportThe export supports fixed routes and routes enumerated with getStaticParams, including isomorphic props and language variants. Dynamic or optional routes without a parameter function, empty parameter lists and server-only pages are excluded and reported. Exporting is independent of the Express handler and its prepare function.
Set excludeFromExport: true in withPage options to exclude a page from static export. The exclusion is recorded in report.json; neither its parameter nor props functions run during export. The default is false, and normal server rendering and client navigation remain available.
Add getStaticParams to withPage to enumerate a dynamic route such as /articles/:slug:
export const ArticlePage = withPage(ArticleComponent, {
getStaticParams: async () => [{ slug: 'first' }, { slug: 'café' }],
getIsomorphicProps: async ({ params, path }) => ({
props: { title: await loadArticleTitle(params.slug), path },
}),
});getStaticParams returns an array of parameter objects synchronously or asynchronously and runs once per route. Values are strings, or non-empty arrays of strings for wildcards such as /files/*path. Optional segments can be omitted, for example [{}, { year: '2026' }] for /archive{/:year}. Values are URL-encoded by path-to-regexp; pass decoded values such as café, not pre-encoded values. Fixed routes without a parameter function render once.
For each concrete path, export calls getIsomorphicProps({ path, params }) without req or res. When both props functions exist, only getIsomorphicProps runs; for server-only pages, neither the props function nor getStaticParams runs. Props must be a plain object containing JSON-compatible strings, booleans, finite numbers, nulls, arrays and plain objects. Undefined values, functions, bigint, dates, other class instances and cycles are rejected. Validated props are embedded with safe script escaping and passed to the initial render and hydration.
Invalid parameters, output collisions, unsafe paths and props failures abort the export. Query strings and fragments do not create additional output pages; they may appear in redirect destinations. A props result with redirect produces a status: 'redirect' entry in report.json with path, destination and statusCode: 302, without an HTML file. Configure that redirect on the static host yourself until deployment configuration generation is implemented. The example preview does not apply the report's redirects; with clientEntry: true, registered redirect routes receive the CSR entry with HTTP 200.
Languages come from the initialized i18next instance's supportedLngs, excluding cimode. For ['en', 'de'], /articles/:slug produces /en/articles/first and /de/articles/first. Parameters are enumerated once per route, independently of language. Each concrete language path is passed to getIsomorphicProps, and language selection finishes before data loading and rendering. Pages are grouped by language; neutral pages join the fallback-language group. Different languages never run concurrently.
ssg.concurrency limits concurrent props loading, rendering and writing within a language; the default is 4. Set it to 1 for sequential export. It must be a positive safe integer. Each page keeps its own head collection, React state and query client; the CSS manifest remains fixed for the export. Report order is independent of completion order. On failure, no further tasks are started, already running tasks finish, and the previous export is preserved. Keep application-owned mutable state local to each props call/render; use concurrency: 1 if your application requires sequential execution.
Pages with supportsPathLanguages: false render once without a prefix, using the effective fallback language. Without supportedLngs, all routes stay unprefixed and render in that fallback language. The exporter resolves string, array, language-map and function forms of fallbackLng using i18next's resolution against the initial configured language; regional fallbacks are matched to supported languages. With fallbackLng: false, the initialized language is used. Empty or unresolvable fallback configurations fail with a descriptive error. The export embeds initialLanguage to identify the HTML language. The client keeps its own selected language: matching HTML is hydrated; a language mismatch triggers client rendering with the embedded initial props. report.json includes the resolved fallbackLanguage, each concrete page's language, and routes with supportsPathLanguages, including excluded routes. Redirect destinations are kept exactly as returned by the props function; include a language prefix there when required.
The command builds a separate SSG entry and webclient under .ea/ssg, renders complete HTML including head content and CSS, and writes deployable files to .ea/static. React hydrates exported HTML when its language matches the client selection, using its initial props, route and runtime configuration. Otherwise the client renders normally in its own language. Client navigation calls getIsomorphicProps in the browser; these data can therefore be newer than the export. No additional props JSON files are needed. The fallback-language rule applies only to server rendering/export. Neutral pages in the client use the current client language; props are cached by path and are not reloaded solely because the client language changes.
Every export includes 404.html in the fallback language and <language>/404.html for each supported language. The registered ea.setErrorPage() component is used, otherwise the framework's default error page. The error is represented by the public fields of a PageNotFoundError without a stack; the same plain object is used for static rendering and passed to the client through normal initial props handling. No initialError flag or error-class restoration is needed. There is no request path at build time. The prerendered error page remains after hydration; it does not resolve the browser URL into another page. report.json.errorPages records these files and their intended HTTP status 404.
Set ssg.clientEntry: true to also generate spa.html, an empty client entry without initial page metadata. It uses the same HTML template as the client and Capacitor builds and loads /runtime-config.js. The default is false. Generating it does not enable a host fallback: configure the static host to match the registered report.json.routes after checking existing files. Registered routes without an exported file receive this empty CSR entry with HTTP 200; unknown routes receive the prerendered language error page with HTTP 404. With clientEntry: false, all missing page paths receive the prerendered error page with HTTP 404, including registered routes; the preview still starts. Exclusion and missing static parameter combinations do not remove a registered route. With clientEntry: true, the empty entry lets the client render the registered route at the original URL with HTTP 200. With clientEntry: false, the prerendered error page remains. Pages with only getServerProps need a running backend and an appropriate serverAddress (empty means the current origin). A prerendered index.html with its own page props is not a universal client entry. Missing assets, API requests and runtime files must not receive SPA HTML.
Static exports use the existing serviceWorker settings. Assets and public files are precached subject to the configured size limit and existing exclusions; generated SSG HTML, prerendered error pages and report.json are excluded. runtime-config.js keeps its existing NetworkFirst cache. Set serviceWorker.enabled: false to disable the worker.
With the default handleInitialRequest: false, navigation goes to the server, even for previously visited pages. There is no offline HTML fallback. With handleInitialRequest: true, the active worker answers normal navigation with the precached empty spa.html, and the client renders the requested URL online or offline. The first visit without a controlling worker still goes to the server. navigateFallbackDenylist excludes matching navigation requests. The export creates this shell even with ssg.clientEntry: false; this does not enable the server's CSR fallback or change its HTTP statuses. The report records serviceWorker.file and, when enabled, serviceWorker.navigationFallback separately from clientEntry.
The worker is finalized after all export files have been written and before publication. Its update behavior and customization through the existing vite hook are retained. Deploy the contents of .ea/static through a static host and serve unknown paths as HTTP 404 with the matching language error file, or the fallback 404.html. Serve the file internally without redirecting the browser URL. A prefixless request for a language-dependent route needs a deployment redirect; the generated Nginx configuration performs this language selection when language detection is enabled through ssg.nginx.
Set ssg.nginx: true to generate .ea/ssg/nginx/static-routes.conf outside the public output directory and enable language detection. Set ssg.nginx: { languageDetection: false } to generate routing without language detection; false or an omitted nginx disables Nginx output. Include this file inside your existing Nginx server block and set that block's root to the exported directory on your server. Domain, TLS and the deployment root are not generated. The standalone export example provides a Docker Compose preview using this configuration.
The generated rules serve a route such as /de/articles/first internally from de/articles/first/index.html, without a slash or filename redirect. Registered missing routes receive CSR with HTTP 200 when enabled; all other missing page routes receive a translated HTTP 404. Redirects recorded in the report become HTTP 302 responses. Existing public files and assets are served directly; missing assets/API/.well-known files receive a plain 404. Exported error files are internal response targets. spa.html is internal unless the service worker needs to fetch it for its navigation fallback. report.json and direct */index.html requests are not public route aliases.
Route matching uses the original encoded request URI, preserving parameter boundaries independently of Nginx's normalized file lookup. After language selection, supported prefixes select the error-page language. Nginx redirect destinations containing a literal $ are rejected because Nginx interprets those values as variables; percent-encode a literal dollar in the URL as %24. Keep the generated routing configuration together with its matching export when deploying or updating the files.
Both ssg.nginx: true and ssg.nginx: { languageDetection: true } enable language detection. Basic Nginx routing remains usable without njs through ssg.nginx: { languageDetection: false }. When language detection and supported languages are configured, the export also generates .ea/ssg/nginx/static-language.js. Both files must be deployed together. The routing include uses the official Nginx JavaScript module (njs) with the QuickJS engine (js_engine qjs, njs 0.9.1 or newer built with QuickJS support). Install the module for your Nginx distribution, load it in the main configuration before events/http, and point js_path to the deployed directory containing static-language.js. The include belongs inside server; domain, TLS and document root remain your deployment settings. See the module documentation for installation and directive contexts. For example:
# Main context; the module path depends on your installation.
load_module modules/ngx_http_js_module.so;
# Inside your existing server block:
js_path /srv/my-app/nginx;
include /srv/my-app/nginx/static-routes.conf;Language lookup uses the initialized backend i18next detector's actual order, lookupCookie, lookupHeader, lookupFromPathIndex, ignoreCase and standard Iso15897 conversion. Configure the standard getEaLanguageDetector() with an explicit order containing only cookie, header and/or path; its middleware default also contains querystring, which is unsupported. Without a detector the exported fallback language is used. The generated njs module reads cookies, parses weighted headers, checks path segments and decides the language redirect in the configured source order. It bundles the i18next language resolver and an Intl.getCanonicalLocales polyfill for QuickJS, initialized once per JavaScript context with empty translation resources. It performs language resolution only; it does not render React pages or load translations. The module is generated even when the configured order contains only cookies or paths. Malformed cookies allow subsequent sources to run; candidate resolution and source acceptance follow the backend detector’s fallback rules. Header candidates are ordered by quality, preserve ties, and exclude q=0; malformed language ranges/weights are ignored. The existing backend middleware is more permissive and currently includes q=0, so those header inputs deliberately differ. Resolver fallback rejection follows the backend's source-priority rules: for example, a regional candidate resolving only to the configured fallback need not override a later explicit path language.
Language redirects use HTTP 302 with Vary for the configured language header and Cookie, plus Cache-Control: private, no-store. A supported existing prefix is replaced, queries are preserved, and redirect targets are checked again without forming a loop. Unsupported prefixes are kept as route data. Neutral registered routes use the full native route syntax and registration order and never receive a language redirect, including unexported dynamic routes. Existing public files, reserved asset/API/.well-known paths, runtime/report files and internal HTML targets bypass language lookup; missing files outside registered routes do not receive language redirects. The js_set result is cached within a request. The module does not write language cookies; client language selection remains independent.
Export fails explicitly for custom detector implementations/lookup functions, custom header regexes, lookupPath based on Express route params, and custom conversion functions. Fallback strings, arrays and language maps are serialized; fallback functions are evaluated at export time for the supported language decisions and their final fallback, then stored as a language map. They must describe stable language rules, since Nginx does not execute the original function or its closures. nonExplicitSupportedLngs: true and load: 'languageOnly' are rejected because the detector can then select regional URLs absent from the explicit exported language set. Use ssg.nginx: { languageDetection: false } and supply language negotiation rules if these configurations are required.
Configure the output directory when needed:
export default {
pages: './src/pages.tsx',
ssg: { outDir: '.ea/static', clientEntry: false },
};The output may be inside or outside the project; relative paths resolve from the project directory. src and dist are allowed, provided the output does not overlap configured page/server entry files or other protected paths. Public files, internal .ea build directories, project configuration files, .git, and node_modules remain protected. Output paths cannot traverse symlinks or use the filesystem root. Missing output is created when the completed export is published. Existing directories must be empty or contain a valid report.json from a previous export. Files, symlinks and non-empty directories without that report are rejected. Keep report.json with the exported files for subsequent exports. Rendering occurs in a temporary directory beside the output; a failed render preserves the last successful export.
The console and .ea/static/report.json list exported pages, excluded routes with reasons, and language configuration. The report is included as report.json in the output directory, including when ssg.outDir is customized. Internal bundles and Vite manifests remain outside the deployable directory. The name report.json is reserved and cannot be used by public assets or routes. Public runtime values are captured during export, so changing them requires another export. The export command loads production environment files like ea build.
The optional Vite hook receives Target.STATIC for the SSG server entry and Target.CLIENT for its client. Preserve their isolated output directories under .ea/ssg when customizing these builds.
The standalone workspace @ainias42/express-app-export-example demonstrates fixed, encoded, escaped, neutral, dynamic, optional and props-based routes. Run npm test -w @ainias42/express-app-export-example from the repository root to build the framework and execute its isolated export integration tests. See the example README for export and preview instructions.
Compile The Configuration
Compile the TypeScript configuration ahead of deployment:
ea compile-configThis writes a persistent ea.config.js in the current project directory without evaluating the configuration. Environment variables referenced by the configuration are still read when the JavaScript file is loaded at runtime.
Deploy ea.config.js without ea.config.ts to use ea start without a TypeScript compiler. When both files exist, the TypeScript configuration still takes precedence and is compiled on startup.
For ea start, ea build, and ea dev, compiling ea.config.ts replaces an existing ea.config.js and keeps the updated file. If no JavaScript configuration existed beforehand, the generated file is removed after loading, including when compilation or loading fails. Without ea.config.ts, EA loads ea.config.js directly.
Production Environment And Secrets
ea start loads the standard production environment files in this order:
.env.production.local
.env.local
.env.production
.envUse --env-file to load an additional required environment file before the standard files. Existing environment variables are not overwritten when later files are loaded, so values from the additional file take precedence over values from the standard files.
Use the repeatable --secret NAME=PATH option to load individual secret files. Secret files are loaded after all environment files and their values take precedence. One trailing line break is removed from each secret value.
ea start \
--env-file=/run/secrets/app_env \
--secret=DB_PASSWORD=/run/secrets/app_db_password \
--secret=EMAIL_PASSWORD=/run/secrets/smtp_passwordBoth the additional environment file and every specified secret file are required. Omit a --secret option in environments where that secret is not needed. All values are loaded before ea.config.ts is evaluated.
Public Runtime Environment Variables
Environment variables with the PUBLIC_ prefix are available at runtime on the server and in the browser. They are public: every matching value is included in the SSR response and must not contain secrets.
Configure a different prefix or multiple prefixes in ea.config.ts when needed:
export default {
publicEnvPrefix: ['PUBLIC_', 'CLIENT_'],
};Read public runtime variables through the package API instead of import.meta.env:
import { getPublicEnvValue } from '@ainias42/express-app';
const apiUrl = getPublicEnvValue('PUBLIC_API_URL');Use getPublicEnv() to obtain all public runtime variables. Direct accesses such as import.meta.env.PUBLIC_API_URL are not supported because public values are resolved when the app starts, not when the client bundle is built.
The configured prefix is internal build configuration; callers never pass it to getPublicEnv() or getPublicEnvValue().
Use getRuntimeConfigValue() for runtime app configuration:
import { getRuntimeConfigValue } from '@ainias42/express-app';
const appName = getRuntimeConfigValue('name');
const serverAddress = getRuntimeConfigValue('serverAddress');On the server, both values are read directly from _EA_NAME and _EA_SERVER_ADDRESS. In the browser, both values are read from the runtime config.
Runtime Config Delivery
SSR responses include the runtime configuration in window.ea.config and use Cache-Control: private, no-store. The public variables are available under window.ea.config.publicEnv. Static client and PWA HTML loads /runtime-config.js before the application bundle. The Express server serves that URL dynamically and applies staticFilesMaxAge as its cache lifetime. The service worker uses the last successful runtime config while offline and replaces it after a successful online request. When deploying .ea/client through a separate static host, route /runtime-config.js to the Express server.
ea build writes .ea/app/runtime-config.js for Capacitor. It contains the public variables available during the build; an installed Capacitor app does not load container-start-time values.
Capacitor Deeplinks
EA can generate the static association files for iOS Universal Links and Android App Links during ea build.
Configure deeplinks in ea.config.ts:
export default {
serverAddress: 'https://example.com',
capacitor: {
appId: 'com.example.app',
appName: 'Example App',
},
deeplinks: {
enabled: true,
paths: ['*'],
ios: {
teamId: 'ABCDE12345',
},
android: {
sha256CertFingerprints: ['AA:BB:CC:...'],
},
},
};The build writes:
.ea/client/.well-known/apple-app-site-association
.ea/client/.well-known/assetlinks.json
.ea/app/.well-known/apple-app-site-association
.ea/app/.well-known/assetlinks.jsonDeploy the .ea/client files with your website. They must be reachable through HTTPS, for example:
https://example.com/.well-known/apple-app-site-association
https://example.com/.well-known/assetlinks.jsonServe both files without redirects. For nginx, make sure apple-app-site-association is served with a JSON content type even though it has no .json extension.
The Capacitor runtime uses @capacitor/app to handle cold-start and already-running deeplink opens.
iOS Native Setup
Universal Links also require an Associated Domains entitlement in the native iOS app.
- Open the Capacitor iOS project in Xcode.
- Select the app target.
- Open
Signing & Capabilities. - Add the
Associated Domainscapability. - Add your domain in this format:
applinks:example.comUse the host from serverAddress, without https:// and without a port.
Android Native Setup
Android App Links also require an intent filter in android/app/src/main/AndroidManifest.xml.
Add it inside the main activity:
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="example.com" />
</intent-filter>Use the host from serverAddress, without https:// and without a port.
The sha256CertFingerprints value should be the SHA-256 fingerprint of the signing certificate used for the Android app.
