@camada/remix 0.1.2 → 0.1.4
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 +68 -15
- package/dist/index.cjs +73 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +14 -2
- package/dist/index.d.ts +14 -2
- package/dist/index.js +73 -5
- package/dist/index.js.map +1 -1
- package/package.json +11 -6
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'), //
|
|
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
|
-
|
|
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):
|
|
@@ -80,8 +82,13 @@ fill one event queue. Two mounts with different keys or URLs each get their own.
|
|
|
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
84
|
6. **Challenge** → a `403` proof-of-work page for HTML navigations, `403
|
|
83
|
-
{"error":"challenge_required"}` for
|
|
84
|
-
work, sets `_cch` and 302s back.
|
|
85
|
+
{"error":"challenge_required"}` for API and `fetch()` calls; `POST /_cam/challenge` verifies
|
|
86
|
+
the work, sets `_cch` and 302s back. A client-side navigation (React Router fetching
|
|
87
|
+
`<path>.data` for a `<Link>`) is answered in React Router's own redirect form instead
|
|
88
|
+
(`204` + `X-Remix-Redirect` to the same page + `X-Remix-Reload-Document`), so the browser
|
|
89
|
+
reloads the page as a document and meets the challenge, rather than your error boundary
|
|
90
|
+
rendering a 403. A challenged submission (`fetcher.submit`, `<Form action>`, any non-GET
|
|
91
|
+
`.data`) reloads the same-origin page it came from (its `Referer`), else the data path.
|
|
85
92
|
7. Otherwise `next()` runs your loaders and actions, and the settled response ships one
|
|
86
93
|
batched, redacted event with its real status (Authorization and Cookie values never leave
|
|
87
94
|
the process; credential-looking query values are scrubbed — see `@camada/core`). A first
|
|
@@ -98,22 +105,42 @@ this tap vouches for no address. A custom server knows the socket and can say so
|
|
|
98
105
|
// server.ts (Express)
|
|
99
106
|
import { createRequestHandler } from '@react-router/express';
|
|
100
107
|
import { RouterContextProvider } from 'react-router';
|
|
101
|
-
import { camadaPeerContext } from '@camada/remix';
|
|
108
|
+
import { camadaPeerContext, camadaResponseContext } from '@camada/remix';
|
|
102
109
|
|
|
103
110
|
app.all('*', createRequestHandler({
|
|
104
111
|
build,
|
|
105
|
-
getLoadContext(req) {
|
|
112
|
+
getLoadContext(req, res) {
|
|
106
113
|
const context = new RouterContextProvider();
|
|
107
114
|
context.set(camadaPeerContext, req.socket.remoteAddress ?? null); // the address Node vouches for, never a header
|
|
115
|
+
context.set(camadaResponseContext, res); // lets camada's answers replace headers set before the handler (below)
|
|
108
116
|
return context;
|
|
109
117
|
},
|
|
110
118
|
}));
|
|
111
119
|
```
|
|
112
120
|
|
|
121
|
+
`@react-router/express` copies a Response's headers onto `res` with `res.append`, so a header an
|
|
122
|
+
Express middleware set before the handler goes out next to camada's own. With
|
|
123
|
+
[helmet](https://helmetjs.github.io/) in front, the challenge page would carry two
|
|
124
|
+
`Content-Security-Policy` headers: browsers enforce both, and helmet's blocks the page's inline
|
|
125
|
+
solver, so a challenged visitor can never pass. Handing camada `res` through
|
|
126
|
+
`camadaResponseContext` (as above) fixes that: before it answers a request itself (block,
|
|
127
|
+
challenge, verify, beacon script), camada removes from `res` every header its answer sets, plus a
|
|
128
|
+
report-only policy under the challenge page's own. Set-Cookie headers are left alone. Without it,
|
|
129
|
+
mount helmet after the React Router handler, or set your policy from React Router's
|
|
130
|
+
`entry.server` / route `headers` instead of Express.
|
|
131
|
+
|
|
113
132
|
Behind a load balancer, set `CAMADA_TRUSTED_PROXY=hops:1` (or `cidrs:…`) — or the
|
|
114
133
|
`trustedProxy` option — and `X-Forwarded-For` is read one hop behind that peer. Never put a
|
|
115
134
|
header value in `camadaPeerContext`: that is the one address camada takes on faith.
|
|
116
135
|
|
|
136
|
+
Without a vouched peer (no `camadaPeerContext`, e.g. on a serverless host), this adapter keeps
|
|
137
|
+
the plain `X-Forwarded-For` walk under your trusted-proxy config. With a peer, a `cidrs`
|
|
138
|
+
config is walked only when the peer itself is a trusted proxy or loopback. Without one there is
|
|
139
|
+
nothing to check, so the walk trusts that every request came through your edge (the platform,
|
|
140
|
+
load balancer or CDN in front of the app). That holds when the edge is the only way in. If the
|
|
141
|
+
origin is reachable directly, a client can send its own `X-Forwarded-For`, so lock the origin to
|
|
142
|
+
the edge.
|
|
143
|
+
|
|
117
144
|
## Options
|
|
118
145
|
|
|
119
146
|
| option | default | meaning |
|
|
@@ -123,11 +150,12 @@ header value in `camadaPeerContext`: that is the one address camada takes on fai
|
|
|
123
150
|
| `snapshotUrl` | `<ingestUrl>/snapshot` | snapshot endpoint |
|
|
124
151
|
| `trustedProxy` | server config | `none` / `vercel` / `hops:N` / `cidrs:a,b`, or the parsed object |
|
|
125
152
|
| `challenge` | `true` | serve the proof-of-work page for `challenge` verdicts |
|
|
126
|
-
| `challengePath` | `/
|
|
153
|
+
| `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
154
|
| `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 |
|
|
155
|
+
| `scriptPath` | `/_cam/b.js` (`<basename>/_cam/b.js` if `basename` is set) | where the first-party beacon script is served |
|
|
156
|
+
| `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
157
|
| `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` |
|
|
158
|
+
| `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
159
|
| `env` | `process.env` | overrides the process env (tests, and apps that read config themselves) |
|
|
132
160
|
|
|
133
161
|
`CAMADA_CHALLENGE=0` in the env switches the challenge off without a code change.
|
|
@@ -173,12 +201,37 @@ never the one the body claims. Both endpoints sit behind the verdict: a blocked
|
|
|
173
201
|
there too. The tag is `''` when camada is off for the request or the project turned the beacon
|
|
174
202
|
off in its settings, and the endpoints stand down with it.
|
|
175
203
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
middleware
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
204
|
+
## The routes camada needs
|
|
205
|
+
|
|
206
|
+
React Router runs middleware only for a URL some route matches. An unmatched URL gets the
|
|
207
|
+
router's 404 before any middleware runs, so camada never sees it. Its own endpoints therefore
|
|
208
|
+
need a route, and `route('/_cam/*', 'routes/cam.tsx')` with `camadaRoute()` is that route. It
|
|
209
|
+
covers three endpoints:
|
|
210
|
+
|
|
211
|
+
| endpoint | what it is |
|
|
212
|
+
|---|---|
|
|
213
|
+
| `GET /_cam/b.js` | the beacon script |
|
|
214
|
+
| `POST /_cam/fp` | the beacon relay |
|
|
215
|
+
| `POST /_cam/challenge` | the challenge page's form: verifies the work and sets `_cch` |
|
|
216
|
+
|
|
217
|
+
With a React Router `basename` (say `/app`) the URLs carry it: `/app/_cam/b.js`, `/app/_cam/fp`,
|
|
218
|
+
`/app/_cam/challenge`. Pass `basename: '/app'` to both `camada()` and `camadaRoute()` and those
|
|
219
|
+
are the defaults; the route itself stays `route('/_cam/*', ...)` in `routes.ts`, because the
|
|
220
|
+
router matches routes after stripping the basename. Without the option the defaults sit outside
|
|
221
|
+
the basename, the router 404s the challenge form, and no challenged visitor can pass.
|
|
222
|
+
|
|
223
|
+
With the root middleware mounted it answers first, and the route's loader and action are the
|
|
224
|
+
fallback (a middleware mounted lower, or none at all). They re-run the verdict, so a blocked
|
|
225
|
+
client gets a 403 there as well. A custom `scriptPath` / `fpPath` must share a directory (the
|
|
226
|
+
script derives the post path from its own `src`), `fpPath` and the challenge path default to that same
|
|
227
|
+
directory (`/api/cam/b.js` → `/api/cam/fp`, `/api/cam/challenge`), and the route must cover it. An explicit
|
|
228
|
+
`challengePath` outside that route 404s in the router, and no challenged visitor can pass.
|
|
229
|
+
|
|
230
|
+
What is **not** captured: requests to URLs no route matches (scanner probes such as
|
|
231
|
+
`/wp-admin/setup.php` or `/.env`). They never reach the middleware, so they ship no event and a
|
|
232
|
+
path block on them is not counted. Your app's 404 still answers them. To capture them as well,
|
|
233
|
+
add a splat route (`route('*', 'routes/not-found.tsx')`) whose loader throws your 404: the
|
|
234
|
+
middleware then runs for every URL.
|
|
182
235
|
|
|
183
236
|
## App-context events
|
|
184
237
|
|
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.
|
|
61
|
+
var version = "0.1.4";
|
|
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
|
-
|
|
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,67 @@ 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
|
+
function forNavigation(req, res, basename) {
|
|
110
|
+
return (0, import_core2.guarded)(() => {
|
|
111
|
+
if (res.status !== 403 || res.headers.get("x-camada-challenge") !== "1") return res;
|
|
112
|
+
const url = new URL(req.url);
|
|
113
|
+
if (!url.pathname.endsWith(".data")) return res;
|
|
114
|
+
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
|
+
url.searchParams.delete("_routes");
|
|
116
|
+
let to = underBasename(url.pathname, basename) ?? "/";
|
|
117
|
+
to = (0, import_core2.safeReturnTo)(to + url.search);
|
|
118
|
+
if (req.method !== "GET" && req.method !== "HEAD") to = fromReferer(req, url.origin, basename) ?? to;
|
|
119
|
+
void res.body?.cancel().catch(() => {
|
|
120
|
+
});
|
|
121
|
+
return new Response(null, {
|
|
122
|
+
status: 204,
|
|
123
|
+
headers: {
|
|
124
|
+
"X-Remix-Redirect": to,
|
|
125
|
+
"X-Remix-Status": "302",
|
|
126
|
+
"X-Remix-Reload-Document": "true",
|
|
127
|
+
"cache-control": "no-store",
|
|
128
|
+
"x-camada-challenge": "1"
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
}, res);
|
|
132
|
+
}
|
|
133
|
+
function answered(req, context, res, basename) {
|
|
134
|
+
const out = forNavigation(req, res, basename);
|
|
135
|
+
(0, import_core2.guarded)(() => {
|
|
136
|
+
const node = context.get(camadaResponseContext);
|
|
137
|
+
if (!node || typeof node.removeHeader !== "function") return;
|
|
138
|
+
for (const k of out.headers.keys()) if (k !== "set-cookie") node.removeHeader(k);
|
|
139
|
+
if (out.headers.has("content-security-policy")) node.removeHeader("content-security-policy-report-only");
|
|
140
|
+
}, void 0);
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
function fromReferer(req, origin, basename) {
|
|
144
|
+
const raw = req.headers.get("referer");
|
|
145
|
+
if (!raw) return null;
|
|
146
|
+
let ref;
|
|
147
|
+
try {
|
|
148
|
+
ref = new URL(raw);
|
|
149
|
+
} catch {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
if (ref.origin !== origin) return null;
|
|
153
|
+
const path = underBasename(ref.pathname, basename);
|
|
154
|
+
if (path === null) return null;
|
|
155
|
+
const to = path + ref.search;
|
|
156
|
+
return (0, import_core2.safeReturnTo)(to) === to ? to : null;
|
|
157
|
+
}
|
|
90
158
|
function camada(opts = {}) {
|
|
91
159
|
const cam = instanceFor(opts);
|
|
92
160
|
return async ({ request, context }, next) => {
|
|
93
161
|
const r = await cam.before(request, requestContext(context));
|
|
94
162
|
if (!r) return next();
|
|
95
|
-
if (r.response) return r.response;
|
|
163
|
+
if (r.response) return answered(request, context, r.response, opts.basename);
|
|
96
164
|
(0, import_core2.guarded)(() => context.set(camadaContext, r.vars), void 0);
|
|
97
165
|
let res;
|
|
98
166
|
try {
|
|
@@ -114,7 +182,7 @@ function camadaRoute(opts = {}) {
|
|
|
114
182
|
const cam = instanceFor(opts);
|
|
115
183
|
const answer = async ({ request, context }) => {
|
|
116
184
|
const r = await cam.before(request, requestContext(context));
|
|
117
|
-
return r?.response
|
|
185
|
+
return r?.response ? answered(request, context, r.response, opts.basename) : new Response(null, { status: 404 });
|
|
118
186
|
};
|
|
119
187
|
return { loader: answer, action: answer };
|
|
120
188
|
}
|
|
@@ -122,6 +190,7 @@ function camadaRoute(opts = {}) {
|
|
|
122
190
|
0 && (module.exports = {
|
|
123
191
|
camada,
|
|
124
192
|
camadaPeerContext,
|
|
193
|
+
camadaResponseContext,
|
|
125
194
|
camadaRoute,
|
|
126
195
|
resetCamada,
|
|
127
196
|
scriptTag,
|
package/dist/index.cjs.map
CHANGED
|
@@ -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.2\",\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/** 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":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACOA,yBAAiB;AACjB,IAAAA,eAAiD;AACjD,IAAAC,gBAA+I;;;ACJ/I,0BAA0D;AAC1D,kBAAwB;AACxB,mBAA+E;AAKxE,IAAM,oBAAgB,mCAAgC,IAAI;AAG1D,IAAM,wBAAoB,mCAA6B,IAAI;AAM3D,IAAM,4BAAwB,mCAA2D,IAAI;AAKpG,IAAM,WAAW,CAAC,gBAAkD,qBAAQ,MAAM,QAAQ,IAAI,aAAa,KAAK,QAAW,MAAS;AAS7H,IAAM,QAAQ,CAAC,SAAwB,OAAe,aAA4C,aAAAC,OAAU,SAAS,OAAO,GAAG,OAAO,IAAI;AAG1I,IAAM,YAAY,CAAC,gBAAmC,aAAAC,WAAc,SAAS,OAAO,CAAC;;;ACrC1F,WAAQ;AACR,cAAW;;;ACCN,IAAM,SAAS,GAAG,IAAI,IAAI,OAAO;;;AHuBxC,IAAM,YAAY,oBAAI,IAAiB;AACvC,IAAM,QAAQ,oBAAI,IAAwD;AAG1E,IAAM,aAAa,CAAC,MAClB,KAAK,UAAU,GAAG,CAAC,GAAG,MAAgB,KAAK,OAAO,MAAM,YAAY,CAAC,MAAM,QAAQ,CAAC,IAAI,OAAO,YAAY,OAAO,QAAQ,CAAC,EAAE,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,MAAO,IAAI,IAAI,KAAK,IAAI,IAAI,IAAI,CAAE,CAAC,IAAI,CAAE;AAG9K,IAAM,cAAc,CAAC,YAAgCC,WAA0B,cAAc,cAAc,QAAQ,UAAUA,KAAI;AAEjI,SAAS,YAAY,QAA4B,CAAC,GAAgB;AAMvE,QAAM,EAAE,UAAU,GAAG,KAAK,IAAI;AAG9B,QAAM,QAAQ,YAAY,IAAI,QAAQ,QAAQ,EAAE;AAChD,QAAM,aAAa,KAAK,eAAe,OAAO,GAAG,IAAI,eAAe;AACpE,QAAM,SAAS,KAAK,UAAU,YAAY,YAAY,IAAI;AAC1D,QAAM,OAAO;AAAA,IACX,GAAG;AAAA,IACH,GAAI,aAAa,EAAE,WAAW,IAAI,CAAC;AAAA,IACnC;AAAA,IACA,eAAe,KAAK,iBAAiB,YAAY,YAAY,WAAW;AAAA,EAC1E;AACA,QAAM,EAAE,WAAW,GAAG,aAAa,IAAI;AACvC,QAAM,MAAM,WAAW,YAAY;AACnC,MAAI,SAAS,MAAM,IAAI,SAAS;AAChC,MAAI,CAAC,OAAQ,OAAM,IAAI,WAAY,SAAS,oBAAI,IAAI,CAAE;AACtD,MAAI,MAAM,OAAO,IAAI,GAAG;AACxB,MAAI,CAAC,KAAK;AACR,cAAM,iCAAkB,EAAE,KAAK,wBAAW,KAAK,QAAQ,yBAAAC,QAAK,GAAG,IAAI;AACnE,WAAO,IAAI,KAAK,GAAG;AACnB,cAAU,IAAI,GAAG;AAAA,EACnB;AACA,SAAO;AACT;AAGO,SAAS,cAAoB;AAClC,aAAW,OAAO,UAAW,KAAI,MAAM;AACzC;AAIO,SAAS,eAAe,SAA6C;AAC1E,SAAO;AAAA,IACL,UAAM,sBAAQ,MAAM,QAAQ,IAAI,iBAAiB,GAAG,IAAI;AAAA,IACxD,KAAK,WAAW,SAAS;AAAA,EAC3B;AACF;AAGA,SAAS,cAAc,MAAc,UAA6C;AAChF,QAAM,QAAQ,YAAY,IAAI,QAAQ,QAAQ,EAAE;AAChD,MAAI,CAAC,KAAM,QAAO;AAClB,MAAI,SAAS,KAAM,QAAO;AAC1B,SAAO,KAAK,WAAW,OAAO,GAAG,IAAI,KAAK,MAAM,KAAK,MAAM,IAAI;AACjE;AAaO,SAAS,cAAc,KAAc,KAAe,UAA6B;AACtF,aAAO,sBAAQ,MAAM;AACnB,QAAI,IAAI,WAAW,OAAO,IAAI,QAAQ,IAAI,oBAAoB,MAAM,IAAK,QAAO;AAChF,UAAM,MAAM,IAAI,IAAI,IAAI,GAAG;AAC3B,QAAI,CAAC,IAAI,SAAS,SAAS,OAAO,EAAG,QAAO;AAC5C,QAAI,WAAW,IAAI,SAAS,SAAS,SAAS,IAAI,IAAI,SAAS,MAAM,GAAG,CAAC,SAAS,MAAM,IACpF,IAAI,SAAS,SAAS,aAAa,IAAI,IAAI,SAAS,MAAM,GAAG,CAAC,aAAa,MAAM,IACjF,IAAI,SAAS,MAAM,GAAG,CAAC,QAAQ,MAAM;AACzC,QAAI,aAAa,OAAO,SAAS;AACjC,QAAI,KAAK,cAAc,IAAI,UAAU,QAAQ,KAAK;AAClD,aAAK,2BAAa,KAAK,IAAI,MAAM;AACjC,QAAI,IAAI,WAAW,SAAS,IAAI,WAAW,OAAQ,MAAK,YAAY,KAAK,IAAI,QAAQ,QAAQ,KAAK;AAClG,SAAK,IAAI,MAAM,OAAO,EAAE,MAAM,MAAM;AAAA,IAAC,CAAC;AACtC,WAAO,IAAI,SAAS,MAAM;AAAA,MACxB,QAAQ;AAAA,MACR,SAAS;AAAA,QACP,oBAAoB;AAAA,QAAI,kBAAkB;AAAA,QAAO,2BAA2B;AAAA,QAC5E,iBAAiB;AAAA,QAAY,sBAAsB;AAAA,MACrD;AAAA,IACF,CAAC;AAAA,EACH,GAAG,GAAG;AACR;AAMO,SAAS,SAAS,KAAc,SAAwB,KAAe,UAA6B;AACzG,QAAM,MAAM,cAAc,KAAK,KAAK,QAAQ;AAC5C,4BAAQ,MAAM;AACZ,UAAM,OAAO,QAAQ,IAAI,qBAAqB;AAC9C,QAAI,CAAC,QAAQ,OAAO,KAAK,iBAAiB,WAAY;AACtD,eAAW,KAAK,IAAI,QAAQ,KAAK,EAAG,KAAI,MAAM,aAAc,MAAK,aAAa,CAAC;AAC/E,QAAI,IAAI,QAAQ,IAAI,yBAAyB,EAAG,MAAK,aAAa,qCAAqC;AAAA,EACzG,GAAG,MAAS;AACZ,SAAO;AACT;AAGA,SAAS,YAAY,KAAc,QAAgB,UAA6C;AAC9F,QAAM,MAAM,IAAI,QAAQ,IAAI,SAAS;AACrC,MAAI,CAAC,IAAK,QAAO;AACjB,MAAI;AACJ,MAAI;AAAE,UAAM,IAAI,IAAI,GAAG;AAAA,EAAG,QAAQ;AAAE,WAAO;AAAA,EAAM;AACjD,MAAI,IAAI,WAAW,OAAQ,QAAO;AAClC,QAAM,OAAO,cAAc,IAAI,UAAU,QAAQ;AACjD,MAAI,SAAS,KAAM,QAAO;AAC1B,QAAM,KAAK,OAAO,IAAI;AACtB,aAAO,2BAAa,EAAE,MAAM,KAAK,KAAK;AACxC;AAEO,SAAS,OAAO,OAA2B,CAAC,GAAiC;AAClF,QAAM,MAAM,YAAY,IAAI;AAC5B,SAAO,OAAO,EAAE,SAAS,QAAQ,GAAG,SAAS;AAC3C,UAAM,IAAI,MAAM,IAAI,OAAO,SAAS,eAAe,OAAO,CAAC;AAC3D,QAAI,CAAC,EAAG,QAAO,KAAK;AACpB,QAAI,EAAE,SAAU,QAAO,SAAS,SAAS,SAAS,EAAE,UAAU,KAAK,QAAQ;AAC3E,8BAAQ,MAAM,QAAQ,IAAI,eAAe,EAAE,IAAI,GAAG,MAAS;AAC3D,QAAI;AACJ,QAAI;AACF,YAAM,MAAM,KAAK;AAAA,IACnB,SAAS,KAAK;AAGZ,UAAI,eAAe,UAAU;AAC3B,YAAI,MAAM,SAAS,EAAE,MAAM,IAAI,MAAM;AACrC,kBAAM,uBAAQ,EAAE,KAAK,oBAAgB,6BAAc,KAAK,EAAE,KAAK,aAAa,IAAI,KAAK,EAAE,IAAI;AAAA,MAC7F;AACA,UAAI,MAAM,SAAS,EAAE,MAAM,GAAG;AAC9B,YAAM;AAAA,IACR;AACA,WAAO,IAAI,OAAO,SAAS,EAAE,MAAM,EAAE,KAAK,oBAAgB,6BAAc,KAAK,EAAE,KAAK,aAAa,IAAI,GAAG;AAAA,EAC1G;AACF;;;AInKO,SAAS,YAAY,OAA2B,CAAC,GAAiF;AACvI,QAAM,MAAM,YAAY,IAAI;AAC5B,QAAM,SAA2D,OAAO,EAAE,SAAS,QAAQ,MAAM;AAC/F,UAAM,IAAI,MAAM,IAAI,OAAO,SAAS,eAAe,OAAO,CAAC;AAC3D,WAAO,GAAG,WAAW,SAAS,SAAS,SAAS,EAAE,UAAU,KAAK,QAAQ,IAAI,IAAI,SAAS,MAAM,EAAE,QAAQ,IAAI,CAAC;AAAA,EACjH;AACA,SAAO,EAAE,QAAQ,QAAQ,QAAQ,OAAO;AAC1C;","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
|
-
|
|
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
|
-
|
|
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.
|
|
19
|
+
var version = "0.1.4";
|
|
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
|
-
|
|
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,67 @@ 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
|
+
function forNavigation(req, res, basename) {
|
|
68
|
+
return guarded2(() => {
|
|
69
|
+
if (res.status !== 403 || res.headers.get("x-camada-challenge") !== "1") return res;
|
|
70
|
+
const url = new URL(req.url);
|
|
71
|
+
if (!url.pathname.endsWith(".data")) return res;
|
|
72
|
+
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
|
+
url.searchParams.delete("_routes");
|
|
74
|
+
let to = underBasename(url.pathname, basename) ?? "/";
|
|
75
|
+
to = safeReturnTo(to + url.search);
|
|
76
|
+
if (req.method !== "GET" && req.method !== "HEAD") to = fromReferer(req, url.origin, basename) ?? to;
|
|
77
|
+
void res.body?.cancel().catch(() => {
|
|
78
|
+
});
|
|
79
|
+
return new Response(null, {
|
|
80
|
+
status: 204,
|
|
81
|
+
headers: {
|
|
82
|
+
"X-Remix-Redirect": to,
|
|
83
|
+
"X-Remix-Status": "302",
|
|
84
|
+
"X-Remix-Reload-Document": "true",
|
|
85
|
+
"cache-control": "no-store",
|
|
86
|
+
"x-camada-challenge": "1"
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
}, res);
|
|
90
|
+
}
|
|
91
|
+
function answered(req, context, res, basename) {
|
|
92
|
+
const out = forNavigation(req, res, basename);
|
|
93
|
+
guarded2(() => {
|
|
94
|
+
const node = context.get(camadaResponseContext);
|
|
95
|
+
if (!node || typeof node.removeHeader !== "function") return;
|
|
96
|
+
for (const k of out.headers.keys()) if (k !== "set-cookie") node.removeHeader(k);
|
|
97
|
+
if (out.headers.has("content-security-policy")) node.removeHeader("content-security-policy-report-only");
|
|
98
|
+
}, void 0);
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
function fromReferer(req, origin, basename) {
|
|
102
|
+
const raw = req.headers.get("referer");
|
|
103
|
+
if (!raw) return null;
|
|
104
|
+
let ref;
|
|
105
|
+
try {
|
|
106
|
+
ref = new URL(raw);
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
if (ref.origin !== origin) return null;
|
|
111
|
+
const path = underBasename(ref.pathname, basename);
|
|
112
|
+
if (path === null) return null;
|
|
113
|
+
const to = path + ref.search;
|
|
114
|
+
return safeReturnTo(to) === to ? to : null;
|
|
115
|
+
}
|
|
49
116
|
function camada(opts = {}) {
|
|
50
117
|
const cam = instanceFor(opts);
|
|
51
118
|
return async ({ request, context }, next) => {
|
|
52
119
|
const r = await cam.before(request, requestContext(context));
|
|
53
120
|
if (!r) return next();
|
|
54
|
-
if (r.response) return r.response;
|
|
121
|
+
if (r.response) return answered(request, context, r.response, opts.basename);
|
|
55
122
|
guarded2(() => context.set(camadaContext, r.vars), void 0);
|
|
56
123
|
let res;
|
|
57
124
|
try {
|
|
@@ -73,13 +140,14 @@ function camadaRoute(opts = {}) {
|
|
|
73
140
|
const cam = instanceFor(opts);
|
|
74
141
|
const answer = async ({ request, context }) => {
|
|
75
142
|
const r = await cam.before(request, requestContext(context));
|
|
76
|
-
return r?.response
|
|
143
|
+
return r?.response ? answered(request, context, r.response, opts.basename) : new Response(null, { status: 404 });
|
|
77
144
|
};
|
|
78
145
|
return { loader: answer, action: answer };
|
|
79
146
|
}
|
|
80
147
|
export {
|
|
81
148
|
camada,
|
|
82
149
|
camadaPeerContext,
|
|
150
|
+
camadaResponseContext,
|
|
83
151
|
camadaRoute,
|
|
84
152
|
resetCamada,
|
|
85
153
|
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.2\",\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/** 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"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camada/remix",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
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
|
-
"
|
|
18
|
-
|
|
19
|
-
|
|
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/
|
|
27
|
-
"@camada/
|
|
31
|
+
"@camada/browser": "^0.3.2",
|
|
32
|
+
"@camada/core": "^0.5.2"
|
|
28
33
|
},
|
|
29
34
|
"peerDependencies": {
|
|
30
35
|
"react-router": ">=7.9"
|