@camada/sveltekit 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
@@ -67,8 +67,22 @@ if camada were not installed.
67
67
  4. **Block** → `403` with `x-block-reason` before `resolve()`; the event still ships, with
68
68
  `st: 403` and `blk: <reason>` so the analyst counts SDK blocks apart from your own 403s.
69
69
  5. **Skip** → a skip rule or the allow list wins over a wider block.
70
- 6. **Challenge** → a `403` proof-of-work page (HTML navigations) or `403 {"error":"challenge_required"}`
71
- (everything else); `POST /__camada/challenge` verifies the solution, sets `_cch` and 302s back.
70
+ 6. **Challenge** → a `403` proof-of-work page (HTML navigations); API and `fetch()` calls
71
+ pass to your app with `x-camada-challenge: 1` on the response (and `chs: 1` on the event) so your
72
+ frontend can choose to challenge; `POST /__camada/challenge` verifies the solution, sets `_cch` and 302s back.
73
+ The solve is a form POST, so SvelteKit's own cross-site form check (`csrf.checkOrigin`) applies
74
+ to it: on **adapter-node served over plain http** (a local run, or a proxy that terminates TLS
75
+ without forwarding the scheme) set the `ORIGIN` env var to the public origin
76
+ (`ORIGIN=http://localhost:3000 node build`), or `PROTOCOL_HEADER` / `HOST_HEADER` (e.g. `PROTOCOL_HEADER=x-forwarded-proto HOST_HEADER=x-forwarded-host`), or
77
+ SvelteKit rejects the POST with "Cross-site POST form submissions are forbidden" before camada
78
+ sees it and the visitor cannot pass.
79
+ A client-side navigation (SvelteKit fetching `<path>/__data.json` for an `<a>` to a page with a
80
+ server `load`) gets a data redirect to `/_cam/reload?to=<page>` instead, beside the beacon
81
+ (`scriptPath`'s directory). No app route matches that URL, so the client reloads it as a
82
+ document, and camada 302s that request to the page, which shows the challenge. Your error
83
+ page never renders a 403 for it. The bounce answers only while camada is active (a key, and no
84
+ `CAMADA_DISABLED=1`); otherwise the path is your app's. See [The bounce path](#the-bounce-path-_camreload)
85
+ if you have a root catch-all route.
72
86
  7. Otherwise `resolve(event)` runs, and the settled response ships one batched, redacted event
73
87
  with its real status (Authorization and Cookie values never leave the process; credential-looking
74
88
  query values are scrubbed — see `@camada/core`).
@@ -77,6 +91,25 @@ A first visit gets the shared `_sfp` session cookie through `event.cookies.set()
77
91
  adds the header to whatever `resolve()` returns, so a `redirect()` or a raw `Response` passes
78
92
  through untouched.
79
93
 
94
+ ## The bounce path (`/_cam/reload`)
95
+
96
+ `GET /_cam/reload?to=<page>` (in `scriptPath`'s directory) is camada's: it 302s to `to` (a
97
+ same-site path only, anything else goes to `/`, `no-store`). It is how a challenged client-side
98
+ navigation turns into a document request (see step 6). It only works while no app route matches
99
+ `/_cam/reload`, since SvelteKit's client navigates client-side to any URL a route matches.
100
+
101
+ A root catch-all (`src/routes/[...rest]`) matches it, and the visitor would see that route
102
+ instead of the challenge. Keep the catch-all off camada's directory with a param matcher:
103
+
104
+ ```ts
105
+ // src/params/notcam.ts
106
+ import type { ParamMatcher } from '@sveltejs/kit';
107
+ export const match = ((param) => !param.startsWith('_cam/')) satisfies ParamMatcher;
108
+ ```
109
+
110
+ and rename the route directory `src/routes/[...rest]` → `src/routes/[...rest=notcam]`. With a
111
+ custom `scriptPath`, test for its directory instead (`/api/cam/b.js` → `api/cam/`).
112
+
80
113
  ## Options
81
114
 
82
115
  | option | default | meaning |
package/dist/index.cjs CHANGED
@@ -44,7 +44,7 @@ var import_iife_string = __toESM(require("@camada/browser/iife-string"), 1);
44
44
 
45
45
  // package.json
46
46
  var name = "@camada/sveltekit";
47
- var version = "0.1.3";
47
+ var version = "0.2.0";
48
48
 
49
49
  // src/version.ts
50
50
  var SDK_ID = `${name}/${version}`;
@@ -74,14 +74,50 @@ function contextOf(event) {
74
74
  httpVersion: cf?.httpProtocol ? cf.httpProtocol.replace(/^HTTP\//i, "") : null
75
75
  };
76
76
  }
77
+ var DATA_SUFFIX = "/__data.json";
78
+ var HTML_DATA_SUFFIX = ".html__data.json";
79
+ function dataRequestPage(url) {
80
+ let path;
81
+ if (url.pathname.endsWith(HTML_DATA_SUFFIX)) path = url.pathname.slice(0, -HTML_DATA_SUFFIX.length) + ".html";
82
+ else if (url.pathname.endsWith(DATA_SUFFIX)) path = url.pathname.slice(0, -DATA_SUFFIX.length) || "/";
83
+ else return null;
84
+ const q = new URLSearchParams(url.search);
85
+ if (q.get("x-sveltekit-trailing-slash") === "1" && !path.endsWith("/")) path += "/";
86
+ q.delete("x-sveltekit-trailing-slash");
87
+ q.delete("x-sveltekit-invalidated");
88
+ const search = q.toString();
89
+ return path + (search ? "?" + search : "");
90
+ }
91
+ function forNavigation(req, res, bouncePath) {
92
+ return (0, import_core.guarded)(() => {
93
+ if (res.status !== 403 || res.headers.get("x-camada-challenge") !== "1") return res;
94
+ const page = dataRequestPage(new URL(req.url));
95
+ if (page === null) return res;
96
+ void res.body?.cancel().catch(() => {
97
+ });
98
+ return new Response(JSON.stringify({ type: "redirect", location: `${bouncePath}?to=${encodeURIComponent(page)}` }), {
99
+ status: 200,
100
+ headers: { "content-type": "application/json", "cache-control": "no-store", "x-camada-challenge": "1" }
101
+ });
102
+ }, res);
103
+ }
77
104
  function camada(opts = {}) {
78
105
  const cam = (0, import_fetch.createFetchCamada)({ tap: import_core.TAP_SVELTEKIT, sdk: SDK_ID, iife: import_iife_string.default }, opts);
79
106
  instances.add(cam);
107
+ const bouncePath = (opts.scriptPath ?? "/_cam/b.js").replace(/[^/]*$/, "reload");
80
108
  return async ({ event, resolve }) => {
81
109
  if (event.isSubRequest) return resolve(event);
82
- const r = await cam.before(event.request, contextOf(event));
110
+ const ctx = contextOf(event);
111
+ const bounce = (0, import_core.guarded)(() => {
112
+ const url = new URL(event.request.url);
113
+ if (event.request.method !== "GET" || url.pathname !== bouncePath) return null;
114
+ if (!cam.active(ctx)) return null;
115
+ return new Response(null, { status: 302, headers: { location: (0, import_core.safeReturnTo)(url.searchParams.get("to")), "cache-control": "no-store" } });
116
+ }, null);
117
+ if (bounce) return bounce;
118
+ const r = await cam.before(event.request, { ...ctx, navigation: event.isDataRequest });
83
119
  if (!r) return resolve(event);
84
- if (r.response) return r.response;
120
+ if (r.response) return forNavigation(event.request, r.response, bouncePath);
85
121
  const vars = r.vars;
86
122
  (0, import_core.guarded)(() => {
87
123
  event.locals[VAR] = vars;
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/camada.ts","../package.json","../src/version.ts"],"sourcesContent":["// @camada/sveltekit — the one-line install for a SvelteKit app, in src/hooks.server.ts:\n// export const handle = camada(); // env: CAMADA_KEY (+ CAMADA_INGEST_URL / CAMADA_SNAPSHOT_URL in dev)\n// scriptTag(event) // the first-party beacon, returned from a load() into the page\n// track(event, 'login_failed', { user }) // an outcome the wire cannot show\nexport { camada, track, scriptTag, resetCamada, type CamadaSvelteKitOptions, type CamadaSvelteKitVars } from './camada.js';\n","// @camada/sveltekit — the `handle` hook that binds @camada/core/fetch to a SvelteKit app. The\n// pipeline (verdict, block, challenge, beacon, the event) lives in core; this file only says what\n// SvelteKit knows: the adapter-resolved client address, the per-request env and execution context\n// an edge adapter hands over, the cookie API, and `event.locals` as the per-request slot.\nimport type { Handle, RequestEvent } from '@sveltejs/kit';\nimport { guarded, TAP_SVELTEKIT } from '@camada/core';\nimport { createFetchCamada, withRid, withSetCookie, track as coreTrack, scriptTag as coreScriptTag, type FetchCamada, type FetchCamadaOptions, type FetchRequestContext, type FetchVars } from '@camada/core/fetch';\nimport iife from '@camada/browser/iife-string';\nimport { SDK_ID } from './version.js';\n\nexport type CamadaSvelteKitOptions = FetchCamadaOptions;\nexport type CamadaSvelteKitVars = FetchVars;\n\nconst VAR = '__camada'; // private: track() and scriptTag() are the API, not event.locals\nconst instances = new Set<FetchCamada>();\n\ntype Env = Record<string, string | undefined>;\n\n/** What adapter-cloudflare puts on `event.platform`; every field is optional because other adapters put nothing there. */\ninterface CfPlatform {\n env?: Env;\n context?: { waitUntil(p: Promise<unknown>): void };\n cf?: { asn?: number; country?: string; tlsClientExtensionsSha1?: string; httpProtocol?: string };\n}\n\nconst slotOf = (event: RequestEvent): FetchVars | undefined =>\n guarded(() => (event.locals as Record<string, unknown>)[VAR] as FetchVars | undefined, undefined);\n\n/** Everything the runtime vouches for. Nothing here is read from a client header. */\nfunction contextOf(event: RequestEvent): FetchRequestContext {\n const platform = event.platform as CfPlatform | undefined;\n // process.env on adapter-node (and nodejs_compat Workers), the bindings on adapter-cloudflare;\n // a binding can be a KV namespace, so the merge is guarded and core reads only CAMADA_* keys.\n const env = guarded<Env>(() => ({ ...(globalThis as { process?: { env?: Env } }).process?.env, ...platform?.env }), {});\n let peer: string | null = null;\n try { peer = event.getClientAddress(); } catch { /* prerendering, or an adapter with no socket: nothing vouched for */ }\n const exec = platform?.context;\n const cf = platform?.cf;\n return {\n peer, env,\n waitUntil: exec ? (p) => exec.waitUntil(p) : undefined,\n asn: cf?.asn ?? null,\n country: cf?.country ?? null,\n tlsx: cf?.tlsClientExtensionsSha1 ?? null,\n // The connection terminates at Cloudflare, so `cf.httpProtocol` is the visitor's own hop.\n httpVersion: cf?.httpProtocol ? cf.httpProtocol.replace(/^HTTP\\//i, '') : null,\n };\n}\n\n/**\n * The `handle` for `src/hooks.server.ts`: `export const handle = camada()`, or first in a\n * `sequence()`. Reads `CAMADA_KEY` (+ `CAMADA_INGEST_URL` / `CAMADA_SNAPSHOT_URL`) from\n * `process.env` or the Cloudflare bindings; inert without a key. One pipeline per call.\n */\nexport function camada(opts: CamadaSvelteKitOptions = {}): Handle {\n const cam = createFetchCamada({ tap: TAP_SVELTEKIT, sdk: SDK_ID, iife }, opts); // mode defaults to lazy in core: the handle may run on edge/serverless\n instances.add(cam);\n return async ({ event, resolve }) => {\n // A server-side `event.fetch` to the app's own routes runs the handle again on a fresh event\n // with no user-agent or accept: one page view is one event, and the page-level verdict must\n // not be re-applied to the server's own load().\n if (event.isSubRequest) return resolve(event);\n const r = await cam.before(event.request, contextOf(event));\n if (!r) return resolve(event);\n if (r.response) return r.response;\n const vars = r.vars;\n guarded(() => { (event.locals as Record<string, unknown>)[VAR] = vars; }, undefined);\n // The _sfp cookie rides the response, not `event.cookies.set`: SvelteKit appends those in place\n // after resolve(), which throws on an immutable Response (a `fetch()` result, `Response.redirect()`).\n // withSetCookie copies such a response; the hook sees it, so the 500 never happens.\n const res = await resolve(event);\n // SvelteKit swaps a 200 carrying an etag for a bodiless 304 after this hook when the client's\n // If-None-Match matches, dropping the body unread: waiting on it would never ship, so an\n // etagged response ships now even when it is an event stream. (That 304 is built by SvelteKit\n // from a fixed header allow-list, so it carries no x-rid; the hook cannot change that.)\n const out = guarded(() => (vars.sessionCookie ? withSetCookie(res, vars.sessionCookie) : res), res);\n if (out.headers.has('etag')) { cam.after(event.request, vars, out.status); return guarded(() => withRid(out, vars), out); }\n return cam.finish(event.request, vars, out); // an SSE body ships once it has gone out; anything else ships now, untouched\n };\n}\n\n/**\n * Records an outcome the app knows and the wire cannot show (`login_failed`, `signup`,\n * `payment_failed`, ...), joined to this request's event by rid and session. The user identifier\n * is HMAC-hashed in-process. Never throws; a no-op where the handle did not run for this event.\n */\nexport const track = (event: RequestEvent, et: string, data?: { user?: string }): Promise<void> => coreTrack(slotOf(event), et, data);\n\n/** The beacon `<script>` tag for this request's page; `''` where the handle did not run or the tenant turned the beacon off. */\nexport const scriptTag = (event: RequestEvent): string => coreScriptTag(slotOf(event));\n\n/** Test/reset hook: stops and drops every engine of every handle this module created. */\nexport function resetCamada(): void {\n for (const cam of instances) cam.reset();\n}\n","{\n \"name\": \"@camada/sveltekit\",\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 SvelteKit (hooks.server.ts handle): snapshot enforcement, first-party challenge and beacon, track() outcomes, waitUntil-flushed event shipping on Cloudflare\",\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 \"@sveltejs/kit\": \">=2\"\n },\n \"devDependencies\": {\n \"@sveltejs/kit\": \"^2.0.0\",\n \"@types/node\": \"^22.10.0\",\n \"svelte\": \"^5.0.0\",\n \"tsup\": \"^8.3.5\",\n \"typescript\": \"^5.7.2\",\n \"vite\": \"^8.0.0\",\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"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACKA,kBAAuC;AACvC,mBAA+L;AAC/L,yBAAiB;;;ACNf,WAAQ;AACR,cAAW;;;ACCN,IAAM,SAAS,GAAG,IAAI,IAAI,OAAO;;;AFUxC,IAAM,MAAM;AACZ,IAAM,YAAY,oBAAI,IAAiB;AAWvC,IAAM,SAAS,CAAC,cACd,qBAAQ,MAAO,MAAM,OAAmC,GAAG,GAA4B,MAAS;AAGlG,SAAS,UAAU,OAA0C;AAC3D,QAAM,WAAW,MAAM;AAGvB,QAAM,UAAM,qBAAa,OAAO,EAAE,GAAI,WAA2C,SAAS,KAAK,GAAG,UAAU,IAAI,IAAI,CAAC,CAAC;AACtH,MAAI,OAAsB;AAC1B,MAAI;AAAE,WAAO,MAAM,iBAAiB;AAAA,EAAG,QAAQ;AAAA,EAAwE;AACvH,QAAM,OAAO,UAAU;AACvB,QAAM,KAAK,UAAU;AACrB,SAAO;AAAA,IACL;AAAA,IAAM;AAAA,IACN,WAAW,OAAO,CAAC,MAAM,KAAK,UAAU,CAAC,IAAI;AAAA,IAC7C,KAAK,IAAI,OAAO;AAAA,IAChB,SAAS,IAAI,WAAW;AAAA,IACxB,MAAM,IAAI,2BAA2B;AAAA;AAAA,IAErC,aAAa,IAAI,eAAe,GAAG,aAAa,QAAQ,YAAY,EAAE,IAAI;AAAA,EAC5E;AACF;AAOO,SAAS,OAAO,OAA+B,CAAC,GAAW;AAChE,QAAM,UAAM,gCAAkB,EAAE,KAAK,2BAAe,KAAK,QAAQ,yBAAAA,QAAK,GAAG,IAAI;AAC7E,YAAU,IAAI,GAAG;AACjB,SAAO,OAAO,EAAE,OAAO,QAAQ,MAAM;AAInC,QAAI,MAAM,aAAc,QAAO,QAAQ,KAAK;AAC5C,UAAM,IAAI,MAAM,IAAI,OAAO,MAAM,SAAS,UAAU,KAAK,CAAC;AAC1D,QAAI,CAAC,EAAG,QAAO,QAAQ,KAAK;AAC5B,QAAI,EAAE,SAAU,QAAO,EAAE;AACzB,UAAM,OAAO,EAAE;AACf,6BAAQ,MAAM;AAAE,MAAC,MAAM,OAAmC,GAAG,IAAI;AAAA,IAAM,GAAG,MAAS;AAInF,UAAM,MAAM,MAAM,QAAQ,KAAK;AAK/B,UAAM,UAAM,qBAAQ,MAAO,KAAK,oBAAgB,4BAAc,KAAK,KAAK,aAAa,IAAI,KAAM,GAAG;AAClG,QAAI,IAAI,QAAQ,IAAI,MAAM,GAAG;AAAE,UAAI,MAAM,MAAM,SAAS,MAAM,IAAI,MAAM;AAAG,iBAAO,qBAAQ,UAAM,sBAAQ,KAAK,IAAI,GAAG,GAAG;AAAA,IAAG;AAC1H,WAAO,IAAI,OAAO,MAAM,SAAS,MAAM,GAAG;AAAA,EAC5C;AACF;AAOO,IAAM,QAAQ,CAAC,OAAqB,IAAY,aAA4C,aAAAC,OAAU,OAAO,KAAK,GAAG,IAAI,IAAI;AAG7H,IAAM,YAAY,CAAC,cAAgC,aAAAC,WAAc,OAAO,KAAK,CAAC;AAG9E,SAAS,cAAoB;AAClC,aAAW,OAAO,UAAW,KAAI,MAAM;AACzC;","names":["iife","coreTrack","coreScriptTag"]}
1
+ {"version":3,"sources":["../src/index.ts","../src/camada.ts","../package.json","../src/version.ts"],"sourcesContent":["// @camada/sveltekit — the one-line install for a SvelteKit app, in src/hooks.server.ts:\n// export const handle = camada(); // env: CAMADA_KEY (+ CAMADA_INGEST_URL / CAMADA_SNAPSHOT_URL in dev)\n// scriptTag(event) // the first-party beacon, returned from a load() into the page\n// track(event, 'login_failed', { user }) // an outcome the wire cannot show\nexport { camada, track, scriptTag, resetCamada, type CamadaSvelteKitOptions, type CamadaSvelteKitVars } from './camada.js';\n","// @camada/sveltekit — the `handle` hook that binds @camada/core/fetch to a SvelteKit app. The\n// pipeline (verdict, block, challenge, beacon, the event) lives in core; this file only says what\n// SvelteKit knows: the adapter-resolved client address, the per-request env and execution context\n// an edge adapter hands over, the cookie API, and `event.locals` as the per-request slot.\nimport type { Handle, RequestEvent } from '@sveltejs/kit';\nimport { guarded, safeReturnTo, TAP_SVELTEKIT } from '@camada/core';\nimport { createFetchCamada, withRid, withSetCookie, track as coreTrack, scriptTag as coreScriptTag, type FetchCamada, type FetchCamadaOptions, type FetchRequestContext, type FetchVars } from '@camada/core/fetch';\nimport iife from '@camada/browser/iife-string';\nimport { SDK_ID } from './version.js';\n\nexport type CamadaSvelteKitOptions = FetchCamadaOptions;\nexport type CamadaSvelteKitVars = FetchVars;\n\nconst VAR = '__camada'; // private: track() and scriptTag() are the API, not event.locals\nconst instances = new Set<FetchCamada>();\n\ntype Env = Record<string, string | undefined>;\n\n/** What adapter-cloudflare puts on `event.platform`; every field is optional because other adapters put nothing there. */\ninterface CfPlatform {\n env?: Env;\n context?: { waitUntil(p: Promise<unknown>): void };\n cf?: { asn?: number; country?: string; tlsClientExtensionsSha1?: string; httpProtocol?: string };\n}\n\nconst slotOf = (event: RequestEvent): FetchVars | undefined =>\n guarded(() => (event.locals as Record<string, unknown>)[VAR] as FetchVars | undefined, undefined);\n\n/** Everything the runtime vouches for. Nothing here is read from a client header. */\nfunction contextOf(event: RequestEvent): FetchRequestContext {\n const platform = event.platform as CfPlatform | undefined;\n // process.env on adapter-node (and nodejs_compat Workers), the bindings on adapter-cloudflare;\n // a binding can be a KV namespace, so the merge is guarded and core reads only CAMADA_* keys.\n const env = guarded<Env>(() => ({ ...(globalThis as { process?: { env?: Env } }).process?.env, ...platform?.env }), {});\n let peer: string | null = null;\n try { peer = event.getClientAddress(); } catch { /* prerendering, or an adapter with no socket: nothing vouched for */ }\n const exec = platform?.context;\n const cf = platform?.cf;\n return {\n peer, env,\n waitUntil: exec ? (p) => exec.waitUntil(p) : undefined,\n asn: cf?.asn ?? null,\n country: cf?.country ?? null,\n tlsx: cf?.tlsClientExtensionsSha1 ?? null,\n // The connection terminates at Cloudflare, so `cf.httpProtocol` is the visitor's own hop.\n httpVersion: cf?.httpProtocol ? cf.httpProtocol.replace(/^HTTP\\//i, '') : null,\n };\n}\n\nconst DATA_SUFFIX = '/__data.json';\nconst HTML_DATA_SUFFIX = '.html__data.json';\n\n/** The page a SvelteKit `__data.json` request loads data for, or null when `url` is not one\n * (kit's own strip_data_suffix, plus its trailing-slash and invalidation params). */\nfunction dataRequestPage(url: URL): string | null {\n let path: string;\n if (url.pathname.endsWith(HTML_DATA_SUFFIX)) path = url.pathname.slice(0, -HTML_DATA_SUFFIX.length) + '.html';\n else if (url.pathname.endsWith(DATA_SUFFIX)) path = url.pathname.slice(0, -DATA_SUFFIX.length) || '/';\n else return null;\n const q = new URLSearchParams(url.search);\n if (q.get('x-sveltekit-trailing-slash') === '1' && !path.endsWith('/')) path += '/';\n q.delete('x-sveltekit-trailing-slash');\n q.delete('x-sveltekit-invalidated');\n const search = q.toString();\n return path + (search ? '?' + search : '');\n}\n\n/**\n * A challenged client-side navigation (camada-all-wjln). SvelteKit's client fetches\n * `<path>/__data.json` and renders the root error page for a 403 there, so the visitor would never\n * meet the challenge. A data `{\"type\":\"redirect\"}` to the page itself does not help: the client\n * follows it client-side, fetches `__data.json` again and loops into its 20-redirect error. So the\n * redirect points at camada's bounce path (`<scriptPath dir>/reload?to=<page>`). No app route\n * matches it, so the client falls back to a native navigation (`location.href`), and the bounce\n * then 302s that document request to the page, which gets the HTML challenge. Every other answer,\n * and any request that is not a `__data.json` fetch, is left as it is.\n */\nfunction forNavigation(req: Request, res: Response, bouncePath: string): Response {\n return guarded(() => {\n if (res.status !== 403 || res.headers.get('x-camada-challenge') !== '1') return res;\n const page = dataRequestPage(new URL(req.url));\n if (page === null) return res;\n void res.body?.cancel().catch(() => {});\n return new Response(JSON.stringify({ type: 'redirect', location: `${bouncePath}?to=${encodeURIComponent(page)}` }), {\n status: 200, headers: { 'content-type': 'application/json', 'cache-control': 'no-store', 'x-camada-challenge': '1' },\n });\n }, res);\n}\n\n/**\n * The `handle` for `src/hooks.server.ts`: `export const handle = camada()`, or first in a\n * `sequence()`. Reads `CAMADA_KEY` (+ `CAMADA_INGEST_URL` / `CAMADA_SNAPSHOT_URL`) from\n * `process.env` or the Cloudflare bindings; inert without a key. One pipeline per call.\n */\nexport function camada(opts: CamadaSvelteKitOptions = {}): Handle {\n const cam = createFetchCamada({ tap: TAP_SVELTEKIT, sdk: SDK_ID, iife }, opts); // mode defaults to lazy in core: the handle may run on edge/serverless\n instances.add(cam);\n const bouncePath = (opts.scriptPath ?? '/_cam/b.js').replace(/[^/]*$/, 'reload'); // beside the beacon: camada's own directory\n return async ({ event, resolve }) => {\n // A server-side `event.fetch` to the app's own routes runs the handle again on a fresh event\n // with no user-agent or accept: one page view is one event, and the page-level verdict must\n // not be re-applied to the server's own load().\n if (event.isSubRequest) return resolve(event);\n const ctx = contextOf(event);\n // The bounce a challenged data request sends the client through (see forNavigation): a\n // same-site 302 back to the page, which then meets the verdict as a document request. Only\n // while camada is active (core's own inert test: a key, and no CAMADA_DISABLED=1); otherwise\n // the path is the app's.\n const bounce = guarded(() => {\n const url = new URL(event.request.url);\n if (event.request.method !== 'GET' || url.pathname !== bouncePath) return null;\n if (!cam.active(ctx)) return null;\n return new Response(null, { status: 302, headers: { location: safeReturnTo(url.searchParams.get('to')), 'cache-control': 'no-store' } });\n }, null);\n if (bounce) return bounce;\n const r = await cam.before(event.request, { ...ctx, navigation: event.isDataRequest });\n if (!r) return resolve(event);\n if (r.response) return forNavigation(event.request, r.response, bouncePath);\n const vars = r.vars;\n guarded(() => { (event.locals as Record<string, unknown>)[VAR] = vars; }, undefined);\n // The _sfp cookie rides the response, not `event.cookies.set`: SvelteKit appends those in place\n // after resolve(), which throws on an immutable Response (a `fetch()` result, `Response.redirect()`).\n // withSetCookie copies such a response; the hook sees it, so the 500 never happens.\n const res = await resolve(event);\n // SvelteKit swaps a 200 carrying an etag for a bodiless 304 after this hook when the client's\n // If-None-Match matches, dropping the body unread: waiting on it would never ship, so an\n // etagged response ships now even when it is an event stream. (That 304 is built by SvelteKit\n // from a fixed header allow-list, so it carries no x-rid; the hook cannot change that.)\n const out = guarded(() => (vars.sessionCookie ? withSetCookie(res, vars.sessionCookie) : res), res);\n if (out.headers.has('etag')) { cam.after(event.request, vars, out.status); return guarded(() => withRid(out, vars), out); }\n return cam.finish(event.request, vars, out); // an SSE body ships once it has gone out; anything else ships now, untouched\n };\n}\n\n/**\n * Records an outcome the app knows and the wire cannot show (`login_failed`, `signup`,\n * `payment_failed`, ...), joined to this request's event by rid and session. The user identifier\n * is HMAC-hashed in-process. Never throws; a no-op where the handle did not run for this event.\n */\nexport const track = (event: RequestEvent, et: string, data?: { user?: string }): Promise<void> => coreTrack(slotOf(event), et, data);\n\n/** The beacon `<script>` tag for this request's page; `''` where the handle did not run or the tenant turned the beacon off. */\nexport const scriptTag = (event: RequestEvent): string => coreScriptTag(slotOf(event));\n\n/** Test/reset hook: stops and drops every engine of every handle this module created. */\nexport function resetCamada(): void {\n for (const cam of instances) cam.reset();\n}\n","{\n \"name\": \"@camada/sveltekit\",\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 },\n \"description\": \"camada SDK for SvelteKit (hooks.server.ts handle): snapshot enforcement, first-party challenge and beacon, track() outcomes, waitUntil-flushed event shipping on Cloudflare\",\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/core\": \"file:../camada-core\",\n \"@camada/browser\": \"file:../camada-browser\"\n },\n \"peerDependencies\": {\n \"@sveltejs/kit\": \">=2\"\n },\n \"devDependencies\": {\n \"@sveltejs/kit\": \"^2.0.0\",\n \"@types/node\": \"^22.10.0\",\n \"svelte\": \"^5.0.0\",\n \"tsup\": \"^8.3.5\",\n \"typescript\": \"^5.7.2\",\n \"vite\": \"^8.0.0\",\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"],"mappings":"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","names":["iife","coreTrack","coreScriptTag"]}
package/dist/index.js CHANGED
@@ -1,11 +1,11 @@
1
1
  // src/camada.ts
2
- import { guarded, TAP_SVELTEKIT } from "@camada/core";
2
+ import { guarded, safeReturnTo, TAP_SVELTEKIT } from "@camada/core";
3
3
  import { createFetchCamada, withRid, withSetCookie, track as coreTrack, scriptTag as coreScriptTag } from "@camada/core/fetch";
4
4
  import iife from "@camada/browser/iife-string";
5
5
 
6
6
  // package.json
7
7
  var name = "@camada/sveltekit";
8
- var version = "0.1.3";
8
+ var version = "0.2.0";
9
9
 
10
10
  // src/version.ts
11
11
  var SDK_ID = `${name}/${version}`;
@@ -35,14 +35,50 @@ function contextOf(event) {
35
35
  httpVersion: cf?.httpProtocol ? cf.httpProtocol.replace(/^HTTP\//i, "") : null
36
36
  };
37
37
  }
38
+ var DATA_SUFFIX = "/__data.json";
39
+ var HTML_DATA_SUFFIX = ".html__data.json";
40
+ function dataRequestPage(url) {
41
+ let path;
42
+ if (url.pathname.endsWith(HTML_DATA_SUFFIX)) path = url.pathname.slice(0, -HTML_DATA_SUFFIX.length) + ".html";
43
+ else if (url.pathname.endsWith(DATA_SUFFIX)) path = url.pathname.slice(0, -DATA_SUFFIX.length) || "/";
44
+ else return null;
45
+ const q = new URLSearchParams(url.search);
46
+ if (q.get("x-sveltekit-trailing-slash") === "1" && !path.endsWith("/")) path += "/";
47
+ q.delete("x-sveltekit-trailing-slash");
48
+ q.delete("x-sveltekit-invalidated");
49
+ const search = q.toString();
50
+ return path + (search ? "?" + search : "");
51
+ }
52
+ function forNavigation(req, res, bouncePath) {
53
+ return guarded(() => {
54
+ if (res.status !== 403 || res.headers.get("x-camada-challenge") !== "1") return res;
55
+ const page = dataRequestPage(new URL(req.url));
56
+ if (page === null) return res;
57
+ void res.body?.cancel().catch(() => {
58
+ });
59
+ return new Response(JSON.stringify({ type: "redirect", location: `${bouncePath}?to=${encodeURIComponent(page)}` }), {
60
+ status: 200,
61
+ headers: { "content-type": "application/json", "cache-control": "no-store", "x-camada-challenge": "1" }
62
+ });
63
+ }, res);
64
+ }
38
65
  function camada(opts = {}) {
39
66
  const cam = createFetchCamada({ tap: TAP_SVELTEKIT, sdk: SDK_ID, iife }, opts);
40
67
  instances.add(cam);
68
+ const bouncePath = (opts.scriptPath ?? "/_cam/b.js").replace(/[^/]*$/, "reload");
41
69
  return async ({ event, resolve }) => {
42
70
  if (event.isSubRequest) return resolve(event);
43
- const r = await cam.before(event.request, contextOf(event));
71
+ const ctx = contextOf(event);
72
+ const bounce = guarded(() => {
73
+ const url = new URL(event.request.url);
74
+ if (event.request.method !== "GET" || url.pathname !== bouncePath) return null;
75
+ if (!cam.active(ctx)) return null;
76
+ return new Response(null, { status: 302, headers: { location: safeReturnTo(url.searchParams.get("to")), "cache-control": "no-store" } });
77
+ }, null);
78
+ if (bounce) return bounce;
79
+ const r = await cam.before(event.request, { ...ctx, navigation: event.isDataRequest });
44
80
  if (!r) return resolve(event);
45
- if (r.response) return r.response;
81
+ if (r.response) return forNavigation(event.request, r.response, bouncePath);
46
82
  const vars = r.vars;
47
83
  guarded(() => {
48
84
  event.locals[VAR] = vars;
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/camada.ts","../package.json","../src/version.ts"],"sourcesContent":["// @camada/sveltekit — the `handle` hook that binds @camada/core/fetch to a SvelteKit app. The\n// pipeline (verdict, block, challenge, beacon, the event) lives in core; this file only says what\n// SvelteKit knows: the adapter-resolved client address, the per-request env and execution context\n// an edge adapter hands over, the cookie API, and `event.locals` as the per-request slot.\nimport type { Handle, RequestEvent } from '@sveltejs/kit';\nimport { guarded, TAP_SVELTEKIT } from '@camada/core';\nimport { createFetchCamada, withRid, withSetCookie, track as coreTrack, scriptTag as coreScriptTag, type FetchCamada, type FetchCamadaOptions, type FetchRequestContext, type FetchVars } from '@camada/core/fetch';\nimport iife from '@camada/browser/iife-string';\nimport { SDK_ID } from './version.js';\n\nexport type CamadaSvelteKitOptions = FetchCamadaOptions;\nexport type CamadaSvelteKitVars = FetchVars;\n\nconst VAR = '__camada'; // private: track() and scriptTag() are the API, not event.locals\nconst instances = new Set<FetchCamada>();\n\ntype Env = Record<string, string | undefined>;\n\n/** What adapter-cloudflare puts on `event.platform`; every field is optional because other adapters put nothing there. */\ninterface CfPlatform {\n env?: Env;\n context?: { waitUntil(p: Promise<unknown>): void };\n cf?: { asn?: number; country?: string; tlsClientExtensionsSha1?: string; httpProtocol?: string };\n}\n\nconst slotOf = (event: RequestEvent): FetchVars | undefined =>\n guarded(() => (event.locals as Record<string, unknown>)[VAR] as FetchVars | undefined, undefined);\n\n/** Everything the runtime vouches for. Nothing here is read from a client header. */\nfunction contextOf(event: RequestEvent): FetchRequestContext {\n const platform = event.platform as CfPlatform | undefined;\n // process.env on adapter-node (and nodejs_compat Workers), the bindings on adapter-cloudflare;\n // a binding can be a KV namespace, so the merge is guarded and core reads only CAMADA_* keys.\n const env = guarded<Env>(() => ({ ...(globalThis as { process?: { env?: Env } }).process?.env, ...platform?.env }), {});\n let peer: string | null = null;\n try { peer = event.getClientAddress(); } catch { /* prerendering, or an adapter with no socket: nothing vouched for */ }\n const exec = platform?.context;\n const cf = platform?.cf;\n return {\n peer, env,\n waitUntil: exec ? (p) => exec.waitUntil(p) : undefined,\n asn: cf?.asn ?? null,\n country: cf?.country ?? null,\n tlsx: cf?.tlsClientExtensionsSha1 ?? null,\n // The connection terminates at Cloudflare, so `cf.httpProtocol` is the visitor's own hop.\n httpVersion: cf?.httpProtocol ? cf.httpProtocol.replace(/^HTTP\\//i, '') : null,\n };\n}\n\n/**\n * The `handle` for `src/hooks.server.ts`: `export const handle = camada()`, or first in a\n * `sequence()`. Reads `CAMADA_KEY` (+ `CAMADA_INGEST_URL` / `CAMADA_SNAPSHOT_URL`) from\n * `process.env` or the Cloudflare bindings; inert without a key. One pipeline per call.\n */\nexport function camada(opts: CamadaSvelteKitOptions = {}): Handle {\n const cam = createFetchCamada({ tap: TAP_SVELTEKIT, sdk: SDK_ID, iife }, opts); // mode defaults to lazy in core: the handle may run on edge/serverless\n instances.add(cam);\n return async ({ event, resolve }) => {\n // A server-side `event.fetch` to the app's own routes runs the handle again on a fresh event\n // with no user-agent or accept: one page view is one event, and the page-level verdict must\n // not be re-applied to the server's own load().\n if (event.isSubRequest) return resolve(event);\n const r = await cam.before(event.request, contextOf(event));\n if (!r) return resolve(event);\n if (r.response) return r.response;\n const vars = r.vars;\n guarded(() => { (event.locals as Record<string, unknown>)[VAR] = vars; }, undefined);\n // The _sfp cookie rides the response, not `event.cookies.set`: SvelteKit appends those in place\n // after resolve(), which throws on an immutable Response (a `fetch()` result, `Response.redirect()`).\n // withSetCookie copies such a response; the hook sees it, so the 500 never happens.\n const res = await resolve(event);\n // SvelteKit swaps a 200 carrying an etag for a bodiless 304 after this hook when the client's\n // If-None-Match matches, dropping the body unread: waiting on it would never ship, so an\n // etagged response ships now even when it is an event stream. (That 304 is built by SvelteKit\n // from a fixed header allow-list, so it carries no x-rid; the hook cannot change that.)\n const out = guarded(() => (vars.sessionCookie ? withSetCookie(res, vars.sessionCookie) : res), res);\n if (out.headers.has('etag')) { cam.after(event.request, vars, out.status); return guarded(() => withRid(out, vars), out); }\n return cam.finish(event.request, vars, out); // an SSE body ships once it has gone out; anything else ships now, untouched\n };\n}\n\n/**\n * Records an outcome the app knows and the wire cannot show (`login_failed`, `signup`,\n * `payment_failed`, ...), joined to this request's event by rid and session. The user identifier\n * is HMAC-hashed in-process. Never throws; a no-op where the handle did not run for this event.\n */\nexport const track = (event: RequestEvent, et: string, data?: { user?: string }): Promise<void> => coreTrack(slotOf(event), et, data);\n\n/** The beacon `<script>` tag for this request's page; `''` where the handle did not run or the tenant turned the beacon off. */\nexport const scriptTag = (event: RequestEvent): string => coreScriptTag(slotOf(event));\n\n/** Test/reset hook: stops and drops every engine of every handle this module created. */\nexport function resetCamada(): void {\n for (const cam of instances) cam.reset();\n}\n","{\n \"name\": \"@camada/sveltekit\",\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 SvelteKit (hooks.server.ts handle): snapshot enforcement, first-party challenge and beacon, track() outcomes, waitUntil-flushed event shipping on Cloudflare\",\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 \"@sveltejs/kit\": \">=2\"\n },\n \"devDependencies\": {\n \"@sveltejs/kit\": \"^2.0.0\",\n \"@types/node\": \"^22.10.0\",\n \"svelte\": \"^5.0.0\",\n \"tsup\": \"^8.3.5\",\n \"typescript\": \"^5.7.2\",\n \"vite\": \"^8.0.0\",\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"],"mappings":";AAKA,SAAS,SAAS,qBAAqB;AACvC,SAAS,mBAAmB,SAAS,eAAe,SAAS,WAAW,aAAa,qBAA0G;AAC/L,OAAO,UAAU;;;ACNf,WAAQ;AACR,cAAW;;;ACCN,IAAM,SAAS,GAAG,IAAI,IAAI,OAAO;;;AFUxC,IAAM,MAAM;AACZ,IAAM,YAAY,oBAAI,IAAiB;AAWvC,IAAM,SAAS,CAAC,UACd,QAAQ,MAAO,MAAM,OAAmC,GAAG,GAA4B,MAAS;AAGlG,SAAS,UAAU,OAA0C;AAC3D,QAAM,WAAW,MAAM;AAGvB,QAAM,MAAM,QAAa,OAAO,EAAE,GAAI,WAA2C,SAAS,KAAK,GAAG,UAAU,IAAI,IAAI,CAAC,CAAC;AACtH,MAAI,OAAsB;AAC1B,MAAI;AAAE,WAAO,MAAM,iBAAiB;AAAA,EAAG,QAAQ;AAAA,EAAwE;AACvH,QAAM,OAAO,UAAU;AACvB,QAAM,KAAK,UAAU;AACrB,SAAO;AAAA,IACL;AAAA,IAAM;AAAA,IACN,WAAW,OAAO,CAAC,MAAM,KAAK,UAAU,CAAC,IAAI;AAAA,IAC7C,KAAK,IAAI,OAAO;AAAA,IAChB,SAAS,IAAI,WAAW;AAAA,IACxB,MAAM,IAAI,2BAA2B;AAAA;AAAA,IAErC,aAAa,IAAI,eAAe,GAAG,aAAa,QAAQ,YAAY,EAAE,IAAI;AAAA,EAC5E;AACF;AAOO,SAAS,OAAO,OAA+B,CAAC,GAAW;AAChE,QAAM,MAAM,kBAAkB,EAAE,KAAK,eAAe,KAAK,QAAQ,KAAK,GAAG,IAAI;AAC7E,YAAU,IAAI,GAAG;AACjB,SAAO,OAAO,EAAE,OAAO,QAAQ,MAAM;AAInC,QAAI,MAAM,aAAc,QAAO,QAAQ,KAAK;AAC5C,UAAM,IAAI,MAAM,IAAI,OAAO,MAAM,SAAS,UAAU,KAAK,CAAC;AAC1D,QAAI,CAAC,EAAG,QAAO,QAAQ,KAAK;AAC5B,QAAI,EAAE,SAAU,QAAO,EAAE;AACzB,UAAM,OAAO,EAAE;AACf,YAAQ,MAAM;AAAE,MAAC,MAAM,OAAmC,GAAG,IAAI;AAAA,IAAM,GAAG,MAAS;AAInF,UAAM,MAAM,MAAM,QAAQ,KAAK;AAK/B,UAAM,MAAM,QAAQ,MAAO,KAAK,gBAAgB,cAAc,KAAK,KAAK,aAAa,IAAI,KAAM,GAAG;AAClG,QAAI,IAAI,QAAQ,IAAI,MAAM,GAAG;AAAE,UAAI,MAAM,MAAM,SAAS,MAAM,IAAI,MAAM;AAAG,aAAO,QAAQ,MAAM,QAAQ,KAAK,IAAI,GAAG,GAAG;AAAA,IAAG;AAC1H,WAAO,IAAI,OAAO,MAAM,SAAS,MAAM,GAAG;AAAA,EAC5C;AACF;AAOO,IAAM,QAAQ,CAAC,OAAqB,IAAY,SAA4C,UAAU,OAAO,KAAK,GAAG,IAAI,IAAI;AAG7H,IAAM,YAAY,CAAC,UAAgC,cAAc,OAAO,KAAK,CAAC;AAG9E,SAAS,cAAoB;AAClC,aAAW,OAAO,UAAW,KAAI,MAAM;AACzC;","names":[]}
1
+ {"version":3,"sources":["../src/camada.ts","../package.json","../src/version.ts"],"sourcesContent":["// @camada/sveltekit — the `handle` hook that binds @camada/core/fetch to a SvelteKit app. The\n// pipeline (verdict, block, challenge, beacon, the event) lives in core; this file only says what\n// SvelteKit knows: the adapter-resolved client address, the per-request env and execution context\n// an edge adapter hands over, the cookie API, and `event.locals` as the per-request slot.\nimport type { Handle, RequestEvent } from '@sveltejs/kit';\nimport { guarded, safeReturnTo, TAP_SVELTEKIT } from '@camada/core';\nimport { createFetchCamada, withRid, withSetCookie, track as coreTrack, scriptTag as coreScriptTag, type FetchCamada, type FetchCamadaOptions, type FetchRequestContext, type FetchVars } from '@camada/core/fetch';\nimport iife from '@camada/browser/iife-string';\nimport { SDK_ID } from './version.js';\n\nexport type CamadaSvelteKitOptions = FetchCamadaOptions;\nexport type CamadaSvelteKitVars = FetchVars;\n\nconst VAR = '__camada'; // private: track() and scriptTag() are the API, not event.locals\nconst instances = new Set<FetchCamada>();\n\ntype Env = Record<string, string | undefined>;\n\n/** What adapter-cloudflare puts on `event.platform`; every field is optional because other adapters put nothing there. */\ninterface CfPlatform {\n env?: Env;\n context?: { waitUntil(p: Promise<unknown>): void };\n cf?: { asn?: number; country?: string; tlsClientExtensionsSha1?: string; httpProtocol?: string };\n}\n\nconst slotOf = (event: RequestEvent): FetchVars | undefined =>\n guarded(() => (event.locals as Record<string, unknown>)[VAR] as FetchVars | undefined, undefined);\n\n/** Everything the runtime vouches for. Nothing here is read from a client header. */\nfunction contextOf(event: RequestEvent): FetchRequestContext {\n const platform = event.platform as CfPlatform | undefined;\n // process.env on adapter-node (and nodejs_compat Workers), the bindings on adapter-cloudflare;\n // a binding can be a KV namespace, so the merge is guarded and core reads only CAMADA_* keys.\n const env = guarded<Env>(() => ({ ...(globalThis as { process?: { env?: Env } }).process?.env, ...platform?.env }), {});\n let peer: string | null = null;\n try { peer = event.getClientAddress(); } catch { /* prerendering, or an adapter with no socket: nothing vouched for */ }\n const exec = platform?.context;\n const cf = platform?.cf;\n return {\n peer, env,\n waitUntil: exec ? (p) => exec.waitUntil(p) : undefined,\n asn: cf?.asn ?? null,\n country: cf?.country ?? null,\n tlsx: cf?.tlsClientExtensionsSha1 ?? null,\n // The connection terminates at Cloudflare, so `cf.httpProtocol` is the visitor's own hop.\n httpVersion: cf?.httpProtocol ? cf.httpProtocol.replace(/^HTTP\\//i, '') : null,\n };\n}\n\nconst DATA_SUFFIX = '/__data.json';\nconst HTML_DATA_SUFFIX = '.html__data.json';\n\n/** The page a SvelteKit `__data.json` request loads data for, or null when `url` is not one\n * (kit's own strip_data_suffix, plus its trailing-slash and invalidation params). */\nfunction dataRequestPage(url: URL): string | null {\n let path: string;\n if (url.pathname.endsWith(HTML_DATA_SUFFIX)) path = url.pathname.slice(0, -HTML_DATA_SUFFIX.length) + '.html';\n else if (url.pathname.endsWith(DATA_SUFFIX)) path = url.pathname.slice(0, -DATA_SUFFIX.length) || '/';\n else return null;\n const q = new URLSearchParams(url.search);\n if (q.get('x-sveltekit-trailing-slash') === '1' && !path.endsWith('/')) path += '/';\n q.delete('x-sveltekit-trailing-slash');\n q.delete('x-sveltekit-invalidated');\n const search = q.toString();\n return path + (search ? '?' + search : '');\n}\n\n/**\n * A challenged client-side navigation (camada-all-wjln). SvelteKit's client fetches\n * `<path>/__data.json` and renders the root error page for a 403 there, so the visitor would never\n * meet the challenge. A data `{\"type\":\"redirect\"}` to the page itself does not help: the client\n * follows it client-side, fetches `__data.json` again and loops into its 20-redirect error. So the\n * redirect points at camada's bounce path (`<scriptPath dir>/reload?to=<page>`). No app route\n * matches it, so the client falls back to a native navigation (`location.href`), and the bounce\n * then 302s that document request to the page, which gets the HTML challenge. Every other answer,\n * and any request that is not a `__data.json` fetch, is left as it is.\n */\nfunction forNavigation(req: Request, res: Response, bouncePath: string): Response {\n return guarded(() => {\n if (res.status !== 403 || res.headers.get('x-camada-challenge') !== '1') return res;\n const page = dataRequestPage(new URL(req.url));\n if (page === null) return res;\n void res.body?.cancel().catch(() => {});\n return new Response(JSON.stringify({ type: 'redirect', location: `${bouncePath}?to=${encodeURIComponent(page)}` }), {\n status: 200, headers: { 'content-type': 'application/json', 'cache-control': 'no-store', 'x-camada-challenge': '1' },\n });\n }, res);\n}\n\n/**\n * The `handle` for `src/hooks.server.ts`: `export const handle = camada()`, or first in a\n * `sequence()`. Reads `CAMADA_KEY` (+ `CAMADA_INGEST_URL` / `CAMADA_SNAPSHOT_URL`) from\n * `process.env` or the Cloudflare bindings; inert without a key. One pipeline per call.\n */\nexport function camada(opts: CamadaSvelteKitOptions = {}): Handle {\n const cam = createFetchCamada({ tap: TAP_SVELTEKIT, sdk: SDK_ID, iife }, opts); // mode defaults to lazy in core: the handle may run on edge/serverless\n instances.add(cam);\n const bouncePath = (opts.scriptPath ?? '/_cam/b.js').replace(/[^/]*$/, 'reload'); // beside the beacon: camada's own directory\n return async ({ event, resolve }) => {\n // A server-side `event.fetch` to the app's own routes runs the handle again on a fresh event\n // with no user-agent or accept: one page view is one event, and the page-level verdict must\n // not be re-applied to the server's own load().\n if (event.isSubRequest) return resolve(event);\n const ctx = contextOf(event);\n // The bounce a challenged data request sends the client through (see forNavigation): a\n // same-site 302 back to the page, which then meets the verdict as a document request. Only\n // while camada is active (core's own inert test: a key, and no CAMADA_DISABLED=1); otherwise\n // the path is the app's.\n const bounce = guarded(() => {\n const url = new URL(event.request.url);\n if (event.request.method !== 'GET' || url.pathname !== bouncePath) return null;\n if (!cam.active(ctx)) return null;\n return new Response(null, { status: 302, headers: { location: safeReturnTo(url.searchParams.get('to')), 'cache-control': 'no-store' } });\n }, null);\n if (bounce) return bounce;\n const r = await cam.before(event.request, { ...ctx, navigation: event.isDataRequest });\n if (!r) return resolve(event);\n if (r.response) return forNavigation(event.request, r.response, bouncePath);\n const vars = r.vars;\n guarded(() => { (event.locals as Record<string, unknown>)[VAR] = vars; }, undefined);\n // The _sfp cookie rides the response, not `event.cookies.set`: SvelteKit appends those in place\n // after resolve(), which throws on an immutable Response (a `fetch()` result, `Response.redirect()`).\n // withSetCookie copies such a response; the hook sees it, so the 500 never happens.\n const res = await resolve(event);\n // SvelteKit swaps a 200 carrying an etag for a bodiless 304 after this hook when the client's\n // If-None-Match matches, dropping the body unread: waiting on it would never ship, so an\n // etagged response ships now even when it is an event stream. (That 304 is built by SvelteKit\n // from a fixed header allow-list, so it carries no x-rid; the hook cannot change that.)\n const out = guarded(() => (vars.sessionCookie ? withSetCookie(res, vars.sessionCookie) : res), res);\n if (out.headers.has('etag')) { cam.after(event.request, vars, out.status); return guarded(() => withRid(out, vars), out); }\n return cam.finish(event.request, vars, out); // an SSE body ships once it has gone out; anything else ships now, untouched\n };\n}\n\n/**\n * Records an outcome the app knows and the wire cannot show (`login_failed`, `signup`,\n * `payment_failed`, ...), joined to this request's event by rid and session. The user identifier\n * is HMAC-hashed in-process. Never throws; a no-op where the handle did not run for this event.\n */\nexport const track = (event: RequestEvent, et: string, data?: { user?: string }): Promise<void> => coreTrack(slotOf(event), et, data);\n\n/** The beacon `<script>` tag for this request's page; `''` where the handle did not run or the tenant turned the beacon off. */\nexport const scriptTag = (event: RequestEvent): string => coreScriptTag(slotOf(event));\n\n/** Test/reset hook: stops and drops every engine of every handle this module created. */\nexport function resetCamada(): void {\n for (const cam of instances) cam.reset();\n}\n","{\n \"name\": \"@camada/sveltekit\",\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 },\n \"description\": \"camada SDK for SvelteKit (hooks.server.ts handle): snapshot enforcement, first-party challenge and beacon, track() outcomes, waitUntil-flushed event shipping on Cloudflare\",\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/core\": \"file:../camada-core\",\n \"@camada/browser\": \"file:../camada-browser\"\n },\n \"peerDependencies\": {\n \"@sveltejs/kit\": \">=2\"\n },\n \"devDependencies\": {\n \"@sveltejs/kit\": \"^2.0.0\",\n \"@types/node\": \"^22.10.0\",\n \"svelte\": \"^5.0.0\",\n \"tsup\": \"^8.3.5\",\n \"typescript\": \"^5.7.2\",\n \"vite\": \"^8.0.0\",\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"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camada/sveltekit",
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 SvelteKit (hooks.server.ts handle): snapshot enforcement, first-party challenge and beacon, track() outcomes, waitUntil-flushed event shipping on Cloudflare",
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/core": "^0.6.0",
32
+ "@camada/browser": "^0.4.0"
28
33
  },
29
34
  "peerDependencies": {
30
35
  "@sveltejs/kit": ">=2"