@camada/remix 0.1.3 → 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
@@ -25,7 +25,7 @@ import { type RouteConfig, index, route } from '@react-router/dev/routes';
25
25
 
26
26
  export default [
27
27
  index('routes/home.tsx'),
28
- route('/_cam/*', 'routes/cam.tsx'), // the beacon endpoints only run where a route exists
28
+ route('/_cam/*', 'routes/cam.tsx'), // camada's own endpoints (beacon + challenge) only run where a route exists
29
29
  ] satisfies RouteConfig;
30
30
  ```
31
31
 
@@ -33,7 +33,9 @@ export default [
33
33
  // app/routes/cam.tsx
34
34
  import { camadaRoute } from '@camada/remix';
35
35
 
36
- export const { loader, action } = camadaRoute();
36
+ const r = camadaRoute();
37
+ export const loader = r.loader; // plain consts: `export const { loader, action } = ...` fails
38
+ export const action = r.action; // `react-router build` ("Cannot remove destructured export")
37
39
  ```
38
40
 
39
41
  Env (printed by camada onboarding / `npm run seed` in dev):
@@ -79,9 +81,15 @@ fill one event queue. Two mounts with different keys or URLs each get their own.
79
81
  before `next()`; the event still ships, with `st: 403` and `blk: <reason>` so the analyst
80
82
  counts SDK blocks apart from your own 403s.
81
83
  5. **Skip** → a skip rule or the allow list wins over a wider block.
82
- 6. **Challenge** → a `403` proof-of-work page for HTML navigations, `403
83
- {"error":"challenge_required"}` for anything else; `POST /__camada/challenge` verifies the
84
- work, sets `_cch` and 302s back.
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
87
+ the work, sets `_cch` and 302s back. A client-side navigation (React Router fetching
88
+ `<path>.data` for a `<Link>`) is answered in React Router's own redirect form instead
89
+ (`204` + `X-Remix-Redirect` to the same page + `X-Remix-Reload-Document`), so the browser
90
+ reloads the page as a document and meets the challenge, rather than your error boundary
91
+ rendering a 403. A challenged submission (`fetcher.submit`, `<Form action>`, any non-GET
92
+ `.data`) reloads the same-origin page it came from (its `Referer`), else the data path.
85
93
  7. Otherwise `next()` runs your loaders and actions, and the settled response ships one
86
94
  batched, redacted event with its real status (Authorization and Cookie values never leave
87
95
  the process; credential-looking query values are scrubbed — see `@camada/core`). A first
@@ -98,22 +106,42 @@ this tap vouches for no address. A custom server knows the socket and can say so
98
106
  // server.ts (Express)
99
107
  import { createRequestHandler } from '@react-router/express';
100
108
  import { RouterContextProvider } from 'react-router';
101
- import { camadaPeerContext } from '@camada/remix';
109
+ import { camadaPeerContext, camadaResponseContext } from '@camada/remix';
102
110
 
103
111
  app.all('*', createRequestHandler({
104
112
  build,
105
- getLoadContext(req) {
113
+ getLoadContext(req, res) {
106
114
  const context = new RouterContextProvider();
107
115
  context.set(camadaPeerContext, req.socket.remoteAddress ?? null); // the address Node vouches for, never a header
116
+ context.set(camadaResponseContext, res); // lets camada's answers replace headers set before the handler (below)
108
117
  return context;
109
118
  },
110
119
  }));
111
120
  ```
112
121
 
122
+ `@react-router/express` copies a Response's headers onto `res` with `res.append`, so a header an
123
+ Express middleware set before the handler goes out next to camada's own. With
124
+ [helmet](https://helmetjs.github.io/) in front, the challenge page would carry two
125
+ `Content-Security-Policy` headers: browsers enforce both, and helmet's blocks the page's inline
126
+ solver, so a challenged visitor can never pass. Handing camada `res` through
127
+ `camadaResponseContext` (as above) fixes that: before it answers a request itself (block,
128
+ challenge, verify, beacon script), camada removes from `res` every header its answer sets, plus a
129
+ report-only policy under the challenge page's own. Set-Cookie headers are left alone. Without it,
130
+ mount helmet after the React Router handler, or set your policy from React Router's
131
+ `entry.server` / route `headers` instead of Express.
132
+
113
133
  Behind a load balancer, set `CAMADA_TRUSTED_PROXY=hops:1` (or `cidrs:…`) — or the
114
134
  `trustedProxy` option — and `X-Forwarded-For` is read one hop behind that peer. Never put a
115
135
  header value in `camadaPeerContext`: that is the one address camada takes on faith.
116
136
 
137
+ Without a vouched peer (no `camadaPeerContext`, e.g. on a serverless host), this adapter keeps
138
+ the plain `X-Forwarded-For` walk under your trusted-proxy config. With a peer, a `cidrs`
139
+ config is walked only when the peer itself is a trusted proxy or loopback. Without one there is
140
+ nothing to check, so the walk trusts that every request came through your edge (the platform,
141
+ load balancer or CDN in front of the app). That holds when the edge is the only way in. If the
142
+ origin is reachable directly, a client can send its own `X-Forwarded-For`, so lock the origin to
143
+ the edge.
144
+
117
145
  ## Options
118
146
 
119
147
  | option | default | meaning |
@@ -123,11 +151,12 @@ header value in `camadaPeerContext`: that is the one address camada takes on fai
123
151
  | `snapshotUrl` | `<ingestUrl>/snapshot` | snapshot endpoint |
124
152
  | `trustedProxy` | server config | `none` / `vercel` / `hops:N` / `cidrs:a,b`, or the parsed object |
125
153
  | `challenge` | `true` | serve the proof-of-work page for `challenge` verdicts |
126
- | `challengePath` | `/__camada/challenge` | where that page posts its solution |
154
+ | `challengePath` | `/_cam/challenge` (next to `scriptPath`; under `basename` if set) | where that page posts its solution; keep it under the route that calls `camadaRoute()` |
127
155
  | `snapshotVersion` | `5` | `4` drops the custom rules, `3` the allow/challenge sides too |
128
- | `scriptPath` | `/_cam/b.js` | where the first-party beacon script is served |
129
- | `fpPath` | `/_cam/fp` | where that script posts the beacon; keep it in `scriptPath`'s directory |
156
+ | `scriptPath` | `/_cam/b.js` (`<basename>/_cam/b.js` if `basename` is set) | where the first-party beacon script is served |
157
+ | `fpPath` | `fp` in `scriptPath`'s directory: `/_cam/fp`, `<basename>/_cam/fp` if `basename` is set, `/app/x/fp` for `scriptPath: '/app/x/b.js'` | where that script posts the beacon; the script derives it from its own `src`, so keep it in `scriptPath`'s directory |
130
158
  | `mode` | `lazy` (or `timer`) | `timer` polls the snapshot on an unref'd interval (long-lived process); `lazy` refreshes it per request off-path. `CAMADA_SERVERLESS=1` forces `lazy` |
159
+ | `basename` | none | your React Router `basename` (react-router.config.ts). Set it when the app has one (on `camada()` and `camadaRoute()` alike): the default `scriptPath`, `fpPath` and `challengePath` then live under it (`/app/_cam/b.js`, `/app/_cam/fp`, `/app/_cam/challenge`), and the client's redirect for a challenged navigation no longer lands on `/app/app/page`. An explicit path is used as given |
131
160
  | `env` | `process.env` | overrides the process env (tests, and apps that read config themselves) |
132
161
 
133
162
  `CAMADA_CHALLENGE=0` in the env switches the challenge off without a code change.
@@ -173,12 +202,37 @@ never the one the body claims. Both endpoints sit behind the verdict: a blocked
173
202
  there too. The tag is `''` when camada is off for the request or the project turned the beacon
174
203
  off in its settings, and the endpoints stand down with it.
175
204
 
176
- React Router runs middleware only for matched routes, so `/_cam/*` needs a route to exist:
177
- that is what `route('/_cam/*', 'routes/cam.tsx')` and `camadaRoute()` are for. With the root
178
- middleware mounted it answers first and the route's loader and action are the fallback (a
179
- middleware mounted lower, or none at all); they re-run the verdict, so a blocked client is
180
- 403'd there as well. A custom `scriptPath` / `fpPath` must share a directory (the script derives
181
- the post path from its own `src`) and the route must cover it.
205
+ ## The routes camada needs
206
+
207
+ React Router runs middleware only for a URL some route matches. An unmatched URL gets the
208
+ router's 404 before any middleware runs, so camada never sees it. Its own endpoints therefore
209
+ need a route, and `route('/_cam/*', 'routes/cam.tsx')` with `camadaRoute()` is that route. It
210
+ covers three endpoints:
211
+
212
+ | endpoint | what it is |
213
+ |---|---|
214
+ | `GET /_cam/b.js` | the beacon script |
215
+ | `POST /_cam/fp` | the beacon relay |
216
+ | `POST /_cam/challenge` | the challenge page's form: verifies the work and sets `_cch` |
217
+
218
+ With a React Router `basename` (say `/app`) the URLs carry it: `/app/_cam/b.js`, `/app/_cam/fp`,
219
+ `/app/_cam/challenge`. Pass `basename: '/app'` to both `camada()` and `camadaRoute()` and those
220
+ are the defaults; the route itself stays `route('/_cam/*', ...)` in `routes.ts`, because the
221
+ router matches routes after stripping the basename. Without the option the defaults sit outside
222
+ the basename, the router 404s the challenge form, and no challenged visitor can pass.
223
+
224
+ With the root middleware mounted it answers first, and the route's loader and action are the
225
+ fallback (a middleware mounted lower, or none at all). They re-run the verdict, so a blocked
226
+ client gets a 403 there as well. A custom `scriptPath` / `fpPath` must share a directory (the
227
+ script derives the post path from its own `src`), `fpPath` and the challenge path default to that same
228
+ directory (`/api/cam/b.js` → `/api/cam/fp`, `/api/cam/challenge`), and the route must cover it. An explicit
229
+ `challengePath` outside that route 404s in the router, and no challenged visitor can pass.
230
+
231
+ What is **not** captured: requests to URLs no route matches (scanner probes such as
232
+ `/wp-admin/setup.php` or `/.env`). They never reach the middleware, so they ship no event and a
233
+ path block on them is not counted. Your app's 404 still answers them. To capture them as well,
234
+ add a splat route (`route('*', 'routes/not-found.tsx')`) whose loader throws your 404: the
235
+ middleware then runs for every URL.
182
236
 
183
237
  ## App-context events
184
238
 
package/dist/index.cjs CHANGED
@@ -32,6 +32,7 @@ var index_exports = {};
32
32
  __export(index_exports, {
33
33
  camada: () => camada,
34
34
  camadaPeerContext: () => camadaPeerContext,
35
+ camadaResponseContext: () => camadaResponseContext,
35
36
  camadaRoute: () => camadaRoute,
36
37
  resetCamada: () => resetCamada,
37
38
  scriptTag: () => scriptTag,
@@ -50,13 +51,14 @@ var import_core = require("@camada/core");
50
51
  var import_fetch = require("@camada/core/fetch");
51
52
  var camadaContext = (0, import_react_router.createContext)(null);
52
53
  var camadaPeerContext = (0, import_react_router.createContext)(null);
54
+ var camadaResponseContext = (0, import_react_router.createContext)(null);
53
55
  var readSlot = (context) => (0, import_core.guarded)(() => context.get(camadaContext) ?? void 0, void 0);
54
56
  var track = (context, event, data) => (0, import_fetch.track)(readSlot(context), event, data);
55
57
  var scriptTag = (context) => (0, import_fetch.scriptTag)(readSlot(context));
56
58
 
57
59
  // package.json
58
60
  var name = "@camada/remix";
59
- var version = "0.1.3";
61
+ var version = "0.2.0";
60
62
 
61
63
  // src/version.ts
62
64
  var SDK_ID = `${name}/${version}`;
@@ -65,7 +67,18 @@ var SDK_ID = `${name}/${version}`;
65
67
  var instances = /* @__PURE__ */ new Set();
66
68
  var byKey = /* @__PURE__ */ new Map();
67
69
  var sortedJson = (v) => JSON.stringify(v, (_, x) => x && typeof x === "object" && !Array.isArray(x) ? Object.fromEntries(Object.entries(x).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) : x);
68
- function instanceFor(opts = {}) {
70
+ var inScriptDir = (scriptPath, name2) => (scriptPath ?? "/_cam/b.js").replace(/[^/]*$/, name2);
71
+ function instanceFor(given = {}) {
72
+ const { basename, ...core } = given;
73
+ const base = (basename ?? "").replace(/\/+$/, "");
74
+ const scriptPath = core.scriptPath ?? (base ? `${base}/_cam/b.js` : void 0);
75
+ const fpPath = core.fpPath ?? inScriptDir(scriptPath, "fp");
76
+ const opts = {
77
+ ...core,
78
+ ...scriptPath ? { scriptPath } : {},
79
+ fpPath,
80
+ challengePath: core.challengePath ?? inScriptDir(scriptPath, "challenge")
81
+ };
69
82
  const { fetchImpl, ...serialisable } = opts;
70
83
  const key = sortedJson(serialisable);
71
84
  let bucket = byKey.get(fetchImpl);
@@ -87,12 +100,69 @@ function requestContext(context) {
87
100
  env: globalThis.process?.env
88
101
  };
89
102
  }
103
+ function underBasename(path, basename) {
104
+ const base = (basename ?? "").replace(/\/+$/, "");
105
+ if (!base) return path;
106
+ if (path === base) return "/";
107
+ return path.startsWith(base + "/") ? path.slice(base.length) : null;
108
+ }
109
+ var isDataPath = (pathname) => pathname.endsWith(".data");
110
+ var isDataRequest = (req) => isDataPath(new URL(req.url).pathname);
111
+ function forNavigation(req, res, basename) {
112
+ return (0, import_core2.guarded)(() => {
113
+ if (res.status !== 403 || res.headers.get("x-camada-challenge") !== "1") return res;
114
+ const url = new URL(req.url);
115
+ if (!isDataPath(url.pathname)) return res;
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);
117
+ url.searchParams.delete("_routes");
118
+ let to = underBasename(url.pathname, basename) ?? "/";
119
+ to = (0, import_core2.safeReturnTo)(to + url.search);
120
+ if (req.method !== "GET" && req.method !== "HEAD") to = fromReferer(req, url.origin, basename) ?? to;
121
+ void res.body?.cancel().catch(() => {
122
+ });
123
+ return new Response(null, {
124
+ status: 204,
125
+ headers: {
126
+ "X-Remix-Redirect": to,
127
+ "X-Remix-Status": "302",
128
+ "X-Remix-Reload-Document": "true",
129
+ "cache-control": "no-store",
130
+ "x-camada-challenge": "1"
131
+ }
132
+ });
133
+ }, res);
134
+ }
135
+ function answered(req, context, res, basename) {
136
+ const out = forNavigation(req, res, basename);
137
+ (0, import_core2.guarded)(() => {
138
+ const node = context.get(camadaResponseContext);
139
+ if (!node || typeof node.removeHeader !== "function") return;
140
+ for (const k of out.headers.keys()) if (k !== "set-cookie") node.removeHeader(k);
141
+ if (out.headers.has("content-security-policy")) node.removeHeader("content-security-policy-report-only");
142
+ }, void 0);
143
+ return out;
144
+ }
145
+ function fromReferer(req, origin, basename) {
146
+ const raw = req.headers.get("referer");
147
+ if (!raw) return null;
148
+ let ref;
149
+ try {
150
+ ref = new URL(raw);
151
+ } catch {
152
+ return null;
153
+ }
154
+ if (ref.origin !== origin) return null;
155
+ const path = underBasename(ref.pathname, basename);
156
+ if (path === null) return null;
157
+ const to = path + ref.search;
158
+ return (0, import_core2.safeReturnTo)(to) === to ? to : null;
159
+ }
90
160
  function camada(opts = {}) {
91
161
  const cam = instanceFor(opts);
92
162
  return async ({ request, context }, next) => {
93
- const r = await cam.before(request, requestContext(context));
163
+ const r = await cam.before(request, { ...requestContext(context), navigation: isDataRequest(request) });
94
164
  if (!r) return next();
95
- if (r.response) return r.response;
165
+ if (r.response) return answered(request, context, r.response, opts.basename);
96
166
  (0, import_core2.guarded)(() => context.set(camadaContext, r.vars), void 0);
97
167
  let res;
98
168
  try {
@@ -114,7 +184,7 @@ function camadaRoute(opts = {}) {
114
184
  const cam = instanceFor(opts);
115
185
  const answer = async ({ request, context }) => {
116
186
  const r = await cam.before(request, requestContext(context));
117
- return r?.response ?? new Response(null, { status: 404 });
187
+ return r?.response ? answered(request, context, r.response, opts.basename) : new Response(null, { status: 404 });
118
188
  };
119
189
  return { loader: answer, action: answer };
120
190
  }
@@ -122,6 +192,7 @@ function camadaRoute(opts = {}) {
122
192
  0 && (module.exports = {
123
193
  camada,
124
194
  camadaPeerContext,
195
+ camadaResponseContext,
125
196
  camadaRoute,
126
197
  resetCamada,
127
198
  scriptTag,
@@ -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 } 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, 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, type RouterContext } from './context.js';\nimport { SDK_ID } from './version.js';\n\nexport type CamadaRemixOptions = FetchCamadaOptions;\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\nexport function instanceFor(opts: CamadaRemixOptions = {}): FetchCamada {\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\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 r.response; // 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/** 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.3\",\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 },\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 \"types\": \"./dist/index.d.ts\",\n \"import\": \"./dist/index.js\",\n \"require\": \"./dist/index.cjs\"\n }\n },\n \"main\": \"./dist/index.cjs\",\n \"module\": \"./dist/index.js\",\n \"types\": \"./dist/index.d.ts\",\n \"dependencies\": {\n \"@camada/core\": \"file:../camada-core\",\n \"@camada/browser\": \"file:../camada-browser\"\n },\n \"peerDependencies\": {\n \"react-router\": \">=7.9\"\n },\n \"devDependencies\": {\n \"@types/node\": \"^22.10.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, 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 ?? 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","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.d.cts CHANGED
@@ -4,6 +4,13 @@ import { RouterContextProvider, MiddlewareFunction, LoaderFunctionArgs, ActionFu
4
4
 
5
5
  /** The socket address a custom server vouches for (set from `getLoadContext`); null = nothing vouched. */
6
6
  declare const camadaPeerContext: react_router.RouterContext<string | null>;
7
+ /** The Node response a custom server hands camada (Express's `res`, set from `getLoadContext(req, res)`).
8
+ * @react-router/express writes an answer's headers with `res.append`, so a header an Express
9
+ * middleware already set (helmet's Content-Security-Policy) would go out twice; with this, camada
10
+ * removes the ones its own answer replaces first. Null = nothing handed over. */
11
+ declare const camadaResponseContext: react_router.RouterContext<{
12
+ removeHeader(name: string): void;
13
+ } | null>;
7
14
  /** Loaders, actions and middleware all receive the same read-only provider. */
8
15
  type RouterContext = Readonly<RouterContextProvider>;
9
16
  /**
@@ -19,7 +26,12 @@ declare const track: (context: RouterContext, event: string, data?: {
19
26
  /** The `<script>` tag for an HTML response — `''` when camada is off for this request or the tenant turned the beacon off. */
20
27
  declare const scriptTag: (context: RouterContext) => string;
21
28
 
22
- type CamadaRemixOptions = FetchCamadaOptions;
29
+ interface CamadaRemixOptions extends FetchCamadaOptions {
30
+ /** The app's React Router `basename` (react-router.config.ts), when it has one. The client puts
31
+ * it back in front of a redirect, so a challenged data request's `X-Remix-Redirect` goes out
32
+ * without it. */
33
+ basename?: string;
34
+ }
23
35
  type CamadaRemixVars = FetchVars;
24
36
  /** Test/reset hook: stops and drops every engine this module created; the instances stay wired and rebuild lazily. */
25
37
  declare function resetCamada(): void;
@@ -31,4 +43,4 @@ declare function camadaRoute(opts?: CamadaRemixOptions): {
31
43
  action: Handler<ActionFunctionArgs>;
32
44
  };
33
45
 
34
- export { type CamadaRemixOptions, type CamadaRemixVars, camada, camadaPeerContext, camadaRoute, resetCamada, scriptTag, track };
46
+ export { type CamadaRemixOptions, type CamadaRemixVars, camada, camadaPeerContext, camadaResponseContext, camadaRoute, resetCamada, scriptTag, track };
package/dist/index.d.ts CHANGED
@@ -4,6 +4,13 @@ import { RouterContextProvider, MiddlewareFunction, LoaderFunctionArgs, ActionFu
4
4
 
5
5
  /** The socket address a custom server vouches for (set from `getLoadContext`); null = nothing vouched. */
6
6
  declare const camadaPeerContext: react_router.RouterContext<string | null>;
7
+ /** The Node response a custom server hands camada (Express's `res`, set from `getLoadContext(req, res)`).
8
+ * @react-router/express writes an answer's headers with `res.append`, so a header an Express
9
+ * middleware already set (helmet's Content-Security-Policy) would go out twice; with this, camada
10
+ * removes the ones its own answer replaces first. Null = nothing handed over. */
11
+ declare const camadaResponseContext: react_router.RouterContext<{
12
+ removeHeader(name: string): void;
13
+ } | null>;
7
14
  /** Loaders, actions and middleware all receive the same read-only provider. */
8
15
  type RouterContext = Readonly<RouterContextProvider>;
9
16
  /**
@@ -19,7 +26,12 @@ declare const track: (context: RouterContext, event: string, data?: {
19
26
  /** The `<script>` tag for an HTML response — `''` when camada is off for this request or the tenant turned the beacon off. */
20
27
  declare const scriptTag: (context: RouterContext) => string;
21
28
 
22
- type CamadaRemixOptions = FetchCamadaOptions;
29
+ interface CamadaRemixOptions extends FetchCamadaOptions {
30
+ /** The app's React Router `basename` (react-router.config.ts), when it has one. The client puts
31
+ * it back in front of a redirect, so a challenged data request's `X-Remix-Redirect` goes out
32
+ * without it. */
33
+ basename?: string;
34
+ }
23
35
  type CamadaRemixVars = FetchVars;
24
36
  /** Test/reset hook: stops and drops every engine this module created; the instances stay wired and rebuild lazily. */
25
37
  declare function resetCamada(): void;
@@ -31,4 +43,4 @@ declare function camadaRoute(opts?: CamadaRemixOptions): {
31
43
  action: Handler<ActionFunctionArgs>;
32
44
  };
33
45
 
34
- export { type CamadaRemixOptions, type CamadaRemixVars, camada, camadaPeerContext, camadaRoute, resetCamada, scriptTag, track };
46
+ export { type CamadaRemixOptions, type CamadaRemixVars, camada, camadaPeerContext, camadaResponseContext, camadaRoute, resetCamada, scriptTag, track };
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // src/camada.ts
2
2
  import iife from "@camada/browser/iife-string";
3
- import { guarded as guarded2, TAP_REMIX } from "@camada/core";
3
+ import { guarded as guarded2, safeReturnTo, TAP_REMIX } from "@camada/core";
4
4
  import { createFetchCamada, withSetCookie, withRid } from "@camada/core/fetch";
5
5
 
6
6
  // src/context.ts
@@ -9,13 +9,14 @@ import { guarded } from "@camada/core";
9
9
  import { track as coreTrack, scriptTag as coreScriptTag } from "@camada/core/fetch";
10
10
  var camadaContext = createContext(null);
11
11
  var camadaPeerContext = createContext(null);
12
+ var camadaResponseContext = createContext(null);
12
13
  var readSlot = (context) => guarded(() => context.get(camadaContext) ?? void 0, void 0);
13
14
  var track = (context, event, data) => coreTrack(readSlot(context), event, data);
14
15
  var scriptTag = (context) => coreScriptTag(readSlot(context));
15
16
 
16
17
  // package.json
17
18
  var name = "@camada/remix";
18
- var version = "0.1.3";
19
+ var version = "0.2.0";
19
20
 
20
21
  // src/version.ts
21
22
  var SDK_ID = `${name}/${version}`;
@@ -24,7 +25,18 @@ var SDK_ID = `${name}/${version}`;
24
25
  var instances = /* @__PURE__ */ new Set();
25
26
  var byKey = /* @__PURE__ */ new Map();
26
27
  var sortedJson = (v) => JSON.stringify(v, (_, x) => x && typeof x === "object" && !Array.isArray(x) ? Object.fromEntries(Object.entries(x).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) : x);
27
- function instanceFor(opts = {}) {
28
+ var inScriptDir = (scriptPath, name2) => (scriptPath ?? "/_cam/b.js").replace(/[^/]*$/, name2);
29
+ function instanceFor(given = {}) {
30
+ const { basename, ...core } = given;
31
+ const base = (basename ?? "").replace(/\/+$/, "");
32
+ const scriptPath = core.scriptPath ?? (base ? `${base}/_cam/b.js` : void 0);
33
+ const fpPath = core.fpPath ?? inScriptDir(scriptPath, "fp");
34
+ const opts = {
35
+ ...core,
36
+ ...scriptPath ? { scriptPath } : {},
37
+ fpPath,
38
+ challengePath: core.challengePath ?? inScriptDir(scriptPath, "challenge")
39
+ };
28
40
  const { fetchImpl, ...serialisable } = opts;
29
41
  const key = sortedJson(serialisable);
30
42
  let bucket = byKey.get(fetchImpl);
@@ -46,12 +58,69 @@ function requestContext(context) {
46
58
  env: globalThis.process?.env
47
59
  };
48
60
  }
61
+ function underBasename(path, basename) {
62
+ const base = (basename ?? "").replace(/\/+$/, "");
63
+ if (!base) return path;
64
+ if (path === base) return "/";
65
+ return path.startsWith(base + "/") ? path.slice(base.length) : null;
66
+ }
67
+ var isDataPath = (pathname) => pathname.endsWith(".data");
68
+ var isDataRequest = (req) => isDataPath(new URL(req.url).pathname);
69
+ function forNavigation(req, res, basename) {
70
+ return guarded2(() => {
71
+ if (res.status !== 403 || res.headers.get("x-camada-challenge") !== "1") return res;
72
+ const url = new URL(req.url);
73
+ if (!isDataPath(url.pathname)) return res;
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);
75
+ url.searchParams.delete("_routes");
76
+ let to = underBasename(url.pathname, basename) ?? "/";
77
+ to = safeReturnTo(to + url.search);
78
+ if (req.method !== "GET" && req.method !== "HEAD") to = fromReferer(req, url.origin, basename) ?? to;
79
+ void res.body?.cancel().catch(() => {
80
+ });
81
+ return new Response(null, {
82
+ status: 204,
83
+ headers: {
84
+ "X-Remix-Redirect": to,
85
+ "X-Remix-Status": "302",
86
+ "X-Remix-Reload-Document": "true",
87
+ "cache-control": "no-store",
88
+ "x-camada-challenge": "1"
89
+ }
90
+ });
91
+ }, res);
92
+ }
93
+ function answered(req, context, res, basename) {
94
+ const out = forNavigation(req, res, basename);
95
+ guarded2(() => {
96
+ const node = context.get(camadaResponseContext);
97
+ if (!node || typeof node.removeHeader !== "function") return;
98
+ for (const k of out.headers.keys()) if (k !== "set-cookie") node.removeHeader(k);
99
+ if (out.headers.has("content-security-policy")) node.removeHeader("content-security-policy-report-only");
100
+ }, void 0);
101
+ return out;
102
+ }
103
+ function fromReferer(req, origin, basename) {
104
+ const raw = req.headers.get("referer");
105
+ if (!raw) return null;
106
+ let ref;
107
+ try {
108
+ ref = new URL(raw);
109
+ } catch {
110
+ return null;
111
+ }
112
+ if (ref.origin !== origin) return null;
113
+ const path = underBasename(ref.pathname, basename);
114
+ if (path === null) return null;
115
+ const to = path + ref.search;
116
+ return safeReturnTo(to) === to ? to : null;
117
+ }
49
118
  function camada(opts = {}) {
50
119
  const cam = instanceFor(opts);
51
120
  return async ({ request, context }, next) => {
52
- const r = await cam.before(request, requestContext(context));
121
+ const r = await cam.before(request, { ...requestContext(context), navigation: isDataRequest(request) });
53
122
  if (!r) return next();
54
- if (r.response) return r.response;
123
+ if (r.response) return answered(request, context, r.response, opts.basename);
55
124
  guarded2(() => context.set(camadaContext, r.vars), void 0);
56
125
  let res;
57
126
  try {
@@ -73,13 +142,14 @@ function camadaRoute(opts = {}) {
73
142
  const cam = instanceFor(opts);
74
143
  const answer = async ({ request, context }) => {
75
144
  const r = await cam.before(request, requestContext(context));
76
- return r?.response ?? new Response(null, { status: 404 });
145
+ return r?.response ? answered(request, context, r.response, opts.basename) : new Response(null, { status: 404 });
77
146
  };
78
147
  return { loader: answer, action: answer };
79
148
  }
80
149
  export {
81
150
  camada,
82
151
  camadaPeerContext,
152
+ camadaResponseContext,
83
153
  camadaRoute,
84
154
  resetCamada,
85
155
  scriptTag,
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, 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, type RouterContext } from './context.js';\nimport { SDK_ID } from './version.js';\n\nexport type CamadaRemixOptions = FetchCamadaOptions;\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\nexport function instanceFor(opts: CamadaRemixOptions = {}): FetchCamada {\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\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 r.response; // 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/** 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.3\",\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 },\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 \"types\": \"./dist/index.d.ts\",\n \"import\": \"./dist/index.js\",\n \"require\": \"./dist/index.cjs\"\n }\n },\n \"main\": \"./dist/index.cjs\",\n \"module\": \"./dist/index.js\",\n \"types\": \"./dist/index.d.ts\",\n \"dependencies\": {\n \"@camada/core\": \"file:../camada-core\",\n \"@camada/browser\": \"file:../camada-browser\"\n },\n \"peerDependencies\": {\n \"react-router\": \">=7.9\"\n },\n \"devDependencies\": {\n \"@types/node\": \"^22.10.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, 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 ?? 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","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.3",
3
+ "version": "0.2.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "engines": {
@@ -14,17 +14,22 @@
14
14
  "description": "camada SDK for React Router framework mode (Remix): snapshot enforcement in a root middleware, first-party challenge and beacon route, track() outcomes",
15
15
  "exports": {
16
16
  ".": {
17
- "types": "./dist/index.d.ts",
18
- "import": "./dist/index.js",
19
- "require": "./dist/index.cjs"
17
+ "import": {
18
+ "types": "./dist/index.d.ts",
19
+ "default": "./dist/index.js"
20
+ },
21
+ "require": {
22
+ "types": "./dist/index.d.cts",
23
+ "default": "./dist/index.cjs"
24
+ }
20
25
  }
21
26
  },
22
27
  "main": "./dist/index.cjs",
23
28
  "module": "./dist/index.js",
24
29
  "types": "./dist/index.d.ts",
25
30
  "dependencies": {
26
- "@camada/core": "^0.5.1",
27
- "@camada/browser": "^0.3.0"
31
+ "@camada/browser": "^0.4.0",
32
+ "@camada/core": "^0.6.0"
28
33
  },
29
34
  "peerDependencies": {
30
35
  "react-router": ">=7.9"