Skip to content

Bring Remix v3 support to server-first Palamedes parity #353

Description

@swernerx

Context

PR #350 merged the first experimental Remix v3 integration:

  • @palamedes/remix with a Node registerHooks() loader for server-loaded JS/TS modules
  • request-local i18n scope for Remix handlers
  • examples/remix-cookie with cookie locale switching, Accept-Language negotiation, catalog loading, and matrix smoke coverage

That is a strong server-side candidate. This issue tracks the next step: bring Remix v3 to server-first Palamedes parity where Remix's beta architecture allows it, while making the remaining UI/client boundaries explicit.

Refs #284
Follow-up to #350

Goal

Make Remix v3 a first-class server-side Palamedes integration:

  • normal catalog files and extraction workflow
  • reusable Remix server API instead of per-example wiring
  • standard Palamedes locale strategy examples
  • clear documentation for supported macros, unsupported UI/client paths, and Remix beta tracking

Full client-side feature parity is not yet a realistic promise because Remix v3 browser-delivered modules currently go through Remix's asset compiler, not the Node registerHooks() chain. This issue should make that boundary visible and start the upstream/API work needed to remove it.

Spikes Before Implementation

Two risks are cheap to check up front and would change the design if they fail. Run these before the corresponding slice.

Spike 1: .po imports through the real Remix loader chain (gates Slice A)

Claiming .po URLs in the load hook is expected to work — Node's resolver is extension-agnostic, and the default loader never sees the file because the hook returns the module itself. But it has not been proven inside a real remix/node-tsx chain. Half a day, throwaway scaffold:

  • import { messages } from "./locales/de.po" resolves and executes under node --import remix/node-tsx --import @palamedes/remix/register.
  • node --watch (the dev script) picks up .po edits, i.e. catalog files enter the watched module graph.
  • tsc --noEmit passes with the po.d.ts declaration pattern the other examples use.

Spike 2: request scope vs. streamed rendering (gates the Slice B API shape)

The middleware plan wraps next() in scope.run(). That covers everything that executes before the Response is returned. If remix/ui/server renders lazily into the response body stream — and Frames fragments likely use the same path — translated code may run while the body is being consumed, after run() already resolved and potentially outside the AsyncLocalStorage context. If that context is lost, getI18n() silently resolves nothing and the middleware API has to look different.

  • Verify getI18n() resolves correctly inside a streamed remix/ui/server render wrapped by the middleware.
  • Repeat the check for a Frames-style fragment response as soon as that API is testable.
  • If the context is lost: decide between rendering eagerly before returning the Response, binding the scope into the stream explicitly (AsyncResource-style capture), or resolving the i18n instance per request and passing it explicitly. This decision defines the public middleware API, which is why it must precede Slice B.

Known sharp edges (decide, no spike needed)

  • CJS handling in the register hook: the injected runtime import is ESM syntax, but the default include also matches .cjs and CommonJS-format modules. Either narrow the default or emit a CJS-compatible binding.
  • Per-request catalog cost: the current example builds and loads a fresh i18n instance per request. The Slice B server API should cache compiled catalogs per locale at module scope so per-request work is activation only.

Slice A: Catalog Workflow And Loader Support

Start here after Spike 1. This is low risk and removes the most fragile part of the current example.

Design decision

The normal Palamedes workflow imports checked-in catalog files:

import { messages } from "../locales/de.po"

Remix v3 has no bundler plugin in its default path, so the natural parity path is for the @palamedes/remix register hook to claim .po URLs and compile them through the existing catalog loading/rendering machinery in @palamedes/transform. Node cannot load .po files by default, so this hook does not need to delegate to nextLoad() for catalog files.

Work

  • Add .po loading support to @palamedes/remix/register.
  • Reuse the existing catalog loader/rendering logic from @palamedes/transform.
  • Add palamedes.yaml to examples/remix-cookie.
  • Replace or supplement the inline demo catalogs in examples/remix-cookie with checked-in catalog files.
  • Ensure pmds extract can discover Remix example messages.
  • Document the Remix catalog import/loading path in @palamedes/remix docs.

Out of scope for this issue

Changing core fallback behavior when message ids drift is not Remix-specific. pmds audit and reporting are the right tooling surface for that; open a separate core/tooling issue if that behavior needs to change.

Slice B: First-Class Remix Server API, Then Strategy Examples

This must land before adding more Remix examples. Otherwise the route/subdomain/tld examples will copy the current manual wiring and immediately become refactor debt.

The middleware shape depends on the outcome of Spike 2 above — do not finalize the public API before that spike has an answer.

Server API

  • Add reusable @palamedes/remix/server primitives for locale negotiation from Request.
  • Build on Remix v3 primitives where possible:
    • AcceptLanguage from remix/headers
    • fetch-router middleware scopes and typed context
    • @remix-run/async-context-middleware, which uses the same AsyncLocalStorage primitive as Palamedes' server scope
  • Provide a middleware-style API that wraps next() in the active i18n request scope.
  • Provide cookie helpers or cookie middleware that fits Remix v3 Fetch handlers.
  • Keep the API compatible with Remix v3's beta Fetch/router model.

Locale strategy matrix

After the server API exists, add Remix v3 examples and smoke coverage for the standard Palamedes locale strategies:

  • examples/remix-cookie
  • examples/remix-route
  • examples/remix-subdomain
  • examples/remix-tld

Each example should be represented in scripts/example-matrix.mjs with targeted smoke checks.

Route, subdomain, and TLD should be server-side straightforward in Remix v3: route prefix via fetch-router and host-based locale resolution from request headers. The examples should showcase the new server API rather than duplicating manual setup.

Example UI note

The existing shared @palamedes/example-ui package is React-based and not directly usable for Remix v3's component model. Either:

  • add a small shared Remix example helper for repeated HTML/UI fragments, or
  • explicitly document in examples/README.md that Remix examples intentionally do not visually match the React/Solid/etc. matrix until a Remix UI adapter exists.

Slice C: UI Adapter, Rich Messages, Client Boundary, And Frames

This is research-heavy and should not block Slice A or Slice B.

Remix UI adapter and rich JSX messages

The transform options from #350 remain valid, but rich messages need a render target. A <Trans> equivalent requires an adapter for Remix v3's own component model.

  • Add a time-boxed spike for a remix/ui adapter: a Trans equivalent in Remix's component model.
  • Decide whether Palamedes should:
    • run before Remix's JSX lowering and own the relevant app-file TS/JSX pipeline, or
    • recognize Remix-lowered JSX runtime calls, or
    • expose a Remix-specific rich-message API.
  • Account for churn in @remix-run/node-tsx while it is still 0.1.x.
  • Account for behavior drift between Palamedes' Rust OXC path and Remix's npm oxc-transform path if Palamedes owns more of the loader pipeline.
  • If the own-the-pipeline option is chosen, the spike must prove tsconfig-driven JSX options and the inline sourcemap chain match node-tsx behavior for files Palamedes does not touch.

Acceptance for this area should be a working rich text example equivalent to the existing React/Solid story where Remix's component model allows it. If that is not possible yet, document the unsupported behavior explicitly.

Browser/client asset boundary

Research from #350 indicates Remix v3's asset server currently has no script transform hook. The practical options are wrapping createAssetServer, proxying in front of it, or getting an upstream extension point.

  • Open an upstream Remix issue/discussion requesting a transform extension point in the asset pipeline.
  • Track whether a first-party hook lands before Remix v3 stable.
  • If a hook becomes available, add client-side macro transform support.
  • If not, document the server-only boundary clearly in package docs and examples.
  • Add tests or examples that make the boundary explicit.

Frames and partial updates

Frames should be cheap once Remix documents and exports the relevant remix/ui Frames API: they are server-rendered fragments, so the request-local i18n scope should apply the same way as full document requests.

  • Start this only once remix/ui documents and exports Frames.
  • Add a small frame or partial-update route to a Remix example.
  • Verify request-local locale and catalog state in frame requests.
  • Add a smoke or unit test proving frame responses render with the same i18n state as full document responses.

Beta Tracking Policy

The example is pinned exactly to the tested Remix beta. Keep that policy.

  • Keep example dependencies pinned to exact Remix beta versions.
  • Define who/what bumps the examples to beta.6+.
  • Add a non-blocking canary smoke against remix@next to detect loader or asset pipeline churn early.
  • Document the tested Remix beta version in package docs.

Documentation And Positioning

  • Update packages/remix/README.md from experimental-first-path language to a clear server-first support matrix.
  • Update docs/api/remix.md with the final setup path.
  • Document supported macros, unsupported macros, server/client boundaries, tested Remix beta version, and migration notes.
  • Keep the first-mover positioning accurate: server-side Remix v3 support is available now; full UI/client parity depends on the items above and upstream Remix asset pipeline support.

Acceptance Criteria

  • Remix catalog imports work through the default Remix v3 Node loader path.
  • Remix examples use checked-in catalog files and the normal extraction workflow.
  • A reusable @palamedes/remix/server middleware/helper API exists and is used by the examples.
  • Remix examples cover cookie, route, subdomain, and TLD strategies, or document any intentionally unsupported strategy.
  • pnpm verify:examples:smoke -- --framework remix passes.
  • Rich message behavior is implemented or explicitly documented as unsupported with a concrete follow-up.
  • Server/client transform boundaries are documented and covered by tests or examples.
  • A Remix upstream issue/discussion exists for the asset pipeline transform hook.
  • Package docs describe a stable server-first Remix setup path.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions