@camada/bun 0.1.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/LICENSE +21 -0
- package/README.md +181 -0
- package/dist/index.cjs +93 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +29 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +53 -0
- package/dist/index.js.map +1 -0
- package/package.json +37 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 camada
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# @camada/bun
|
|
2
|
+
|
|
3
|
+
camada for [Bun.serve](https://bun.sh/docs/api/http): enforces the tenant snapshot inline (your
|
|
4
|
+
ordered custom rules, then block, allow, challenge), serves a first-party proof-of-work challenge
|
|
5
|
+
page and beacon, records the outcomes your handlers know (`track()`), and ships wire events in
|
|
6
|
+
batches off the response path. A thin binding over `@camada/core/fetch`: the socket peer comes
|
|
7
|
+
from `server.requestIP`, the env from `Bun.env`, and the snapshot polls on a timer because a Bun
|
|
8
|
+
server lives long enough to have one. Fails open by design — a camada outage or bug never 5xxes
|
|
9
|
+
your app.
|
|
10
|
+
|
|
11
|
+
Not yet on npm — consumed via a `file:` dependency from a sibling checkout.
|
|
12
|
+
|
|
13
|
+
## Quickstart
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { camada } from '@camada/bun';
|
|
17
|
+
|
|
18
|
+
Bun.serve({
|
|
19
|
+
fetch: camada()((req, server) => new Response('hello')), // reads CAMADA_KEY / CAMADA_INGEST_URL / CAMADA_SNAPSHOT_URL from Bun.env
|
|
20
|
+
});
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`camada()` builds one instance and returns `wrap`; call it once and wrap every handler the
|
|
24
|
+
server runs with the same `wrap`. With Bun's `routes` the fallback `fetch` and each route
|
|
25
|
+
handler are separate entry points, so each one is wrapped:
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import { camada, scriptTag } from '@camada/bun';
|
|
29
|
+
|
|
30
|
+
const guard = camada();
|
|
31
|
+
|
|
32
|
+
Bun.serve({
|
|
33
|
+
routes: {
|
|
34
|
+
'/': guard((req) => new Response(`<html><head>${scriptTag(req)}</head><body>home</body></html>`, { headers: { 'content-type': 'text/html' } })),
|
|
35
|
+
'/api/health': () => new Response('ok'), // unwrapped: answers whatever the snapshot says
|
|
36
|
+
},
|
|
37
|
+
fetch: guard((req) => new Response('not found', { status: 404 })),
|
|
38
|
+
});
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Env (printed by camada onboarding / `npm run seed` in dev):
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
CAMADA_KEY=<ingest_token>.<snap_token>
|
|
45
|
+
CAMADA_INGEST_URL=http://localhost:8787 # dev only; defaults to production ingest
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The engine is built from `Bun.env` (or `process.env` under Node) on the first request, merged
|
|
49
|
+
with whatever the app passes in code:
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
const guard = camada({ key: MY_KEY, ingestUrl: MY_INGEST });
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Without `CAMADA_KEY` the wrapper is inert (one log line, no requests, no enforcement), so an
|
|
56
|
+
unprovisioned environment behaves exactly as if camada were not installed.
|
|
57
|
+
|
|
58
|
+
## What it does per request
|
|
59
|
+
|
|
60
|
+
1. Keeps the snapshot fresh on an unref'd 30 s timer (the server steers the cadence per tenant)
|
|
61
|
+
and drains the event queue on `beforeExit`, `SIGTERM` and `SIGINT`. `mode: 'lazy'` or
|
|
62
|
+
`CAMADA_SERVERLESS=1` refreshes per request instead, for a Bun that is not a long-lived
|
|
63
|
+
process. Every poll and event batch carries `x-camada-sdk: @camada/bun/<version>`, and polls
|
|
64
|
+
ask for snapshot v5 (`x-camada-snapshot: 5`) — the container that carries your ordered
|
|
65
|
+
custom rules.
|
|
66
|
+
2. Resolves the client from the socket Bun vouches for (`server.requestIP(req)`), then
|
|
67
|
+
`X-Forwarded-For` under your tenant's trusted-proxy config. No header is ever trusted on its
|
|
68
|
+
own: without a trusted-proxy config a forwarded header is just another header the caller
|
|
69
|
+
controls, and `cf-connecting-ip` / `x-real-ip` are never read.
|
|
70
|
+
3. Runs your ordered custom rules, then the allow, block and challenge lists.
|
|
71
|
+
4. **Block** → `403` with `x-block-reason` before your handler; the event still ships, with
|
|
72
|
+
`st: 403` and `blk: <reason>` so the analyst counts SDK blocks apart from your own 403s.
|
|
73
|
+
5. **Skip** → a skip rule or the allow list wins over a wider block.
|
|
74
|
+
6. **Challenge** → a `403` proof-of-work page: no external assets, `no-store`, an inline SHA-256
|
|
75
|
+
solver that posts to `POST /__camada/challenge`, which sets `_cch` (an HMAC token bound to
|
|
76
|
+
the client ip, 1 h) and 302s back. Non-HTML requests get `403 {"error":"challenge_required"}`.
|
|
77
|
+
7. Otherwise your handler runs, the `_sfp` session cookie is appended on a first visit (also
|
|
78
|
+
onto a `Response.redirect()`, whose headers are immutable — the response is rebuilt around
|
|
79
|
+
the same body), and the settled response ships one batched, redacted event with its real
|
|
80
|
+
status (Authorization and Cookie values never leave the process — see `@camada/core`). A
|
|
81
|
+
handler that throws ships the event with `st: 500` — what Bun answers for it — and the error
|
|
82
|
+
goes on to Bun's `error` callback (a callback that answers with another status is not seen
|
|
83
|
+
here); a handler that returns nothing after `server.upgrade(req)` (Bun's WebSocket pattern)
|
|
84
|
+
is passed through and ships `st: 101`, with no Response to carry a cookie.
|
|
85
|
+
|
|
86
|
+
## Options
|
|
87
|
+
|
|
88
|
+
| option | default | meaning |
|
|
89
|
+
|---|---|---|
|
|
90
|
+
| `key` | `env.CAMADA_KEY` | `<ingest_token>.<snap_token>`; without it the wrapper is inert |
|
|
91
|
+
| `ingestUrl` | `env.CAMADA_INGEST_URL` | ingest base; batches go to `<ingestUrl>/e` |
|
|
92
|
+
| `snapshotUrl` | `<ingestUrl>/snapshot` | snapshot endpoint |
|
|
93
|
+
| `trustedProxy` | server config | `none` / `vercel` / `hops:N` / `cidrs:a,b`, or the parsed object |
|
|
94
|
+
| `challenge` | `true` | serve the proof-of-work page for `challenge` verdicts |
|
|
95
|
+
| `challengePath` | `/__camada/challenge` | where that page posts its solution |
|
|
96
|
+
| `snapshotVersion` | `5` | `4` drops the custom rules, `3` the allow/challenge sides too |
|
|
97
|
+
| `scriptPath` | `/_cam/b.js` | where the first-party beacon script is served |
|
|
98
|
+
| `fpPath` | `/_cam/fp` | where that script posts the beacon; keep it in `scriptPath`'s directory |
|
|
99
|
+
| `mode` | `timer` (or `lazy`) | `timer` polls the snapshot on an unref'd interval (long-lived process); `lazy` refreshes it per request off-path. `CAMADA_SERVERLESS=1` forces `lazy` |
|
|
100
|
+
| `env` | `Bun.env` | overrides the host env (tests, and apps that read config themselves) |
|
|
101
|
+
|
|
102
|
+
`CAMADA_CHALLENGE=0` in the env switches the challenge off without a code change.
|
|
103
|
+
|
|
104
|
+
`CAMADA_DISABLED=1` in the env switches everything off, checked per request.
|
|
105
|
+
|
|
106
|
+
## The first-party beacon
|
|
107
|
+
|
|
108
|
+
Bots that never run JavaScript are the cheapest to catch. Put the tag in the `<head>` of the
|
|
109
|
+
pages you render and the wrapper does the rest:
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
import { camada, scriptTag } from '@camada/bun';
|
|
113
|
+
|
|
114
|
+
Bun.serve({
|
|
115
|
+
fetch: camada()((req) => new Response(`<html><head>${scriptTag(req)}</head><body>…</body></html>`, { headers: { 'content-type': 'text/html' } })),
|
|
116
|
+
});
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`scriptTag(req)` returns `<script src="/_cam/b.js?r=<rid>" async></script>` — the `rid` is this
|
|
120
|
+
request's event id, so the analyst joins the beacon to the page view. `scriptTag` and `track`
|
|
121
|
+
find their request by the `Request` object Bun handed you — pass that one, not a `clone()`. The wrapper serves the
|
|
122
|
+
script at `GET /_cam/b.js` (cacheable, 1 h) and relays `POST /_cam/fp` (≤ 32 KB, answers 204)
|
|
123
|
+
onto the event batch as a `sig: 1` row stamped with the client ip camada resolved — never the
|
|
124
|
+
one the body claims. Both endpoints sit behind the verdict: a blocked client gets 403 there
|
|
125
|
+
too. The tag is `''` when camada is off for the request or the project turned the beacon off
|
|
126
|
+
in its settings, and the endpoints stand down with it.
|
|
127
|
+
|
|
128
|
+
With `routes`, the two paths are answered by whichever wrapped handler Bun dispatches to — the
|
|
129
|
+
fallback `fetch` in the example above — so wrap the fallback even when every route is wrapped.
|
|
130
|
+
Moving them (`camada({ scriptPath: '/api/_cam/b.js', fpPath: '/api/_cam/fp' })`) keeps the
|
|
131
|
+
same rule: the script derives the post path from its own `src`, so the two must share a directory.
|
|
132
|
+
|
|
133
|
+
## App-context events
|
|
134
|
+
|
|
135
|
+
The wire shows a `POST /login`; only your handler knows whether it failed. Tell camada:
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
import { camada, track } from '@camada/bun';
|
|
139
|
+
|
|
140
|
+
Bun.serve({
|
|
141
|
+
fetch: camada()(async (req) => {
|
|
142
|
+
const ok = await signIn(req);
|
|
143
|
+
if (!ok) track(req, 'login_failed', { user: email }); // await optional — it never throws
|
|
144
|
+
return ok ? Response.redirect('/') : new Response('Invalid credentials', { status: 401 });
|
|
145
|
+
}),
|
|
146
|
+
});
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
`track(req, event, { user? })` ships `{ et, uid, rid, sid, ip, ts }` joined to this request's
|
|
150
|
+
event. The user identifier is HMAC-hashed in-process with the ingest token — the raw value never
|
|
151
|
+
leaves the process. It never throws and is a no-op where the wrapper did not run. The event name
|
|
152
|
+
is free-form; the analyst's rules read this vocabulary:
|
|
153
|
+
|
|
154
|
+
| event | when |
|
|
155
|
+
|---|---|
|
|
156
|
+
| `login_failed` / `login_succeeded` | a credential check settled |
|
|
157
|
+
| `signup` | an account was created |
|
|
158
|
+
| `password_reset` | a reset was requested |
|
|
159
|
+
| `mfa_failed` | a second factor was rejected |
|
|
160
|
+
| `payment_failed` / `payment_succeeded` | a charge settled |
|
|
161
|
+
| `coupon_failed` | a promo code was rejected |
|
|
162
|
+
|
|
163
|
+
## What this tap can see
|
|
164
|
+
|
|
165
|
+
This is the in-app position: the beacon, the client hints the browser sends, the real response
|
|
166
|
+
status, the `_sfp` session and the app context from `track()`. Bun vouches for one connection
|
|
167
|
+
fact — the socket address from `server.requestIP` — and nothing else: no ASN, country or TLS
|
|
168
|
+
fingerprint (those rules never match here), no client protocol (the event's `proto` is null; a
|
|
169
|
+
forwarded protocol header is never read for it), and `Headers` normalises order, so the
|
|
170
|
+
raw-wire-order signal is not available either. The analyst knows all of that from the tap's
|
|
171
|
+
capability mask (`sdk-bun`) and never scores an absence as evidence.
|
|
172
|
+
|
|
173
|
+
## Fail open
|
|
174
|
+
|
|
175
|
+
Every entry point runs inside camada's guard. A dead ingest, a corrupt snapshot, a bug in this
|
|
176
|
+
package: telemetry is lost, the request is not.
|
|
177
|
+
|
|
178
|
+
## Tests
|
|
179
|
+
|
|
180
|
+
`npm test` runs the suite under vitest in Node with a stub `server` (the two snapshot-mode tests
|
|
181
|
+
use vitest's fake timers, which `bun test` does not provide).
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name2 in all)
|
|
10
|
+
__defProp(target, name2, { get: all[name2], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var index_exports = {};
|
|
32
|
+
__export(index_exports, {
|
|
33
|
+
camada: () => camada,
|
|
34
|
+
resetCamada: () => resetCamada,
|
|
35
|
+
scriptTag: () => scriptTag,
|
|
36
|
+
track: () => track
|
|
37
|
+
});
|
|
38
|
+
module.exports = __toCommonJS(index_exports);
|
|
39
|
+
|
|
40
|
+
// src/camada.ts
|
|
41
|
+
var import_core = require("@camada/core");
|
|
42
|
+
var import_fetch = require("@camada/core/fetch");
|
|
43
|
+
var import_iife_string = __toESM(require("@camada/browser/iife-string"), 1);
|
|
44
|
+
|
|
45
|
+
// package.json
|
|
46
|
+
var name = "@camada/bun";
|
|
47
|
+
var version = "0.1.0";
|
|
48
|
+
|
|
49
|
+
// src/version.ts
|
|
50
|
+
var SDK_ID = `${name}/${version}`;
|
|
51
|
+
|
|
52
|
+
// src/camada.ts
|
|
53
|
+
var slots = /* @__PURE__ */ new WeakMap();
|
|
54
|
+
var instances = /* @__PURE__ */ new Set();
|
|
55
|
+
function camada(opts = {}) {
|
|
56
|
+
const cam = (0, import_fetch.createFetchCamada)({ tap: import_core.TAP_BUN, sdk: SDK_ID, iife: import_iife_string.default }, { ...opts, mode: opts.mode ?? "timer" });
|
|
57
|
+
instances.add(cam);
|
|
58
|
+
return function wrap(handler) {
|
|
59
|
+
return async (req, server) => {
|
|
60
|
+
const peer = (0, import_core.guarded)(() => server?.requestIP?.(req)?.address ?? null, null);
|
|
61
|
+
const r = await cam.before(req, { peer, env: globalThis.Bun?.env ?? globalThis.process?.env });
|
|
62
|
+
if (!r) return handler(req, server);
|
|
63
|
+
if (r.response) return r.response;
|
|
64
|
+
slots.set(req, r.vars);
|
|
65
|
+
let res;
|
|
66
|
+
try {
|
|
67
|
+
res = await handler(req, server);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
cam.after(req, r.vars, 500);
|
|
70
|
+
throw err;
|
|
71
|
+
}
|
|
72
|
+
if (!res) {
|
|
73
|
+
cam.after(req, r.vars, 101);
|
|
74
|
+
return res;
|
|
75
|
+
}
|
|
76
|
+
cam.after(req, r.vars, res.status);
|
|
77
|
+
return r.vars.sessionCookie ? (0, import_fetch.withSetCookie)(res, r.vars.sessionCookie) : res;
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
var track = (req, event, data) => (0, import_fetch.track)(slots.get(req), event, data);
|
|
82
|
+
var scriptTag = (req) => (0, import_fetch.scriptTag)(slots.get(req));
|
|
83
|
+
function resetCamada() {
|
|
84
|
+
for (const cam of instances) cam.reset();
|
|
85
|
+
}
|
|
86
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
87
|
+
0 && (module.exports = {
|
|
88
|
+
camada,
|
|
89
|
+
resetCamada,
|
|
90
|
+
scriptTag,
|
|
91
|
+
track
|
|
92
|
+
});
|
|
93
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/camada.ts","../package.json","../src/version.ts"],"sourcesContent":["// @camada/bun — the one-line install for a Bun.serve app:\n// Bun.serve({ fetch: camada()(handler) }); // env: CAMADA_KEY (+ CAMADA_INGEST_URL / CAMADA_SNAPSHOT_URL in dev)\n// new Response(`<head>${scriptTag(req)}</head>…`, { headers: { 'content-type': 'text/html' } }) // the first-party beacon\n// track(req, 'login_failed', { user }) // an outcome the wire cannot show\nexport { camada, track, scriptTag, resetCamada, type CamadaBunOptions, type CamadaBunVars, type BunServer, type BunHandler } from './camada.js';\n","// @camada/bun — the Bun.serve binding for @camada/core/fetch. `wrap` sits around the app's fetch\n// handler: the socket peer comes from `server.requestIP` (the one address Bun vouches for), the\n// env from Bun.env, and there is no waitUntil — a long-lived process polls on a timer and drains\n// on exit instead. Bun has no per-request context object, so the Request itself keys the slot.\nimport { TAP_BUN, guarded } from '@camada/core';\nimport { createFetchCamada, withSetCookie, track as coreTrack, scriptTag as coreScriptTag, type FetchCamada, type FetchCamadaOptions, type FetchVars } from '@camada/core/fetch';\nimport iife from '@camada/browser/iife-string';\nimport { SDK_ID } from './version.js';\n\n/** The one thing the wrapper needs from Bun's server, typed structurally so the emitted types need no `bun` import; any `Bun.Server` satisfies it. */\nexport interface BunServer { requestIP(req: Request): { address: string } | null }\n/** A Bun fetch handler. `undefined`/`void` is what Bun's own websocket pattern returns after `server.upgrade(req)`. */\nexport type BunHandler<S extends BunServer = BunServer, R extends Response | undefined | void = Response> = (req: Request, server: S) => R | Promise<R>;\nexport type CamadaBunOptions = FetchCamadaOptions;\nexport type CamadaBunVars = FetchVars;\n\nconst slots = new WeakMap<Request, FetchVars>(); // set only when the app is about to run; camada's own answers leave nothing\nconst instances = new Set<FetchCamada>();\n\n/**\n * Builds one camada instance and returns `wrap`, which turns a Bun fetch handler into a guarded\n * one: `Bun.serve({ fetch: camada()(handler) })`. Timer mode by default (a server process lives\n * long enough to poll); `mode: 'lazy'` or CAMADA_SERVERLESS=1 switches to per-request refresh.\n */\nexport function camada(opts: CamadaBunOptions = {}) {\n const cam = createFetchCamada({ tap: TAP_BUN, sdk: SDK_ID, iife }, { ...opts, mode: opts.mode ?? 'timer' });\n instances.add(cam);\n return function wrap<S extends BunServer, R extends Response | undefined | void>(handler: BunHandler<S, R>): (req: Request, server: S) => Promise<Response | R> {\n return async (req, server) => {\n // Guarded: a runtime that is not Bun (or a Bun without a socket for this request) hands us\n // no peer rather than a throw. Client headers are core's to judge under the trusted-proxy rules.\n const peer = guarded(() => server?.requestIP?.(req)?.address ?? null, null);\n const r = await cam.before(req, { peer, env: globalThis.Bun?.env ?? globalThis.process?.env }); // Bun.env is process.env under Bun; the fallback is this suite under Node\n if (!r) return handler(req, server);\n if (r.response) return r.response;\n slots.set(req, r.vars);\n let res: R;\n try {\n res = await handler(req, server);\n } catch (err) {\n cam.after(req, r.vars, 500); // Bun answers 500 for a thrown handler (an `error` callback returning its own status is not visible here)\n throw err;\n }\n if (!res) { cam.after(req, r.vars, 101); return res; } // a websocket upgrade: Bun answers 101 itself, so there is no Response to carry a cookie\n cam.after(req, r.vars, res.status);\n return r.vars.sessionCookie ? withSetCookie(res, r.vars.sessionCookie) : res;\n };\n };\n}\n\n/** An outcome the wire cannot show (`login_failed`, `signup`, …) joined to this request's event;\n * the user is HMAC-hashed in-process. A silent no-op where the wrapper did not run. */\nexport const track = (req: Request, event: string, data?: { user?: string }): Promise<void> => coreTrack(slots.get(req), event, data);\n\n/** The beacon `<script>` tag for an HTML response — `''` where the wrapper did not run or the tenant turned the beacon off. */\nexport const scriptTag = (req: Request): string => coreScriptTag(slots.get(req));\n\n/** Test/reset hook: stops every poller and exit handler of every instance this module created. */\nexport function resetCamada(): void {\n for (const cam of instances) cam.reset();\n}\n","{\n \"name\": \"@camada/bun\",\n \"version\": \"0.1.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 Bun.serve: snapshot enforcement, first-party challenge and beacon, track() outcomes, the socket peer from server.requestIP, timer-polled snapshot with exit flush\",\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 \"devDependencies\": {\n \"@types/bun\": \"^1.2.0\",\n \"@types/node\": \"^22.10.0\",\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"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACIA,kBAAiC;AACjC,mBAA4J;AAC5J,yBAAiB;;;ACLf,WAAQ;AACR,cAAW;;;ACCN,IAAM,SAAS,GAAG,IAAI,IAAI,OAAO;;;AFaxC,IAAM,QAAQ,oBAAI,QAA4B;AAC9C,IAAM,YAAY,oBAAI,IAAiB;AAOhC,SAAS,OAAO,OAAyB,CAAC,GAAG;AAClD,QAAM,UAAM,gCAAkB,EAAE,KAAK,qBAAS,KAAK,QAAQ,yBAAAA,QAAK,GAAG,EAAE,GAAG,MAAM,MAAM,KAAK,QAAQ,QAAQ,CAAC;AAC1G,YAAU,IAAI,GAAG;AACjB,SAAO,SAAS,KAAiE,SAA+E;AAC9J,WAAO,OAAO,KAAK,WAAW;AAG5B,YAAM,WAAO,qBAAQ,MAAM,QAAQ,YAAY,GAAG,GAAG,WAAW,MAAM,IAAI;AAC1E,YAAM,IAAI,MAAM,IAAI,OAAO,KAAK,EAAE,MAAM,KAAK,WAAW,KAAK,OAAO,WAAW,SAAS,IAAI,CAAC;AAC7F,UAAI,CAAC,EAAG,QAAO,QAAQ,KAAK,MAAM;AAClC,UAAI,EAAE,SAAU,QAAO,EAAE;AACzB,YAAM,IAAI,KAAK,EAAE,IAAI;AACrB,UAAI;AACJ,UAAI;AACF,cAAM,MAAM,QAAQ,KAAK,MAAM;AAAA,MACjC,SAAS,KAAK;AACZ,YAAI,MAAM,KAAK,EAAE,MAAM,GAAG;AAC1B,cAAM;AAAA,MACR;AACA,UAAI,CAAC,KAAK;AAAE,YAAI,MAAM,KAAK,EAAE,MAAM,GAAG;AAAG,eAAO;AAAA,MAAK;AACrD,UAAI,MAAM,KAAK,EAAE,MAAM,IAAI,MAAM;AACjC,aAAO,EAAE,KAAK,oBAAgB,4BAAc,KAAK,EAAE,KAAK,aAAa,IAAI;AAAA,IAC3E;AAAA,EACF;AACF;AAIO,IAAM,QAAQ,CAAC,KAAc,OAAe,aAA4C,aAAAC,OAAU,MAAM,IAAI,GAAG,GAAG,OAAO,IAAI;AAG7H,IAAM,YAAY,CAAC,YAAyB,aAAAC,WAAc,MAAM,IAAI,GAAG,CAAC;AAGxE,SAAS,cAAoB;AAClC,aAAW,OAAO,UAAW,KAAI,MAAM;AACzC;","names":["iife","coreTrack","coreScriptTag"]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { FetchCamadaOptions, FetchVars } from '@camada/core/fetch';
|
|
2
|
+
|
|
3
|
+
/** The one thing the wrapper needs from Bun's server, typed structurally so the emitted types need no `bun` import; any `Bun.Server` satisfies it. */
|
|
4
|
+
interface BunServer {
|
|
5
|
+
requestIP(req: Request): {
|
|
6
|
+
address: string;
|
|
7
|
+
} | null;
|
|
8
|
+
}
|
|
9
|
+
/** A Bun fetch handler. `undefined`/`void` is what Bun's own websocket pattern returns after `server.upgrade(req)`. */
|
|
10
|
+
type BunHandler<S extends BunServer = BunServer, R extends Response | undefined | void = Response> = (req: Request, server: S) => R | Promise<R>;
|
|
11
|
+
type CamadaBunOptions = FetchCamadaOptions;
|
|
12
|
+
type CamadaBunVars = FetchVars;
|
|
13
|
+
/**
|
|
14
|
+
* Builds one camada instance and returns `wrap`, which turns a Bun fetch handler into a guarded
|
|
15
|
+
* one: `Bun.serve({ fetch: camada()(handler) })`. Timer mode by default (a server process lives
|
|
16
|
+
* long enough to poll); `mode: 'lazy'` or CAMADA_SERVERLESS=1 switches to per-request refresh.
|
|
17
|
+
*/
|
|
18
|
+
declare function camada(opts?: CamadaBunOptions): <S extends BunServer, R extends Response | undefined | void>(handler: BunHandler<S, R>) => (req: Request, server: S) => Promise<Response | R>;
|
|
19
|
+
/** An outcome the wire cannot show (`login_failed`, `signup`, …) joined to this request's event;
|
|
20
|
+
* the user is HMAC-hashed in-process. A silent no-op where the wrapper did not run. */
|
|
21
|
+
declare const track: (req: Request, event: string, data?: {
|
|
22
|
+
user?: string;
|
|
23
|
+
}) => Promise<void>;
|
|
24
|
+
/** The beacon `<script>` tag for an HTML response — `''` where the wrapper did not run or the tenant turned the beacon off. */
|
|
25
|
+
declare const scriptTag: (req: Request) => string;
|
|
26
|
+
/** Test/reset hook: stops every poller and exit handler of every instance this module created. */
|
|
27
|
+
declare function resetCamada(): void;
|
|
28
|
+
|
|
29
|
+
export { type BunHandler, type BunServer, type CamadaBunOptions, type CamadaBunVars, camada, resetCamada, scriptTag, track };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { FetchCamadaOptions, FetchVars } from '@camada/core/fetch';
|
|
2
|
+
|
|
3
|
+
/** The one thing the wrapper needs from Bun's server, typed structurally so the emitted types need no `bun` import; any `Bun.Server` satisfies it. */
|
|
4
|
+
interface BunServer {
|
|
5
|
+
requestIP(req: Request): {
|
|
6
|
+
address: string;
|
|
7
|
+
} | null;
|
|
8
|
+
}
|
|
9
|
+
/** A Bun fetch handler. `undefined`/`void` is what Bun's own websocket pattern returns after `server.upgrade(req)`. */
|
|
10
|
+
type BunHandler<S extends BunServer = BunServer, R extends Response | undefined | void = Response> = (req: Request, server: S) => R | Promise<R>;
|
|
11
|
+
type CamadaBunOptions = FetchCamadaOptions;
|
|
12
|
+
type CamadaBunVars = FetchVars;
|
|
13
|
+
/**
|
|
14
|
+
* Builds one camada instance and returns `wrap`, which turns a Bun fetch handler into a guarded
|
|
15
|
+
* one: `Bun.serve({ fetch: camada()(handler) })`. Timer mode by default (a server process lives
|
|
16
|
+
* long enough to poll); `mode: 'lazy'` or CAMADA_SERVERLESS=1 switches to per-request refresh.
|
|
17
|
+
*/
|
|
18
|
+
declare function camada(opts?: CamadaBunOptions): <S extends BunServer, R extends Response | undefined | void>(handler: BunHandler<S, R>) => (req: Request, server: S) => Promise<Response | R>;
|
|
19
|
+
/** An outcome the wire cannot show (`login_failed`, `signup`, …) joined to this request's event;
|
|
20
|
+
* the user is HMAC-hashed in-process. A silent no-op where the wrapper did not run. */
|
|
21
|
+
declare const track: (req: Request, event: string, data?: {
|
|
22
|
+
user?: string;
|
|
23
|
+
}) => Promise<void>;
|
|
24
|
+
/** The beacon `<script>` tag for an HTML response — `''` where the wrapper did not run or the tenant turned the beacon off. */
|
|
25
|
+
declare const scriptTag: (req: Request) => string;
|
|
26
|
+
/** Test/reset hook: stops every poller and exit handler of every instance this module created. */
|
|
27
|
+
declare function resetCamada(): void;
|
|
28
|
+
|
|
29
|
+
export { type BunHandler, type BunServer, type CamadaBunOptions, type CamadaBunVars, camada, resetCamada, scriptTag, track };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// src/camada.ts
|
|
2
|
+
import { TAP_BUN, guarded } from "@camada/core";
|
|
3
|
+
import { createFetchCamada, withSetCookie, track as coreTrack, scriptTag as coreScriptTag } from "@camada/core/fetch";
|
|
4
|
+
import iife from "@camada/browser/iife-string";
|
|
5
|
+
|
|
6
|
+
// package.json
|
|
7
|
+
var name = "@camada/bun";
|
|
8
|
+
var version = "0.1.0";
|
|
9
|
+
|
|
10
|
+
// src/version.ts
|
|
11
|
+
var SDK_ID = `${name}/${version}`;
|
|
12
|
+
|
|
13
|
+
// src/camada.ts
|
|
14
|
+
var slots = /* @__PURE__ */ new WeakMap();
|
|
15
|
+
var instances = /* @__PURE__ */ new Set();
|
|
16
|
+
function camada(opts = {}) {
|
|
17
|
+
const cam = createFetchCamada({ tap: TAP_BUN, sdk: SDK_ID, iife }, { ...opts, mode: opts.mode ?? "timer" });
|
|
18
|
+
instances.add(cam);
|
|
19
|
+
return function wrap(handler) {
|
|
20
|
+
return async (req, server) => {
|
|
21
|
+
const peer = guarded(() => server?.requestIP?.(req)?.address ?? null, null);
|
|
22
|
+
const r = await cam.before(req, { peer, env: globalThis.Bun?.env ?? globalThis.process?.env });
|
|
23
|
+
if (!r) return handler(req, server);
|
|
24
|
+
if (r.response) return r.response;
|
|
25
|
+
slots.set(req, r.vars);
|
|
26
|
+
let res;
|
|
27
|
+
try {
|
|
28
|
+
res = await handler(req, server);
|
|
29
|
+
} catch (err) {
|
|
30
|
+
cam.after(req, r.vars, 500);
|
|
31
|
+
throw err;
|
|
32
|
+
}
|
|
33
|
+
if (!res) {
|
|
34
|
+
cam.after(req, r.vars, 101);
|
|
35
|
+
return res;
|
|
36
|
+
}
|
|
37
|
+
cam.after(req, r.vars, res.status);
|
|
38
|
+
return r.vars.sessionCookie ? withSetCookie(res, r.vars.sessionCookie) : res;
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
var track = (req, event, data) => coreTrack(slots.get(req), event, data);
|
|
43
|
+
var scriptTag = (req) => coreScriptTag(slots.get(req));
|
|
44
|
+
function resetCamada() {
|
|
45
|
+
for (const cam of instances) cam.reset();
|
|
46
|
+
}
|
|
47
|
+
export {
|
|
48
|
+
camada,
|
|
49
|
+
resetCamada,
|
|
50
|
+
scriptTag,
|
|
51
|
+
track
|
|
52
|
+
};
|
|
53
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/camada.ts","../package.json","../src/version.ts"],"sourcesContent":["// @camada/bun — the Bun.serve binding for @camada/core/fetch. `wrap` sits around the app's fetch\n// handler: the socket peer comes from `server.requestIP` (the one address Bun vouches for), the\n// env from Bun.env, and there is no waitUntil — a long-lived process polls on a timer and drains\n// on exit instead. Bun has no per-request context object, so the Request itself keys the slot.\nimport { TAP_BUN, guarded } from '@camada/core';\nimport { createFetchCamada, withSetCookie, track as coreTrack, scriptTag as coreScriptTag, type FetchCamada, type FetchCamadaOptions, type FetchVars } from '@camada/core/fetch';\nimport iife from '@camada/browser/iife-string';\nimport { SDK_ID } from './version.js';\n\n/** The one thing the wrapper needs from Bun's server, typed structurally so the emitted types need no `bun` import; any `Bun.Server` satisfies it. */\nexport interface BunServer { requestIP(req: Request): { address: string } | null }\n/** A Bun fetch handler. `undefined`/`void` is what Bun's own websocket pattern returns after `server.upgrade(req)`. */\nexport type BunHandler<S extends BunServer = BunServer, R extends Response | undefined | void = Response> = (req: Request, server: S) => R | Promise<R>;\nexport type CamadaBunOptions = FetchCamadaOptions;\nexport type CamadaBunVars = FetchVars;\n\nconst slots = new WeakMap<Request, FetchVars>(); // set only when the app is about to run; camada's own answers leave nothing\nconst instances = new Set<FetchCamada>();\n\n/**\n * Builds one camada instance and returns `wrap`, which turns a Bun fetch handler into a guarded\n * one: `Bun.serve({ fetch: camada()(handler) })`. Timer mode by default (a server process lives\n * long enough to poll); `mode: 'lazy'` or CAMADA_SERVERLESS=1 switches to per-request refresh.\n */\nexport function camada(opts: CamadaBunOptions = {}) {\n const cam = createFetchCamada({ tap: TAP_BUN, sdk: SDK_ID, iife }, { ...opts, mode: opts.mode ?? 'timer' });\n instances.add(cam);\n return function wrap<S extends BunServer, R extends Response | undefined | void>(handler: BunHandler<S, R>): (req: Request, server: S) => Promise<Response | R> {\n return async (req, server) => {\n // Guarded: a runtime that is not Bun (or a Bun without a socket for this request) hands us\n // no peer rather than a throw. Client headers are core's to judge under the trusted-proxy rules.\n const peer = guarded(() => server?.requestIP?.(req)?.address ?? null, null);\n const r = await cam.before(req, { peer, env: globalThis.Bun?.env ?? globalThis.process?.env }); // Bun.env is process.env under Bun; the fallback is this suite under Node\n if (!r) return handler(req, server);\n if (r.response) return r.response;\n slots.set(req, r.vars);\n let res: R;\n try {\n res = await handler(req, server);\n } catch (err) {\n cam.after(req, r.vars, 500); // Bun answers 500 for a thrown handler (an `error` callback returning its own status is not visible here)\n throw err;\n }\n if (!res) { cam.after(req, r.vars, 101); return res; } // a websocket upgrade: Bun answers 101 itself, so there is no Response to carry a cookie\n cam.after(req, r.vars, res.status);\n return r.vars.sessionCookie ? withSetCookie(res, r.vars.sessionCookie) : res;\n };\n };\n}\n\n/** An outcome the wire cannot show (`login_failed`, `signup`, …) joined to this request's event;\n * the user is HMAC-hashed in-process. A silent no-op where the wrapper did not run. */\nexport const track = (req: Request, event: string, data?: { user?: string }): Promise<void> => coreTrack(slots.get(req), event, data);\n\n/** The beacon `<script>` tag for an HTML response — `''` where the wrapper did not run or the tenant turned the beacon off. */\nexport const scriptTag = (req: Request): string => coreScriptTag(slots.get(req));\n\n/** Test/reset hook: stops every poller and exit handler of every instance this module created. */\nexport function resetCamada(): void {\n for (const cam of instances) cam.reset();\n}\n","{\n \"name\": \"@camada/bun\",\n \"version\": \"0.1.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 Bun.serve: snapshot enforcement, first-party challenge and beacon, track() outcomes, the socket peer from server.requestIP, timer-polled snapshot with exit flush\",\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 \"devDependencies\": {\n \"@types/bun\": \"^1.2.0\",\n \"@types/node\": \"^22.10.0\",\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"],"mappings":";AAIA,SAAS,SAAS,eAAe;AACjC,SAAS,mBAAmB,eAAe,SAAS,WAAW,aAAa,qBAAgF;AAC5J,OAAO,UAAU;;;ACLf,WAAQ;AACR,cAAW;;;ACCN,IAAM,SAAS,GAAG,IAAI,IAAI,OAAO;;;AFaxC,IAAM,QAAQ,oBAAI,QAA4B;AAC9C,IAAM,YAAY,oBAAI,IAAiB;AAOhC,SAAS,OAAO,OAAyB,CAAC,GAAG;AAClD,QAAM,MAAM,kBAAkB,EAAE,KAAK,SAAS,KAAK,QAAQ,KAAK,GAAG,EAAE,GAAG,MAAM,MAAM,KAAK,QAAQ,QAAQ,CAAC;AAC1G,YAAU,IAAI,GAAG;AACjB,SAAO,SAAS,KAAiE,SAA+E;AAC9J,WAAO,OAAO,KAAK,WAAW;AAG5B,YAAM,OAAO,QAAQ,MAAM,QAAQ,YAAY,GAAG,GAAG,WAAW,MAAM,IAAI;AAC1E,YAAM,IAAI,MAAM,IAAI,OAAO,KAAK,EAAE,MAAM,KAAK,WAAW,KAAK,OAAO,WAAW,SAAS,IAAI,CAAC;AAC7F,UAAI,CAAC,EAAG,QAAO,QAAQ,KAAK,MAAM;AAClC,UAAI,EAAE,SAAU,QAAO,EAAE;AACzB,YAAM,IAAI,KAAK,EAAE,IAAI;AACrB,UAAI;AACJ,UAAI;AACF,cAAM,MAAM,QAAQ,KAAK,MAAM;AAAA,MACjC,SAAS,KAAK;AACZ,YAAI,MAAM,KAAK,EAAE,MAAM,GAAG;AAC1B,cAAM;AAAA,MACR;AACA,UAAI,CAAC,KAAK;AAAE,YAAI,MAAM,KAAK,EAAE,MAAM,GAAG;AAAG,eAAO;AAAA,MAAK;AACrD,UAAI,MAAM,KAAK,EAAE,MAAM,IAAI,MAAM;AACjC,aAAO,EAAE,KAAK,gBAAgB,cAAc,KAAK,EAAE,KAAK,aAAa,IAAI;AAAA,IAC3E;AAAA,EACF;AACF;AAIO,IAAM,QAAQ,CAAC,KAAc,OAAe,SAA4C,UAAU,MAAM,IAAI,GAAG,GAAG,OAAO,IAAI;AAG7H,IAAM,YAAY,CAAC,QAAyB,cAAc,MAAM,IAAI,GAAG,CAAC;AAGxE,SAAS,cAAoB;AAClC,aAAW,OAAO,UAAW,KAAI,MAAM;AACzC;","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@camada/bun",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=20"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md",
|
|
12
|
+
"LICENSE"
|
|
13
|
+
],
|
|
14
|
+
"description": "camada SDK for Bun.serve: snapshot enforcement, first-party challenge and beacon, track() outcomes, the socket peer from server.requestIP, timer-polled snapshot with exit flush",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"import": "./dist/index.js",
|
|
19
|
+
"require": "./dist/index.cjs"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"main": "./dist/index.cjs",
|
|
23
|
+
"module": "./dist/index.js",
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@camada/core": "^0.3.0",
|
|
27
|
+
"@camada/browser": "^0.2.0"
|
|
28
|
+
},
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/camada-app/camada-bun.git"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://github.com/camada-app/camada-bun#readme",
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/camada-app/camada-bun/issues"
|
|
36
|
+
}
|
|
37
|
+
}
|