@camada/remix 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -81,8 +81,9 @@ fill one event queue. Two mounts with different keys or URLs each get their own.
81
81
  before `next()`; the event still ships, with `st: 403` and `blk: <reason>` so the analyst
82
82
  counts SDK blocks apart from your own 403s.
83
83
  5. **Skip** → a skip rule or the allow list wins over a wider block.
84
- 6. **Challenge** → a `403` proof-of-work page for HTML navigations, `403
85
- {"error":"challenge_required"}` for API and `fetch()` calls; `POST /_cam/challenge` verifies
84
+ 6. **Challenge** → a `403` proof-of-work page for HTML navigations; API and
85
+ `fetch()` calls pass to your app with `x-camada-challenge: 1` on the response (and `chs: 1` on the
86
+ event) so your frontend can choose to challenge; `POST /_cam/challenge` verifies
86
87
  the work, sets `_cch` and 302s back. A client-side navigation (React Router fetching
87
88
  `<path>.data` for a `<Link>`) is answered in React Router's own redirect form instead
88
89
  (`204` + `X-Remix-Redirect` to the same page + `X-Remix-Reload-Document`), so the browser
package/dist/index.cjs CHANGED
@@ -58,7 +58,7 @@ var scriptTag = (context) => (0, import_fetch.scriptTag)(readSlot(context));
58
58
 
59
59
  // package.json
60
60
  var name = "@camada/remix";
61
- var version = "0.1.4";
61
+ var version = "0.2.0";
62
62
 
63
63
  // src/version.ts
64
64
  var SDK_ID = `${name}/${version}`;
@@ -106,11 +106,13 @@ function underBasename(path, basename) {
106
106
  if (path === base) return "/";
107
107
  return path.startsWith(base + "/") ? path.slice(base.length) : null;
108
108
  }
109
+ var isDataPath = (pathname) => pathname.endsWith(".data");
110
+ var isDataRequest = (req) => isDataPath(new URL(req.url).pathname);
109
111
  function forNavigation(req, res, basename) {
110
112
  return (0, import_core2.guarded)(() => {
111
113
  if (res.status !== 403 || res.headers.get("x-camada-challenge") !== "1") return res;
112
114
  const url = new URL(req.url);
113
- if (!url.pathname.endsWith(".data")) return res;
115
+ if (!isDataPath(url.pathname)) return res;
114
116
  url.pathname = url.pathname.endsWith("/_.data") ? url.pathname.slice(0, -"_.data".length) : url.pathname.endsWith("/_root.data") ? url.pathname.slice(0, -"_root.data".length) : url.pathname.slice(0, -".data".length);
115
117
  url.searchParams.delete("_routes");
116
118
  let to = underBasename(url.pathname, basename) ?? "/";
@@ -158,7 +160,7 @@ function fromReferer(req, origin, basename) {
158
160
  function camada(opts = {}) {
159
161
  const cam = instanceFor(opts);
160
162
  return async ({ request, context }, next) => {
161
- const r = await cam.before(request, requestContext(context));
163
+ const r = await cam.before(request, { ...requestContext(context), navigation: isDataRequest(request) });
162
164
  if (!r) return next();
163
165
  if (r.response) return answered(request, context, r.response, opts.basename);
164
166
  (0, import_core2.guarded)(() => context.set(camadaContext, r.vars), void 0);
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/camada.ts","../src/context.ts","../package.json","../src/version.ts","../src/route.ts"],"sourcesContent":["// @camada/remix — the install for a React Router framework-mode (Remix) app:\n// export const middleware = [camada()]; // app/root.tsx; env: CAMADA_KEY (+ CAMADA_INGEST_URL in dev)\n// route('/_cam/*', 'routes/cam.tsx') // routes.ts; the file re-exports camadaRoute()\n// scriptTag(context) // in a loader: the first-party beacon tag\n// track(context, 'login_failed', { user }) // in an action: an outcome the wire cannot show\nexport { camada, resetCamada, type CamadaRemixOptions, type CamadaRemixVars } from './camada.js';\nexport { camadaRoute } from './route.js';\nexport { track, scriptTag, camadaPeerContext, camadaResponseContext } from './context.js';\n","// @camada/remix — the root server middleware for React Router framework mode (SDK-G04):\n// export const middleware = [camada()]; // app/root.tsx\n// A thin binding over @camada/core/fetch: the peer is whatever a custom server put in\n// `camadaPeerContext` (React Router itself vouches for no socket), the env is process.env, and\n// there is no waitUntil — the host is a long-lived Node server or an edge runtime that keeps\n// its own isolate open. A camada bug, a dead ingest or a corrupt snapshot costs telemetry,\n// never the app's response.\nimport iife from '@camada/browser/iife-string';\nimport { guarded, safeReturnTo, TAP_REMIX } from '@camada/core';\nimport { createFetchCamada, withSetCookie, withRid, type FetchCamada, type FetchCamadaOptions, type FetchRequestContext, type FetchVars } from '@camada/core/fetch';\nimport type { MiddlewareFunction } from 'react-router';\nimport { camadaContext, camadaPeerContext, camadaResponseContext, type RouterContext } from './context.js';\nimport { SDK_ID } from './version.js';\n\nexport interface CamadaRemixOptions extends FetchCamadaOptions {\n /** The app's React Router `basename` (react-router.config.ts), when it has one. The client puts\n * it back in front of a redirect, so a challenged data request's `X-Remix-Redirect` goes out\n * without it. */\n basename?: string;\n}\nexport type CamadaRemixVars = FetchVars;\n\n// camada() and camadaRoute() must see one engine for the same options, or the route would poll\n// and enforce a second copy of the tenant's snapshot: an instance is found by its serialisable\n// options (keys sorted, so two modules spelling them in a different order agree); fetchImpl\n// cannot be stringified, so it keys a bucket by identity.\nconst instances = new Set<FetchCamada>();\nconst byKey = new Map<typeof fetch | undefined, Map<string, FetchCamada>>();\n\n// Sorts the keys of every object on the way in, so key order never splits one configuration in two.\nconst sortedJson = (v: unknown): string =>\n JSON.stringify(v, (_, x: unknown) => (x && typeof x === 'object' && !Array.isArray(x) ? Object.fromEntries(Object.entries(x).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) : x));\n\n/** The directory of the beacon script, `/_cam/` by default: a path in it with `name` last. */\nexport const inScriptDir = (scriptPath: string | undefined, name: string): string => (scriptPath ?? '/_cam/b.js').replace(/[^/]*$/, name);\n\nexport function instanceFor(given: CamadaRemixOptions = {}): FetchCamada {\n // React Router runs middleware only for a matched route, and the beacon route (`/_cam/*` in the\n // README) is the one camada can count on: the challenge form posts into that directory by\n // default (`/_cam/challenge`, or next to a custom `scriptPath`), where camadaRoute() matches and\n // the root middleware answers. Core's own default, `/__camada/challenge`, would 404 in the\n // router before camada ever ran (camada-all-zhoc).\n const { basename, ...core } = given; // the adapter's own option: not core's, and part of the engine's identity only through the paths it moves\n // Under a `basename` the router strips it before matching, so the endpoints' URLs (what the\n // browser requests and core compares) carry it: the defaults live under it (camada-all-xms0).\n const base = (basename ?? '').replace(/\\/+$/, '');\n const scriptPath = core.scriptPath ?? (base ? `${base}/_cam/b.js` : undefined);\n const fpPath = core.fpPath ?? inScriptDir(scriptPath, 'fp'); // the script posts next to itself (auto.ts derives it): `/_cam/fp` by default\n const opts = {\n ...core,\n ...(scriptPath ? { scriptPath } : {}),\n fpPath,\n challengePath: core.challengePath ?? inScriptDir(scriptPath, 'challenge'),\n };\n const { fetchImpl, ...serialisable } = opts;\n const key = sortedJson(serialisable);\n let bucket = byKey.get(fetchImpl);\n if (!bucket) byKey.set(fetchImpl, (bucket = new Map()));\n let cam = bucket.get(key);\n if (!cam) {\n cam = createFetchCamada({ tap: TAP_REMIX, sdk: SDK_ID, iife }, opts);\n bucket.set(key, cam);\n instances.add(cam);\n }\n return cam;\n}\n\n/** Test/reset hook: stops and drops every engine this module created; the instances stay wired and rebuild lazily. */\nexport function resetCamada(): void {\n for (const cam of instances) cam.reset();\n}\n\n/** What the host can vouch for: the peer a custom server set, and the process env. The peer read\n * is guarded — a 7.9 app without the middleware flag hands a plain object; an edge runtime has no process. */\nexport function requestContext(context: RouterContext): FetchRequestContext {\n return {\n peer: guarded(() => context.get(camadaPeerContext), null),\n env: globalThis.process?.env,\n };\n}\n\n/** `path` without the router's basename, or null when it lies outside it. */\nfunction underBasename(path: string, basename: string | undefined): string | null {\n const base = (basename ?? '').replace(/\\/+$/, '');\n if (!base) return path;\n if (path === base) return '/';\n return path.startsWith(base + '/') ? path.slice(base.length) : null;\n}\n\n/** A challenged client-side navigation: React Router's client fetches `<path>.data` (`/_.data` for a\n * path ending in `/`) and throws any 4xx without `X-Remix-Response` into the route's error\n * boundary, so core's 403 JSON would show the app's error page, never the challenge. Answer it\n * in React Router's single-fetch redirect form instead: 204 + `X-Remix-Redirect` +\n * `X-Remix-Reload-Document`, which the client turns into a document navigation\n * (`window.location.assign`) that meets the HTML challenge (camada-all-wjln). A GET reloads the\n * page itself. A non-GET (`fetcher.submit`, `fetcher.load` of a resource route, `<Form\n * action=/other>`) targets a route that may have no page, so it reloads the same-origin page it\n * came from (the Referer), else the data path. The location goes out without the router's\n * basename, which the client adds back. Any other answer, and every request that is not a\n * `.data` fetch (an API call from app code), is left as it is. */\nexport function forNavigation(req: Request, res: Response, basename?: string): Response {\n return guarded(() => {\n if (res.status !== 403 || res.headers.get('x-camada-challenge') !== '1') return res;\n const url = new URL(req.url);\n if (!url.pathname.endsWith('.data')) return res;\n url.pathname = url.pathname.endsWith('/_.data') ? url.pathname.slice(0, -'_.data'.length)\n : url.pathname.endsWith('/_root.data') ? url.pathname.slice(0, -'_root.data'.length) // Remix v2's spelling of the root\n : url.pathname.slice(0, -'.data'.length);\n url.searchParams.delete('_routes');\n let to = underBasename(url.pathname, basename) ?? '/';\n to = safeReturnTo(to + url.search);\n if (req.method !== 'GET' && req.method !== 'HEAD') to = fromReferer(req, url.origin, basename) ?? to;\n void res.body?.cancel().catch(() => {});\n return new Response(null, {\n status: 204,\n headers: {\n 'X-Remix-Redirect': to, 'X-Remix-Status': '302', 'X-Remix-Reload-Document': 'true',\n 'cache-control': 'no-store', 'x-camada-challenge': '1',\n },\n });\n }, res);\n}\n\n/** camada's own answer (block, challenge, verify, beacon), in the form the router sends. Where a custom\n * server passed its Node response in `camadaResponseContext`, the headers that answer sets are\n * removed from it first: the Express adapter appends, so an upstream policy or cache header\n * would otherwise go out next to the answer's own (R7-2). Set-Cookie is the app's and stays. */\nexport function answered(req: Request, context: RouterContext, res: Response, basename?: string): Response {\n const out = forNavigation(req, res, basename);\n guarded(() => {\n const node = context.get(camadaResponseContext);\n if (!node || typeof node.removeHeader !== 'function') return;\n for (const k of out.headers.keys()) if (k !== 'set-cookie') node.removeHeader(k);\n if (out.headers.has('content-security-policy')) node.removeHeader('content-security-policy-report-only');\n }, undefined);\n return out;\n}\n\n/** The same-origin Referer's path and query (without the basename), when it is a safe return target. */\nfunction fromReferer(req: Request, origin: string, basename: string | undefined): string | null {\n const raw = req.headers.get('referer');\n if (!raw) return null;\n let ref: URL;\n try { ref = new URL(raw); } catch { return null; }\n if (ref.origin !== origin) return null;\n const path = underBasename(ref.pathname, basename);\n if (path === null) return null;\n const to = path + ref.search;\n return safeReturnTo(to) === to ? to : null;\n}\n\nexport function camada(opts: CamadaRemixOptions = {}): MiddlewareFunction<Response> {\n const cam = instanceFor(opts);\n return async ({ request, context }, next) => {\n const r = await cam.before(request, requestContext(context));\n if (!r) return next();\n if (r.response) return answered(request, context, r.response, opts.basename); // block, challenge, verify, beacon: answered without the app\n guarded(() => context.set(camadaContext, r.vars), undefined);\n let res: Response;\n try {\n res = await next();\n } catch (err) {\n // A thrown Response (a redirect from a downstream middleware) is the answer, so it carries\n // the status and the cookie; anything else lands in the router's error boundary as a 500.\n if (err instanceof Response) {\n cam.after(request, r.vars, err.status);\n throw withRid(r.vars.sessionCookie ? withSetCookie(err, r.vars.sessionCookie) : err, r.vars);\n }\n cam.after(request, r.vars, 500);\n throw err;\n }\n return cam.finish(request, r.vars, r.vars.sessionCookie ? withSetCookie(res, r.vars.sessionCookie) : res); // an SSE body ships once it has gone out; anything else now\n };\n}\n","// Per-request state the middleware leaves on React Router's context for the two helpers an app\n// calls from its own loaders and actions: `scriptTag(context)` (the beacon tag) and\n// `track(context, event, data)` (an app-context outcome). When the middleware did not run for\n// this request (no key, CAMADA_DISABLED, or a request it answered itself) the slot is empty and\n// both helpers stand down.\nimport { createContext, type RouterContextProvider } from 'react-router';\nimport { guarded } from '@camada/core';\nimport { track as coreTrack, scriptTag as coreScriptTag, type FetchVars } from '@camada/core/fetch';\n\n// Defaults are null, not undefined: React Router's `get()` throws on a context that has neither\n// a value nor a default, and the helpers must stand down silently where the middleware did not run.\n/** The vars the middleware kept for this request; null where it did not run. Private to the package: track() and scriptTag() are the API. */\nexport const camadaContext = createContext<FetchVars | null>(null);\n\n/** The socket address a custom server vouches for (set from `getLoadContext`); null = nothing vouched. */\nexport const camadaPeerContext = createContext<string | null>(null);\n\n/** The Node response a custom server hands camada (Express's `res`, set from `getLoadContext(req, res)`).\n * @react-router/express writes an answer's headers with `res.append`, so a header an Express\n * middleware already set (helmet's Content-Security-Policy) would go out twice; with this, camada\n * removes the ones its own answer replaces first. Null = nothing handed over. */\nexport const camadaResponseContext = createContext<{ removeHeader(name: string): void } | null>(null);\n\n/** Loaders, actions and middleware all receive the same read-only provider. */\nexport type RouterContext = Readonly<RouterContextProvider>;\n\nconst readSlot = (context: RouterContext): FetchVars | undefined => guarded(() => context.get(camadaContext) ?? undefined, undefined);\n\n/**\n * Records an outcome the app knows and the wire cannot show: `login_failed`, `login_succeeded`,\n * `signup`, `password_reset`, `mfa_failed`, `payment_failed`, `payment_succeeded`, `coupon_failed`\n * (free-form; that vocabulary is what the analyst's rules read). Joined to this request's event\n * through its rid and session; the user identifier is HMAC-hashed in-process. Never throws,\n * never rejects, and is a no-op where the middleware did not run.\n */\nexport const track = (context: RouterContext, event: string, data?: { user?: string }): Promise<void> => coreTrack(readSlot(context), event, data);\n\n/** The `<script>` tag for an HTML response — `''` when camada is off for this request or the tenant turned the beacon off. */\nexport const scriptTag = (context: RouterContext): string => coreScriptTag(readSlot(context));\n","{\n \"name\": \"@camada/remix\",\n \"version\": \"0.1.4\",\n \"license\": \"MIT\",\n \"type\": \"module\",\n \"engines\": {\n \"node\": \">=20\"\n },\n \"files\": [\n \"dist\",\n \"README.md\",\n \"LICENSE\"\n ],\n \"scripts\": {\n \"build\": \"tsup\",\n \"dev\": \"tsup --watch\",\n \"test\": \"vitest run\",\n \"check\": \"tsc --noEmit\",\n \"check:readme-build\": \"scripts/readme-build-check.sh\"\n },\n \"description\": \"camada SDK for React Router framework mode (Remix): snapshot enforcement in a root middleware, first-party challenge and beacon route, track() outcomes\",\n \"exports\": {\n \".\": {\n \"import\": {\n \"types\": \"./dist/index.d.ts\",\n \"default\": \"./dist/index.js\"\n },\n \"require\": {\n \"types\": \"./dist/index.d.cts\",\n \"default\": \"./dist/index.cjs\"\n }\n }\n },\n \"main\": \"./dist/index.cjs\",\n \"module\": \"./dist/index.js\",\n \"types\": \"./dist/index.d.ts\",\n \"dependencies\": {\n \"@camada/browser\": \"file:../camada-browser\",\n \"@camada/core\": \"file:../camada-core\"\n },\n \"peerDependencies\": {\n \"react-router\": \">=7.9\"\n },\n \"devDependencies\": {\n \"@react-router/express\": \"^8.3.1\",\n \"@types/express\": \"^5.0.6\",\n \"@types/node\": \"^22.10.0\",\n \"express\": \"^5.3.0\",\n \"helmet\": \"^8.3.0\",\n \"react-router\": \"^8.3.1\",\n \"tsup\": \"^8.3.5\",\n \"typescript\": \"^5.7.2\",\n \"vitest\": \"^3.0.0\"\n }\n}\n","// The SDK's wire identity (SDK-03): x-camada-sdk: <package>/<version>. Named JSON imports are\n// inlined and tree-shaken by tsup at build, so dist carries two literals — no runtime fs read.\nimport { name, version } from '../package.json';\nexport const SDK_ID = `${name}/${version}`;\n","// The resource route for the beacon endpoints (routes.ts: route('/_cam/*', 'routes/cam.tsx')).\n// React Router runs middleware only for a matched route, so without a route under /_cam/* the\n// root middleware never sees the script or the relay and the router 404s them — the same reason\n// @camada/next has camadaRoute(). before() re-runs the verdict, so a blocked client gets its\n// 403 here too, and with the root middleware mounted it answers first and this never runs.\nimport type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';\nimport { instanceFor, requestContext, answered, type CamadaRemixOptions } from './camada.js';\n\ntype Handler<A> = (args: A) => Promise<Response>;\n\nexport function camadaRoute(opts: CamadaRemixOptions = {}): { loader: Handler<LoaderFunctionArgs>; action: Handler<ActionFunctionArgs> } {\n const cam = instanceFor(opts);\n const answer: Handler<LoaderFunctionArgs | ActionFunctionArgs> = async ({ request, context }) => {\n const r = await cam.before(request, requestContext(context));\n return r?.response ? answered(request, context, r.response, opts.basename) : new Response(null, { status: 404 }); // not a beacon path, or camada inert: nothing to serve\n };\n return { loader: answer, action: answer };\n}\n"],"mappings":"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","names":["import_core","import_fetch","coreTrack","coreScriptTag","name","iife"]}
1
+ {"version":3,"sources":["../src/index.ts","../src/camada.ts","../src/context.ts","../package.json","../src/version.ts","../src/route.ts"],"sourcesContent":["// @camada/remix — the install for a React Router framework-mode (Remix) app:\n// export const middleware = [camada()]; // app/root.tsx; env: CAMADA_KEY (+ CAMADA_INGEST_URL in dev)\n// route('/_cam/*', 'routes/cam.tsx') // routes.ts; the file re-exports camadaRoute()\n// scriptTag(context) // in a loader: the first-party beacon tag\n// track(context, 'login_failed', { user }) // in an action: an outcome the wire cannot show\nexport { camada, resetCamada, type CamadaRemixOptions, type CamadaRemixVars } from './camada.js';\nexport { camadaRoute } from './route.js';\nexport { track, scriptTag, camadaPeerContext, camadaResponseContext } from './context.js';\n","// @camada/remix — the root server middleware for React Router framework mode (SDK-G04):\n// export const middleware = [camada()]; // app/root.tsx\n// A thin binding over @camada/core/fetch: the peer is whatever a custom server put in\n// `camadaPeerContext` (React Router itself vouches for no socket), the env is process.env, and\n// there is no waitUntil — the host is a long-lived Node server or an edge runtime that keeps\n// its own isolate open. A camada bug, a dead ingest or a corrupt snapshot costs telemetry,\n// never the app's response.\nimport iife from '@camada/browser/iife-string';\nimport { guarded, safeReturnTo, TAP_REMIX } from '@camada/core';\nimport { createFetchCamada, withSetCookie, withRid, type FetchCamada, type FetchCamadaOptions, type FetchRequestContext, type FetchVars } from '@camada/core/fetch';\nimport type { MiddlewareFunction } from 'react-router';\nimport { camadaContext, camadaPeerContext, camadaResponseContext, type RouterContext } from './context.js';\nimport { SDK_ID } from './version.js';\n\nexport interface CamadaRemixOptions extends FetchCamadaOptions {\n /** The app's React Router `basename` (react-router.config.ts), when it has one. The client puts\n * it back in front of a redirect, so a challenged data request's `X-Remix-Redirect` goes out\n * without it. */\n basename?: string;\n}\nexport type CamadaRemixVars = FetchVars;\n\n// camada() and camadaRoute() must see one engine for the same options, or the route would poll\n// and enforce a second copy of the tenant's snapshot: an instance is found by its serialisable\n// options (keys sorted, so two modules spelling them in a different order agree); fetchImpl\n// cannot be stringified, so it keys a bucket by identity.\nconst instances = new Set<FetchCamada>();\nconst byKey = new Map<typeof fetch | undefined, Map<string, FetchCamada>>();\n\n// Sorts the keys of every object on the way in, so key order never splits one configuration in two.\nconst sortedJson = (v: unknown): string =>\n JSON.stringify(v, (_, x: unknown) => (x && typeof x === 'object' && !Array.isArray(x) ? Object.fromEntries(Object.entries(x).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) : x));\n\n/** The directory of the beacon script, `/_cam/` by default: a path in it with `name` last. */\nexport const inScriptDir = (scriptPath: string | undefined, name: string): string => (scriptPath ?? '/_cam/b.js').replace(/[^/]*$/, name);\n\nexport function instanceFor(given: CamadaRemixOptions = {}): FetchCamada {\n // React Router runs middleware only for a matched route, and the beacon route (`/_cam/*` in the\n // README) is the one camada can count on: the challenge form posts into that directory by\n // default (`/_cam/challenge`, or next to a custom `scriptPath`), where camadaRoute() matches and\n // the root middleware answers. Core's own default, `/__camada/challenge`, would 404 in the\n // router before camada ever ran (camada-all-zhoc).\n const { basename, ...core } = given; // the adapter's own option: not core's, and part of the engine's identity only through the paths it moves\n // Under a `basename` the router strips it before matching, so the endpoints' URLs (what the\n // browser requests and core compares) carry it: the defaults live under it (camada-all-xms0).\n const base = (basename ?? '').replace(/\\/+$/, '');\n const scriptPath = core.scriptPath ?? (base ? `${base}/_cam/b.js` : undefined);\n const fpPath = core.fpPath ?? inScriptDir(scriptPath, 'fp'); // the script posts next to itself (auto.ts derives it): `/_cam/fp` by default\n const opts = {\n ...core,\n ...(scriptPath ? { scriptPath } : {}),\n fpPath,\n challengePath: core.challengePath ?? inScriptDir(scriptPath, 'challenge'),\n };\n const { fetchImpl, ...serialisable } = opts;\n const key = sortedJson(serialisable);\n let bucket = byKey.get(fetchImpl);\n if (!bucket) byKey.set(fetchImpl, (bucket = new Map()));\n let cam = bucket.get(key);\n if (!cam) {\n cam = createFetchCamada({ tap: TAP_REMIX, sdk: SDK_ID, iife }, opts);\n bucket.set(key, cam);\n instances.add(cam);\n }\n return cam;\n}\n\n/** Test/reset hook: stops and drops every engine this module created; the instances stay wired and rebuild lazily. */\nexport function resetCamada(): void {\n for (const cam of instances) cam.reset();\n}\n\n/** What the host can vouch for: the peer a custom server set, and the process env. The peer read\n * is guarded — a 7.9 app without the middleware flag hands a plain object; an edge runtime has no process. */\nexport function requestContext(context: RouterContext): FetchRequestContext {\n return {\n peer: guarded(() => context.get(camadaPeerContext), null),\n env: globalThis.process?.env,\n };\n}\n\n/** `path` without the router's basename, or null when it lies outside it. */\nfunction underBasename(path: string, basename: string | undefined): string | null {\n const base = (basename ?? '').replace(/\\/+$/, '');\n if (!base) return path;\n if (path === base) return '/';\n return path.startsWith(base + '/') ? path.slice(base.length) : null;\n}\n\n/** Whether `pathname` is React Router's client fetching `<path>.data` (any method). */\nconst isDataPath = (pathname: string): boolean => pathname.endsWith('.data');\nconst isDataRequest = (req: Request): boolean => isDataPath(new URL(req.url).pathname);\n\n/** A challenged client-side navigation: React Router's client fetches `<path>.data` (`/_.data` for a\n * path ending in `/`) and throws any 4xx without `X-Remix-Response` into the route's error\n * boundary, so core's 403 JSON would show the app's error page, never the challenge. Answer it\n * in React Router's single-fetch redirect form instead: 204 + `X-Remix-Redirect` +\n * `X-Remix-Reload-Document`, which the client turns into a document navigation\n * (`window.location.assign`) that meets the HTML challenge (camada-all-wjln). A GET reloads the\n * page itself. A non-GET (`fetcher.submit`, `fetcher.load` of a resource route, `<Form\n * action=/other>`) targets a route that may have no page, so it reloads the same-origin page it\n * came from (the Referer), else the data path. The location goes out without the router's\n * basename, which the client adds back. Any other answer, and every request that is not a\n * `.data` fetch, is left as it is. */\nexport function forNavigation(req: Request, res: Response, basename?: string): Response {\n return guarded(() => {\n if (res.status !== 403 || res.headers.get('x-camada-challenge') !== '1') return res;\n const url = new URL(req.url);\n if (!isDataPath(url.pathname)) return res;\n url.pathname = url.pathname.endsWith('/_.data') ? url.pathname.slice(0, -'_.data'.length)\n : url.pathname.endsWith('/_root.data') ? url.pathname.slice(0, -'_root.data'.length) // Remix v2's spelling of the root\n : url.pathname.slice(0, -'.data'.length);\n url.searchParams.delete('_routes');\n let to = underBasename(url.pathname, basename) ?? '/';\n to = safeReturnTo(to + url.search);\n if (req.method !== 'GET' && req.method !== 'HEAD') to = fromReferer(req, url.origin, basename) ?? to;\n void res.body?.cancel().catch(() => {});\n return new Response(null, {\n status: 204,\n headers: {\n 'X-Remix-Redirect': to, 'X-Remix-Status': '302', 'X-Remix-Reload-Document': 'true',\n 'cache-control': 'no-store', 'x-camada-challenge': '1',\n },\n });\n }, res);\n}\n\n/** camada's own answer (block, challenge, verify, beacon), in the form the router sends. Where a custom\n * server passed its Node response in `camadaResponseContext`, the headers that answer sets are\n * removed from it first: the Express adapter appends, so an upstream policy or cache header\n * would otherwise go out next to the answer's own (R7-2). Set-Cookie is the app's and stays. */\nexport function answered(req: Request, context: RouterContext, res: Response, basename?: string): Response {\n const out = forNavigation(req, res, basename);\n guarded(() => {\n const node = context.get(camadaResponseContext);\n if (!node || typeof node.removeHeader !== 'function') return;\n for (const k of out.headers.keys()) if (k !== 'set-cookie') node.removeHeader(k);\n if (out.headers.has('content-security-policy')) node.removeHeader('content-security-policy-report-only');\n }, undefined);\n return out;\n}\n\n/** The same-origin Referer's path and query (without the basename), when it is a safe return target. */\nfunction fromReferer(req: Request, origin: string, basename: string | undefined): string | null {\n const raw = req.headers.get('referer');\n if (!raw) return null;\n let ref: URL;\n try { ref = new URL(raw); } catch { return null; }\n if (ref.origin !== origin) return null;\n const path = underBasename(ref.pathname, basename);\n if (path === null) return null;\n const to = path + ref.search;\n return safeReturnTo(to) === to ? to : null;\n}\n\nexport function camada(opts: CamadaRemixOptions = {}): MiddlewareFunction<Response> {\n const cam = instanceFor(opts);\n return async ({ request, context }, next) => {\n const r = await cam.before(request, { ...requestContext(context), navigation: isDataRequest(request) });\n if (!r) return next();\n if (r.response) return answered(request, context, r.response, opts.basename); // block, challenge, verify, beacon: answered without the app\n guarded(() => context.set(camadaContext, r.vars), undefined);\n let res: Response;\n try {\n res = await next();\n } catch (err) {\n // A thrown Response (a redirect from a downstream middleware) is the answer, so it carries\n // the status and the cookie; anything else lands in the router's error boundary as a 500.\n if (err instanceof Response) {\n cam.after(request, r.vars, err.status);\n throw withRid(r.vars.sessionCookie ? withSetCookie(err, r.vars.sessionCookie) : err, r.vars);\n }\n cam.after(request, r.vars, 500);\n throw err;\n }\n return cam.finish(request, r.vars, r.vars.sessionCookie ? withSetCookie(res, r.vars.sessionCookie) : res); // an SSE body ships once it has gone out; anything else now\n };\n}\n","// Per-request state the middleware leaves on React Router's context for the two helpers an app\n// calls from its own loaders and actions: `scriptTag(context)` (the beacon tag) and\n// `track(context, event, data)` (an app-context outcome). When the middleware did not run for\n// this request (no key, CAMADA_DISABLED, or a request it answered itself) the slot is empty and\n// both helpers stand down.\nimport { createContext, type RouterContextProvider } from 'react-router';\nimport { guarded } from '@camada/core';\nimport { track as coreTrack, scriptTag as coreScriptTag, type FetchVars } from '@camada/core/fetch';\n\n// Defaults are null, not undefined: React Router's `get()` throws on a context that has neither\n// a value nor a default, and the helpers must stand down silently where the middleware did not run.\n/** The vars the middleware kept for this request; null where it did not run. Private to the package: track() and scriptTag() are the API. */\nexport const camadaContext = createContext<FetchVars | null>(null);\n\n/** The socket address a custom server vouches for (set from `getLoadContext`); null = nothing vouched. */\nexport const camadaPeerContext = createContext<string | null>(null);\n\n/** The Node response a custom server hands camada (Express's `res`, set from `getLoadContext(req, res)`).\n * @react-router/express writes an answer's headers with `res.append`, so a header an Express\n * middleware already set (helmet's Content-Security-Policy) would go out twice; with this, camada\n * removes the ones its own answer replaces first. Null = nothing handed over. */\nexport const camadaResponseContext = createContext<{ removeHeader(name: string): void } | null>(null);\n\n/** Loaders, actions and middleware all receive the same read-only provider. */\nexport type RouterContext = Readonly<RouterContextProvider>;\n\nconst readSlot = (context: RouterContext): FetchVars | undefined => guarded(() => context.get(camadaContext) ?? undefined, undefined);\n\n/**\n * Records an outcome the app knows and the wire cannot show: `login_failed`, `login_succeeded`,\n * `signup`, `password_reset`, `mfa_failed`, `payment_failed`, `payment_succeeded`, `coupon_failed`\n * (free-form; that vocabulary is what the analyst's rules read). Joined to this request's event\n * through its rid and session; the user identifier is HMAC-hashed in-process. Never throws,\n * never rejects, and is a no-op where the middleware did not run.\n */\nexport const track = (context: RouterContext, event: string, data?: { user?: string }): Promise<void> => coreTrack(readSlot(context), event, data);\n\n/** The `<script>` tag for an HTML response — `''` when camada is off for this request or the tenant turned the beacon off. */\nexport const scriptTag = (context: RouterContext): string => coreScriptTag(readSlot(context));\n","{\n \"name\": \"@camada/remix\",\n \"version\": \"0.2.0\",\n \"license\": \"MIT\",\n \"type\": \"module\",\n \"engines\": {\n \"node\": \">=20\"\n },\n \"files\": [\n \"dist\",\n \"README.md\",\n \"LICENSE\"\n ],\n \"scripts\": {\n \"build\": \"tsup\",\n \"dev\": \"tsup --watch\",\n \"test\": \"vitest run\",\n \"check\": \"tsc --noEmit\",\n \"check:readme-build\": \"scripts/readme-build-check.sh\"\n },\n \"description\": \"camada SDK for React Router framework mode (Remix): snapshot enforcement in a root middleware, first-party challenge and beacon route, track() outcomes\",\n \"exports\": {\n \".\": {\n \"import\": {\n \"types\": \"./dist/index.d.ts\",\n \"default\": \"./dist/index.js\"\n },\n \"require\": {\n \"types\": \"./dist/index.d.cts\",\n \"default\": \"./dist/index.cjs\"\n }\n }\n },\n \"main\": \"./dist/index.cjs\",\n \"module\": \"./dist/index.js\",\n \"types\": \"./dist/index.d.ts\",\n \"dependencies\": {\n \"@camada/browser\": \"file:../camada-browser\",\n \"@camada/core\": \"file:../camada-core\"\n },\n \"peerDependencies\": {\n \"react-router\": \">=7.9\"\n },\n \"devDependencies\": {\n \"@react-router/express\": \"^8.3.1\",\n \"@types/express\": \"^5.0.6\",\n \"@types/node\": \"^22.10.0\",\n \"express\": \"^5.3.0\",\n \"helmet\": \"^8.3.0\",\n \"react-router\": \"^8.3.1\",\n \"tsup\": \"^8.3.5\",\n \"typescript\": \"^5.7.2\",\n \"vitest\": \"^3.0.0\"\n }\n}\n","// The SDK's wire identity (SDK-03): x-camada-sdk: <package>/<version>. Named JSON imports are\n// inlined and tree-shaken by tsup at build, so dist carries two literals — no runtime fs read.\nimport { name, version } from '../package.json';\nexport const SDK_ID = `${name}/${version}`;\n","// The resource route for the beacon endpoints (routes.ts: route('/_cam/*', 'routes/cam.tsx')).\n// React Router runs middleware only for a matched route, so without a route under /_cam/* the\n// root middleware never sees the script or the relay and the router 404s them — the same reason\n// @camada/next has camadaRoute(). before() re-runs the verdict, so a blocked client gets its\n// 403 here too, and with the root middleware mounted it answers first and this never runs.\nimport type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';\nimport { instanceFor, requestContext, answered, type CamadaRemixOptions } from './camada.js';\n\ntype Handler<A> = (args: A) => Promise<Response>;\n\nexport function camadaRoute(opts: CamadaRemixOptions = {}): { loader: Handler<LoaderFunctionArgs>; action: Handler<ActionFunctionArgs> } {\n const cam = instanceFor(opts);\n const answer: Handler<LoaderFunctionArgs | ActionFunctionArgs> = async ({ request, context }) => {\n const r = await cam.before(request, requestContext(context));\n return r?.response ? answered(request, context, r.response, opts.basename) : new Response(null, { status: 404 }); // not a beacon path, or camada inert: nothing to serve\n };\n return { loader: answer, action: answer };\n}\n"],"mappings":"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","names":["import_core","import_fetch","coreTrack","coreScriptTag","name","iife"]}
package/dist/index.js CHANGED
@@ -16,7 +16,7 @@ var scriptTag = (context) => coreScriptTag(readSlot(context));
16
16
 
17
17
  // package.json
18
18
  var name = "@camada/remix";
19
- var version = "0.1.4";
19
+ var version = "0.2.0";
20
20
 
21
21
  // src/version.ts
22
22
  var SDK_ID = `${name}/${version}`;
@@ -64,11 +64,13 @@ function underBasename(path, basename) {
64
64
  if (path === base) return "/";
65
65
  return path.startsWith(base + "/") ? path.slice(base.length) : null;
66
66
  }
67
+ var isDataPath = (pathname) => pathname.endsWith(".data");
68
+ var isDataRequest = (req) => isDataPath(new URL(req.url).pathname);
67
69
  function forNavigation(req, res, basename) {
68
70
  return guarded2(() => {
69
71
  if (res.status !== 403 || res.headers.get("x-camada-challenge") !== "1") return res;
70
72
  const url = new URL(req.url);
71
- if (!url.pathname.endsWith(".data")) return res;
73
+ if (!isDataPath(url.pathname)) return res;
72
74
  url.pathname = url.pathname.endsWith("/_.data") ? url.pathname.slice(0, -"_.data".length) : url.pathname.endsWith("/_root.data") ? url.pathname.slice(0, -"_root.data".length) : url.pathname.slice(0, -".data".length);
73
75
  url.searchParams.delete("_routes");
74
76
  let to = underBasename(url.pathname, basename) ?? "/";
@@ -116,7 +118,7 @@ function fromReferer(req, origin, basename) {
116
118
  function camada(opts = {}) {
117
119
  const cam = instanceFor(opts);
118
120
  return async ({ request, context }, next) => {
119
- const r = await cam.before(request, requestContext(context));
121
+ const r = await cam.before(request, { ...requestContext(context), navigation: isDataRequest(request) });
120
122
  if (!r) return next();
121
123
  if (r.response) return answered(request, context, r.response, opts.basename);
122
124
  guarded2(() => context.set(camadaContext, r.vars), void 0);
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/camada.ts","../src/context.ts","../package.json","../src/version.ts","../src/route.ts"],"sourcesContent":["// @camada/remix — the root server middleware for React Router framework mode (SDK-G04):\n// export const middleware = [camada()]; // app/root.tsx\n// A thin binding over @camada/core/fetch: the peer is whatever a custom server put in\n// `camadaPeerContext` (React Router itself vouches for no socket), the env is process.env, and\n// there is no waitUntil — the host is a long-lived Node server or an edge runtime that keeps\n// its own isolate open. A camada bug, a dead ingest or a corrupt snapshot costs telemetry,\n// never the app's response.\nimport iife from '@camada/browser/iife-string';\nimport { guarded, safeReturnTo, TAP_REMIX } from '@camada/core';\nimport { createFetchCamada, withSetCookie, withRid, type FetchCamada, type FetchCamadaOptions, type FetchRequestContext, type FetchVars } from '@camada/core/fetch';\nimport type { MiddlewareFunction } from 'react-router';\nimport { camadaContext, camadaPeerContext, camadaResponseContext, type RouterContext } from './context.js';\nimport { SDK_ID } from './version.js';\n\nexport interface CamadaRemixOptions extends FetchCamadaOptions {\n /** The app's React Router `basename` (react-router.config.ts), when it has one. The client puts\n * it back in front of a redirect, so a challenged data request's `X-Remix-Redirect` goes out\n * without it. */\n basename?: string;\n}\nexport type CamadaRemixVars = FetchVars;\n\n// camada() and camadaRoute() must see one engine for the same options, or the route would poll\n// and enforce a second copy of the tenant's snapshot: an instance is found by its serialisable\n// options (keys sorted, so two modules spelling them in a different order agree); fetchImpl\n// cannot be stringified, so it keys a bucket by identity.\nconst instances = new Set<FetchCamada>();\nconst byKey = new Map<typeof fetch | undefined, Map<string, FetchCamada>>();\n\n// Sorts the keys of every object on the way in, so key order never splits one configuration in two.\nconst sortedJson = (v: unknown): string =>\n JSON.stringify(v, (_, x: unknown) => (x && typeof x === 'object' && !Array.isArray(x) ? Object.fromEntries(Object.entries(x).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) : x));\n\n/** The directory of the beacon script, `/_cam/` by default: a path in it with `name` last. */\nexport const inScriptDir = (scriptPath: string | undefined, name: string): string => (scriptPath ?? '/_cam/b.js').replace(/[^/]*$/, name);\n\nexport function instanceFor(given: CamadaRemixOptions = {}): FetchCamada {\n // React Router runs middleware only for a matched route, and the beacon route (`/_cam/*` in the\n // README) is the one camada can count on: the challenge form posts into that directory by\n // default (`/_cam/challenge`, or next to a custom `scriptPath`), where camadaRoute() matches and\n // the root middleware answers. Core's own default, `/__camada/challenge`, would 404 in the\n // router before camada ever ran (camada-all-zhoc).\n const { basename, ...core } = given; // the adapter's own option: not core's, and part of the engine's identity only through the paths it moves\n // Under a `basename` the router strips it before matching, so the endpoints' URLs (what the\n // browser requests and core compares) carry it: the defaults live under it (camada-all-xms0).\n const base = (basename ?? '').replace(/\\/+$/, '');\n const scriptPath = core.scriptPath ?? (base ? `${base}/_cam/b.js` : undefined);\n const fpPath = core.fpPath ?? inScriptDir(scriptPath, 'fp'); // the script posts next to itself (auto.ts derives it): `/_cam/fp` by default\n const opts = {\n ...core,\n ...(scriptPath ? { scriptPath } : {}),\n fpPath,\n challengePath: core.challengePath ?? inScriptDir(scriptPath, 'challenge'),\n };\n const { fetchImpl, ...serialisable } = opts;\n const key = sortedJson(serialisable);\n let bucket = byKey.get(fetchImpl);\n if (!bucket) byKey.set(fetchImpl, (bucket = new Map()));\n let cam = bucket.get(key);\n if (!cam) {\n cam = createFetchCamada({ tap: TAP_REMIX, sdk: SDK_ID, iife }, opts);\n bucket.set(key, cam);\n instances.add(cam);\n }\n return cam;\n}\n\n/** Test/reset hook: stops and drops every engine this module created; the instances stay wired and rebuild lazily. */\nexport function resetCamada(): void {\n for (const cam of instances) cam.reset();\n}\n\n/** What the host can vouch for: the peer a custom server set, and the process env. The peer read\n * is guarded — a 7.9 app without the middleware flag hands a plain object; an edge runtime has no process. */\nexport function requestContext(context: RouterContext): FetchRequestContext {\n return {\n peer: guarded(() => context.get(camadaPeerContext), null),\n env: globalThis.process?.env,\n };\n}\n\n/** `path` without the router's basename, or null when it lies outside it. */\nfunction underBasename(path: string, basename: string | undefined): string | null {\n const base = (basename ?? '').replace(/\\/+$/, '');\n if (!base) return path;\n if (path === base) return '/';\n return path.startsWith(base + '/') ? path.slice(base.length) : null;\n}\n\n/** A challenged client-side navigation: React Router's client fetches `<path>.data` (`/_.data` for a\n * path ending in `/`) and throws any 4xx without `X-Remix-Response` into the route's error\n * boundary, so core's 403 JSON would show the app's error page, never the challenge. Answer it\n * in React Router's single-fetch redirect form instead: 204 + `X-Remix-Redirect` +\n * `X-Remix-Reload-Document`, which the client turns into a document navigation\n * (`window.location.assign`) that meets the HTML challenge (camada-all-wjln). A GET reloads the\n * page itself. A non-GET (`fetcher.submit`, `fetcher.load` of a resource route, `<Form\n * action=/other>`) targets a route that may have no page, so it reloads the same-origin page it\n * came from (the Referer), else the data path. The location goes out without the router's\n * basename, which the client adds back. Any other answer, and every request that is not a\n * `.data` fetch (an API call from app code), is left as it is. */\nexport function forNavigation(req: Request, res: Response, basename?: string): Response {\n return guarded(() => {\n if (res.status !== 403 || res.headers.get('x-camada-challenge') !== '1') return res;\n const url = new URL(req.url);\n if (!url.pathname.endsWith('.data')) return res;\n url.pathname = url.pathname.endsWith('/_.data') ? url.pathname.slice(0, -'_.data'.length)\n : url.pathname.endsWith('/_root.data') ? url.pathname.slice(0, -'_root.data'.length) // Remix v2's spelling of the root\n : url.pathname.slice(0, -'.data'.length);\n url.searchParams.delete('_routes');\n let to = underBasename(url.pathname, basename) ?? '/';\n to = safeReturnTo(to + url.search);\n if (req.method !== 'GET' && req.method !== 'HEAD') to = fromReferer(req, url.origin, basename) ?? to;\n void res.body?.cancel().catch(() => {});\n return new Response(null, {\n status: 204,\n headers: {\n 'X-Remix-Redirect': to, 'X-Remix-Status': '302', 'X-Remix-Reload-Document': 'true',\n 'cache-control': 'no-store', 'x-camada-challenge': '1',\n },\n });\n }, res);\n}\n\n/** camada's own answer (block, challenge, verify, beacon), in the form the router sends. Where a custom\n * server passed its Node response in `camadaResponseContext`, the headers that answer sets are\n * removed from it first: the Express adapter appends, so an upstream policy or cache header\n * would otherwise go out next to the answer's own (R7-2). Set-Cookie is the app's and stays. */\nexport function answered(req: Request, context: RouterContext, res: Response, basename?: string): Response {\n const out = forNavigation(req, res, basename);\n guarded(() => {\n const node = context.get(camadaResponseContext);\n if (!node || typeof node.removeHeader !== 'function') return;\n for (const k of out.headers.keys()) if (k !== 'set-cookie') node.removeHeader(k);\n if (out.headers.has('content-security-policy')) node.removeHeader('content-security-policy-report-only');\n }, undefined);\n return out;\n}\n\n/** The same-origin Referer's path and query (without the basename), when it is a safe return target. */\nfunction fromReferer(req: Request, origin: string, basename: string | undefined): string | null {\n const raw = req.headers.get('referer');\n if (!raw) return null;\n let ref: URL;\n try { ref = new URL(raw); } catch { return null; }\n if (ref.origin !== origin) return null;\n const path = underBasename(ref.pathname, basename);\n if (path === null) return null;\n const to = path + ref.search;\n return safeReturnTo(to) === to ? to : null;\n}\n\nexport function camada(opts: CamadaRemixOptions = {}): MiddlewareFunction<Response> {\n const cam = instanceFor(opts);\n return async ({ request, context }, next) => {\n const r = await cam.before(request, requestContext(context));\n if (!r) return next();\n if (r.response) return answered(request, context, r.response, opts.basename); // block, challenge, verify, beacon: answered without the app\n guarded(() => context.set(camadaContext, r.vars), undefined);\n let res: Response;\n try {\n res = await next();\n } catch (err) {\n // A thrown Response (a redirect from a downstream middleware) is the answer, so it carries\n // the status and the cookie; anything else lands in the router's error boundary as a 500.\n if (err instanceof Response) {\n cam.after(request, r.vars, err.status);\n throw withRid(r.vars.sessionCookie ? withSetCookie(err, r.vars.sessionCookie) : err, r.vars);\n }\n cam.after(request, r.vars, 500);\n throw err;\n }\n return cam.finish(request, r.vars, r.vars.sessionCookie ? withSetCookie(res, r.vars.sessionCookie) : res); // an SSE body ships once it has gone out; anything else now\n };\n}\n","// Per-request state the middleware leaves on React Router's context for the two helpers an app\n// calls from its own loaders and actions: `scriptTag(context)` (the beacon tag) and\n// `track(context, event, data)` (an app-context outcome). When the middleware did not run for\n// this request (no key, CAMADA_DISABLED, or a request it answered itself) the slot is empty and\n// both helpers stand down.\nimport { createContext, type RouterContextProvider } from 'react-router';\nimport { guarded } from '@camada/core';\nimport { track as coreTrack, scriptTag as coreScriptTag, type FetchVars } from '@camada/core/fetch';\n\n// Defaults are null, not undefined: React Router's `get()` throws on a context that has neither\n// a value nor a default, and the helpers must stand down silently where the middleware did not run.\n/** The vars the middleware kept for this request; null where it did not run. Private to the package: track() and scriptTag() are the API. */\nexport const camadaContext = createContext<FetchVars | null>(null);\n\n/** The socket address a custom server vouches for (set from `getLoadContext`); null = nothing vouched. */\nexport const camadaPeerContext = createContext<string | null>(null);\n\n/** The Node response a custom server hands camada (Express's `res`, set from `getLoadContext(req, res)`).\n * @react-router/express writes an answer's headers with `res.append`, so a header an Express\n * middleware already set (helmet's Content-Security-Policy) would go out twice; with this, camada\n * removes the ones its own answer replaces first. Null = nothing handed over. */\nexport const camadaResponseContext = createContext<{ removeHeader(name: string): void } | null>(null);\n\n/** Loaders, actions and middleware all receive the same read-only provider. */\nexport type RouterContext = Readonly<RouterContextProvider>;\n\nconst readSlot = (context: RouterContext): FetchVars | undefined => guarded(() => context.get(camadaContext) ?? undefined, undefined);\n\n/**\n * Records an outcome the app knows and the wire cannot show: `login_failed`, `login_succeeded`,\n * `signup`, `password_reset`, `mfa_failed`, `payment_failed`, `payment_succeeded`, `coupon_failed`\n * (free-form; that vocabulary is what the analyst's rules read). Joined to this request's event\n * through its rid and session; the user identifier is HMAC-hashed in-process. Never throws,\n * never rejects, and is a no-op where the middleware did not run.\n */\nexport const track = (context: RouterContext, event: string, data?: { user?: string }): Promise<void> => coreTrack(readSlot(context), event, data);\n\n/** The `<script>` tag for an HTML response — `''` when camada is off for this request or the tenant turned the beacon off. */\nexport const scriptTag = (context: RouterContext): string => coreScriptTag(readSlot(context));\n","{\n \"name\": \"@camada/remix\",\n \"version\": \"0.1.4\",\n \"license\": \"MIT\",\n \"type\": \"module\",\n \"engines\": {\n \"node\": \">=20\"\n },\n \"files\": [\n \"dist\",\n \"README.md\",\n \"LICENSE\"\n ],\n \"scripts\": {\n \"build\": \"tsup\",\n \"dev\": \"tsup --watch\",\n \"test\": \"vitest run\",\n \"check\": \"tsc --noEmit\",\n \"check:readme-build\": \"scripts/readme-build-check.sh\"\n },\n \"description\": \"camada SDK for React Router framework mode (Remix): snapshot enforcement in a root middleware, first-party challenge and beacon route, track() outcomes\",\n \"exports\": {\n \".\": {\n \"import\": {\n \"types\": \"./dist/index.d.ts\",\n \"default\": \"./dist/index.js\"\n },\n \"require\": {\n \"types\": \"./dist/index.d.cts\",\n \"default\": \"./dist/index.cjs\"\n }\n }\n },\n \"main\": \"./dist/index.cjs\",\n \"module\": \"./dist/index.js\",\n \"types\": \"./dist/index.d.ts\",\n \"dependencies\": {\n \"@camada/browser\": \"file:../camada-browser\",\n \"@camada/core\": \"file:../camada-core\"\n },\n \"peerDependencies\": {\n \"react-router\": \">=7.9\"\n },\n \"devDependencies\": {\n \"@react-router/express\": \"^8.3.1\",\n \"@types/express\": \"^5.0.6\",\n \"@types/node\": \"^22.10.0\",\n \"express\": \"^5.3.0\",\n \"helmet\": \"^8.3.0\",\n \"react-router\": \"^8.3.1\",\n \"tsup\": \"^8.3.5\",\n \"typescript\": \"^5.7.2\",\n \"vitest\": \"^3.0.0\"\n }\n}\n","// The SDK's wire identity (SDK-03): x-camada-sdk: <package>/<version>. Named JSON imports are\n// inlined and tree-shaken by tsup at build, so dist carries two literals — no runtime fs read.\nimport { name, version } from '../package.json';\nexport const SDK_ID = `${name}/${version}`;\n","// The resource route for the beacon endpoints (routes.ts: route('/_cam/*', 'routes/cam.tsx')).\n// React Router runs middleware only for a matched route, so without a route under /_cam/* the\n// root middleware never sees the script or the relay and the router 404s them — the same reason\n// @camada/next has camadaRoute(). before() re-runs the verdict, so a blocked client gets its\n// 403 here too, and with the root middleware mounted it answers first and this never runs.\nimport type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';\nimport { instanceFor, requestContext, answered, type CamadaRemixOptions } from './camada.js';\n\ntype Handler<A> = (args: A) => Promise<Response>;\n\nexport function camadaRoute(opts: CamadaRemixOptions = {}): { loader: Handler<LoaderFunctionArgs>; action: Handler<ActionFunctionArgs> } {\n const cam = instanceFor(opts);\n const answer: Handler<LoaderFunctionArgs | ActionFunctionArgs> = async ({ request, context }) => {\n const r = await cam.before(request, requestContext(context));\n return r?.response ? answered(request, context, r.response, opts.basename) : new Response(null, { status: 404 }); // not a beacon path, or camada inert: nothing to serve\n };\n return { loader: answer, action: answer };\n}\n"],"mappings":"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","names":["guarded","name","guarded"]}
1
+ {"version":3,"sources":["../src/camada.ts","../src/context.ts","../package.json","../src/version.ts","../src/route.ts"],"sourcesContent":["// @camada/remix — the root server middleware for React Router framework mode (SDK-G04):\n// export const middleware = [camada()]; // app/root.tsx\n// A thin binding over @camada/core/fetch: the peer is whatever a custom server put in\n// `camadaPeerContext` (React Router itself vouches for no socket), the env is process.env, and\n// there is no waitUntil — the host is a long-lived Node server or an edge runtime that keeps\n// its own isolate open. A camada bug, a dead ingest or a corrupt snapshot costs telemetry,\n// never the app's response.\nimport iife from '@camada/browser/iife-string';\nimport { guarded, safeReturnTo, TAP_REMIX } from '@camada/core';\nimport { createFetchCamada, withSetCookie, withRid, type FetchCamada, type FetchCamadaOptions, type FetchRequestContext, type FetchVars } from '@camada/core/fetch';\nimport type { MiddlewareFunction } from 'react-router';\nimport { camadaContext, camadaPeerContext, camadaResponseContext, type RouterContext } from './context.js';\nimport { SDK_ID } from './version.js';\n\nexport interface CamadaRemixOptions extends FetchCamadaOptions {\n /** The app's React Router `basename` (react-router.config.ts), when it has one. The client puts\n * it back in front of a redirect, so a challenged data request's `X-Remix-Redirect` goes out\n * without it. */\n basename?: string;\n}\nexport type CamadaRemixVars = FetchVars;\n\n// camada() and camadaRoute() must see one engine for the same options, or the route would poll\n// and enforce a second copy of the tenant's snapshot: an instance is found by its serialisable\n// options (keys sorted, so two modules spelling them in a different order agree); fetchImpl\n// cannot be stringified, so it keys a bucket by identity.\nconst instances = new Set<FetchCamada>();\nconst byKey = new Map<typeof fetch | undefined, Map<string, FetchCamada>>();\n\n// Sorts the keys of every object on the way in, so key order never splits one configuration in two.\nconst sortedJson = (v: unknown): string =>\n JSON.stringify(v, (_, x: unknown) => (x && typeof x === 'object' && !Array.isArray(x) ? Object.fromEntries(Object.entries(x).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) : x));\n\n/** The directory of the beacon script, `/_cam/` by default: a path in it with `name` last. */\nexport const inScriptDir = (scriptPath: string | undefined, name: string): string => (scriptPath ?? '/_cam/b.js').replace(/[^/]*$/, name);\n\nexport function instanceFor(given: CamadaRemixOptions = {}): FetchCamada {\n // React Router runs middleware only for a matched route, and the beacon route (`/_cam/*` in the\n // README) is the one camada can count on: the challenge form posts into that directory by\n // default (`/_cam/challenge`, or next to a custom `scriptPath`), where camadaRoute() matches and\n // the root middleware answers. Core's own default, `/__camada/challenge`, would 404 in the\n // router before camada ever ran (camada-all-zhoc).\n const { basename, ...core } = given; // the adapter's own option: not core's, and part of the engine's identity only through the paths it moves\n // Under a `basename` the router strips it before matching, so the endpoints' URLs (what the\n // browser requests and core compares) carry it: the defaults live under it (camada-all-xms0).\n const base = (basename ?? '').replace(/\\/+$/, '');\n const scriptPath = core.scriptPath ?? (base ? `${base}/_cam/b.js` : undefined);\n const fpPath = core.fpPath ?? inScriptDir(scriptPath, 'fp'); // the script posts next to itself (auto.ts derives it): `/_cam/fp` by default\n const opts = {\n ...core,\n ...(scriptPath ? { scriptPath } : {}),\n fpPath,\n challengePath: core.challengePath ?? inScriptDir(scriptPath, 'challenge'),\n };\n const { fetchImpl, ...serialisable } = opts;\n const key = sortedJson(serialisable);\n let bucket = byKey.get(fetchImpl);\n if (!bucket) byKey.set(fetchImpl, (bucket = new Map()));\n let cam = bucket.get(key);\n if (!cam) {\n cam = createFetchCamada({ tap: TAP_REMIX, sdk: SDK_ID, iife }, opts);\n bucket.set(key, cam);\n instances.add(cam);\n }\n return cam;\n}\n\n/** Test/reset hook: stops and drops every engine this module created; the instances stay wired and rebuild lazily. */\nexport function resetCamada(): void {\n for (const cam of instances) cam.reset();\n}\n\n/** What the host can vouch for: the peer a custom server set, and the process env. The peer read\n * is guarded — a 7.9 app without the middleware flag hands a plain object; an edge runtime has no process. */\nexport function requestContext(context: RouterContext): FetchRequestContext {\n return {\n peer: guarded(() => context.get(camadaPeerContext), null),\n env: globalThis.process?.env,\n };\n}\n\n/** `path` without the router's basename, or null when it lies outside it. */\nfunction underBasename(path: string, basename: string | undefined): string | null {\n const base = (basename ?? '').replace(/\\/+$/, '');\n if (!base) return path;\n if (path === base) return '/';\n return path.startsWith(base + '/') ? path.slice(base.length) : null;\n}\n\n/** Whether `pathname` is React Router's client fetching `<path>.data` (any method). */\nconst isDataPath = (pathname: string): boolean => pathname.endsWith('.data');\nconst isDataRequest = (req: Request): boolean => isDataPath(new URL(req.url).pathname);\n\n/** A challenged client-side navigation: React Router's client fetches `<path>.data` (`/_.data` for a\n * path ending in `/`) and throws any 4xx without `X-Remix-Response` into the route's error\n * boundary, so core's 403 JSON would show the app's error page, never the challenge. Answer it\n * in React Router's single-fetch redirect form instead: 204 + `X-Remix-Redirect` +\n * `X-Remix-Reload-Document`, which the client turns into a document navigation\n * (`window.location.assign`) that meets the HTML challenge (camada-all-wjln). A GET reloads the\n * page itself. A non-GET (`fetcher.submit`, `fetcher.load` of a resource route, `<Form\n * action=/other>`) targets a route that may have no page, so it reloads the same-origin page it\n * came from (the Referer), else the data path. The location goes out without the router's\n * basename, which the client adds back. Any other answer, and every request that is not a\n * `.data` fetch, is left as it is. */\nexport function forNavigation(req: Request, res: Response, basename?: string): Response {\n return guarded(() => {\n if (res.status !== 403 || res.headers.get('x-camada-challenge') !== '1') return res;\n const url = new URL(req.url);\n if (!isDataPath(url.pathname)) return res;\n url.pathname = url.pathname.endsWith('/_.data') ? url.pathname.slice(0, -'_.data'.length)\n : url.pathname.endsWith('/_root.data') ? url.pathname.slice(0, -'_root.data'.length) // Remix v2's spelling of the root\n : url.pathname.slice(0, -'.data'.length);\n url.searchParams.delete('_routes');\n let to = underBasename(url.pathname, basename) ?? '/';\n to = safeReturnTo(to + url.search);\n if (req.method !== 'GET' && req.method !== 'HEAD') to = fromReferer(req, url.origin, basename) ?? to;\n void res.body?.cancel().catch(() => {});\n return new Response(null, {\n status: 204,\n headers: {\n 'X-Remix-Redirect': to, 'X-Remix-Status': '302', 'X-Remix-Reload-Document': 'true',\n 'cache-control': 'no-store', 'x-camada-challenge': '1',\n },\n });\n }, res);\n}\n\n/** camada's own answer (block, challenge, verify, beacon), in the form the router sends. Where a custom\n * server passed its Node response in `camadaResponseContext`, the headers that answer sets are\n * removed from it first: the Express adapter appends, so an upstream policy or cache header\n * would otherwise go out next to the answer's own (R7-2). Set-Cookie is the app's and stays. */\nexport function answered(req: Request, context: RouterContext, res: Response, basename?: string): Response {\n const out = forNavigation(req, res, basename);\n guarded(() => {\n const node = context.get(camadaResponseContext);\n if (!node || typeof node.removeHeader !== 'function') return;\n for (const k of out.headers.keys()) if (k !== 'set-cookie') node.removeHeader(k);\n if (out.headers.has('content-security-policy')) node.removeHeader('content-security-policy-report-only');\n }, undefined);\n return out;\n}\n\n/** The same-origin Referer's path and query (without the basename), when it is a safe return target. */\nfunction fromReferer(req: Request, origin: string, basename: string | undefined): string | null {\n const raw = req.headers.get('referer');\n if (!raw) return null;\n let ref: URL;\n try { ref = new URL(raw); } catch { return null; }\n if (ref.origin !== origin) return null;\n const path = underBasename(ref.pathname, basename);\n if (path === null) return null;\n const to = path + ref.search;\n return safeReturnTo(to) === to ? to : null;\n}\n\nexport function camada(opts: CamadaRemixOptions = {}): MiddlewareFunction<Response> {\n const cam = instanceFor(opts);\n return async ({ request, context }, next) => {\n const r = await cam.before(request, { ...requestContext(context), navigation: isDataRequest(request) });\n if (!r) return next();\n if (r.response) return answered(request, context, r.response, opts.basename); // block, challenge, verify, beacon: answered without the app\n guarded(() => context.set(camadaContext, r.vars), undefined);\n let res: Response;\n try {\n res = await next();\n } catch (err) {\n // A thrown Response (a redirect from a downstream middleware) is the answer, so it carries\n // the status and the cookie; anything else lands in the router's error boundary as a 500.\n if (err instanceof Response) {\n cam.after(request, r.vars, err.status);\n throw withRid(r.vars.sessionCookie ? withSetCookie(err, r.vars.sessionCookie) : err, r.vars);\n }\n cam.after(request, r.vars, 500);\n throw err;\n }\n return cam.finish(request, r.vars, r.vars.sessionCookie ? withSetCookie(res, r.vars.sessionCookie) : res); // an SSE body ships once it has gone out; anything else now\n };\n}\n","// Per-request state the middleware leaves on React Router's context for the two helpers an app\n// calls from its own loaders and actions: `scriptTag(context)` (the beacon tag) and\n// `track(context, event, data)` (an app-context outcome). When the middleware did not run for\n// this request (no key, CAMADA_DISABLED, or a request it answered itself) the slot is empty and\n// both helpers stand down.\nimport { createContext, type RouterContextProvider } from 'react-router';\nimport { guarded } from '@camada/core';\nimport { track as coreTrack, scriptTag as coreScriptTag, type FetchVars } from '@camada/core/fetch';\n\n// Defaults are null, not undefined: React Router's `get()` throws on a context that has neither\n// a value nor a default, and the helpers must stand down silently where the middleware did not run.\n/** The vars the middleware kept for this request; null where it did not run. Private to the package: track() and scriptTag() are the API. */\nexport const camadaContext = createContext<FetchVars | null>(null);\n\n/** The socket address a custom server vouches for (set from `getLoadContext`); null = nothing vouched. */\nexport const camadaPeerContext = createContext<string | null>(null);\n\n/** The Node response a custom server hands camada (Express's `res`, set from `getLoadContext(req, res)`).\n * @react-router/express writes an answer's headers with `res.append`, so a header an Express\n * middleware already set (helmet's Content-Security-Policy) would go out twice; with this, camada\n * removes the ones its own answer replaces first. Null = nothing handed over. */\nexport const camadaResponseContext = createContext<{ removeHeader(name: string): void } | null>(null);\n\n/** Loaders, actions and middleware all receive the same read-only provider. */\nexport type RouterContext = Readonly<RouterContextProvider>;\n\nconst readSlot = (context: RouterContext): FetchVars | undefined => guarded(() => context.get(camadaContext) ?? undefined, undefined);\n\n/**\n * Records an outcome the app knows and the wire cannot show: `login_failed`, `login_succeeded`,\n * `signup`, `password_reset`, `mfa_failed`, `payment_failed`, `payment_succeeded`, `coupon_failed`\n * (free-form; that vocabulary is what the analyst's rules read). Joined to this request's event\n * through its rid and session; the user identifier is HMAC-hashed in-process. Never throws,\n * never rejects, and is a no-op where the middleware did not run.\n */\nexport const track = (context: RouterContext, event: string, data?: { user?: string }): Promise<void> => coreTrack(readSlot(context), event, data);\n\n/** The `<script>` tag for an HTML response — `''` when camada is off for this request or the tenant turned the beacon off. */\nexport const scriptTag = (context: RouterContext): string => coreScriptTag(readSlot(context));\n","{\n \"name\": \"@camada/remix\",\n \"version\": \"0.2.0\",\n \"license\": \"MIT\",\n \"type\": \"module\",\n \"engines\": {\n \"node\": \">=20\"\n },\n \"files\": [\n \"dist\",\n \"README.md\",\n \"LICENSE\"\n ],\n \"scripts\": {\n \"build\": \"tsup\",\n \"dev\": \"tsup --watch\",\n \"test\": \"vitest run\",\n \"check\": \"tsc --noEmit\",\n \"check:readme-build\": \"scripts/readme-build-check.sh\"\n },\n \"description\": \"camada SDK for React Router framework mode (Remix): snapshot enforcement in a root middleware, first-party challenge and beacon route, track() outcomes\",\n \"exports\": {\n \".\": {\n \"import\": {\n \"types\": \"./dist/index.d.ts\",\n \"default\": \"./dist/index.js\"\n },\n \"require\": {\n \"types\": \"./dist/index.d.cts\",\n \"default\": \"./dist/index.cjs\"\n }\n }\n },\n \"main\": \"./dist/index.cjs\",\n \"module\": \"./dist/index.js\",\n \"types\": \"./dist/index.d.ts\",\n \"dependencies\": {\n \"@camada/browser\": \"file:../camada-browser\",\n \"@camada/core\": \"file:../camada-core\"\n },\n \"peerDependencies\": {\n \"react-router\": \">=7.9\"\n },\n \"devDependencies\": {\n \"@react-router/express\": \"^8.3.1\",\n \"@types/express\": \"^5.0.6\",\n \"@types/node\": \"^22.10.0\",\n \"express\": \"^5.3.0\",\n \"helmet\": \"^8.3.0\",\n \"react-router\": \"^8.3.1\",\n \"tsup\": \"^8.3.5\",\n \"typescript\": \"^5.7.2\",\n \"vitest\": \"^3.0.0\"\n }\n}\n","// The SDK's wire identity (SDK-03): x-camada-sdk: <package>/<version>. Named JSON imports are\n// inlined and tree-shaken by tsup at build, so dist carries two literals — no runtime fs read.\nimport { name, version } from '../package.json';\nexport const SDK_ID = `${name}/${version}`;\n","// The resource route for the beacon endpoints (routes.ts: route('/_cam/*', 'routes/cam.tsx')).\n// React Router runs middleware only for a matched route, so without a route under /_cam/* the\n// root middleware never sees the script or the relay and the router 404s them — the same reason\n// @camada/next has camadaRoute(). before() re-runs the verdict, so a blocked client gets its\n// 403 here too, and with the root middleware mounted it answers first and this never runs.\nimport type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';\nimport { instanceFor, requestContext, answered, type CamadaRemixOptions } from './camada.js';\n\ntype Handler<A> = (args: A) => Promise<Response>;\n\nexport function camadaRoute(opts: CamadaRemixOptions = {}): { loader: Handler<LoaderFunctionArgs>; action: Handler<ActionFunctionArgs> } {\n const cam = instanceFor(opts);\n const answer: Handler<LoaderFunctionArgs | ActionFunctionArgs> = async ({ request, context }) => {\n const r = await cam.before(request, requestContext(context));\n return r?.response ? answered(request, context, r.response, opts.basename) : new Response(null, { status: 404 }); // not a beacon path, or camada inert: nothing to serve\n };\n return { loader: answer, action: answer };\n}\n"],"mappings":"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","names":["guarded","name","guarded"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camada/remix",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "engines": {
@@ -28,8 +28,8 @@
28
28
  "module": "./dist/index.js",
29
29
  "types": "./dist/index.d.ts",
30
30
  "dependencies": {
31
- "@camada/browser": "^0.3.2",
32
- "@camada/core": "^0.5.2"
31
+ "@camada/browser": "^0.4.0",
32
+ "@camada/core": "^0.6.0"
33
33
  },
34
34
  "peerDependencies": {
35
35
  "react-router": ">=7.9"