@mybase-dev/sdk 1.0.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 +206 -0
- package/dist/analytics-script.d.ts +11 -0
- package/dist/analytics.d.ts +89 -0
- package/dist/analytics.js +224 -0
- package/dist/analytics.min.js +3 -0
- package/dist/cache.d.ts +91 -0
- package/dist/cache.js +277 -0
- package/dist/config.d.ts +65 -0
- package/dist/config.js +9 -0
- package/dist/cron.d.ts +166 -0
- package/dist/cron.js +190 -0
- package/dist/queue.d.ts +279 -0
- package/dist/queue.js +358 -0
- package/dist/ratelimit.d.ts +89 -0
- package/dist/ratelimit.js +244 -0
- package/package.json +63 -0
- package/src/analytics.ts +311 -0
- package/src/cache.ts +191 -0
- package/src/config.ts +76 -0
- package/src/cron.ts +339 -0
- package/src/queue.ts +461 -0
- package/src/ratelimit.ts +150 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Landon Harter
|
|
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,206 @@
|
|
|
1
|
+
# @mybase-dev/sdk
|
|
2
|
+
|
|
3
|
+
Helpers for apps that run on [mybase](https://github.com/landonharter/mybase), a self-hosted backend.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm install @mybase-dev/sdk # or bun add / pnpm add
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
No runtime dependencies. Uses Web Crypto, so it works in Node 20+, Bun, Deno, Next.js route handlers, Hono, and edge runtimes.
|
|
10
|
+
|
|
11
|
+
## Analytics (`@mybase-dev/sdk/analytics`)
|
|
12
|
+
|
|
13
|
+
Page views, custom events, and errors, shown on the project's Analytics page. Turn analytics on for the project (dashboard, or `mybase analytics enable <project>`) to get its public write key, then:
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { createAnalytics } from "@mybase-dev/sdk/analytics";
|
|
17
|
+
|
|
18
|
+
const analytics = createAnalytics({
|
|
19
|
+
apiUrl: "https://mybase.example.com",
|
|
20
|
+
project: "shop",
|
|
21
|
+
key: "mba_…", // public: fine in browser code, it can only add events
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
analytics.track("signup", { plan: "pro" });
|
|
25
|
+
analytics.captureError(err);
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
In a browser it records page views (including History API route changes) and uncaught errors on its own, batches events, and sends them with `sendBeacon` when the page is hidden. On a server, call `await analytics.flush()` before exiting. No cookies; visitors are counted with a daily-rotating hash and IPs are never stored.
|
|
29
|
+
|
|
30
|
+
Plain HTML sites can use the script tag mybase serves instead:
|
|
31
|
+
|
|
32
|
+
```html
|
|
33
|
+
<script defer src="https://mybase.example.com/analytics.js" data-project="shop" data-key="mba_…"></script>
|
|
34
|
+
<script>mybase.track("signup", { plan: "pro" })</script>
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Full guide: [docs/analytics.md](docs/analytics.md). Wire format and admin routes: [docs/analytics-api.md](docs/analytics-api.md).
|
|
38
|
+
|
|
39
|
+
## Cache (`@mybase-dev/sdk/cache`)
|
|
40
|
+
|
|
41
|
+
A Redis-compatible cache per project. Turn it on (dashboard, or `mybase cache enable <project>`) to get its password. On the LAN, use any Redis client with `keyPrefix: "<project>:"`. From anywhere else, over HTTP with an API key (`cache:write`):
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
import { createCache } from "@mybase-dev/sdk/cache";
|
|
45
|
+
|
|
46
|
+
const cache = createCache({ apiUrl: "https://mybase.example.com", project: "shop", apiKey: process.env.MYBASE_API_KEY! });
|
|
47
|
+
await cache.set("session:abc", userId, { ex: 3600 }); // key: shop:session:abc
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Full guide: [docs/cache.md](docs/cache.md).
|
|
51
|
+
|
|
52
|
+
## Rate limits (`@mybase-dev/sdk/ratelimit`)
|
|
53
|
+
|
|
54
|
+
Limits shared by every instance of your app. Turn them on (dashboard, or `mybase ratelimit enable <project>`), create an API key with `ratelimit:write`, define rules on the dashboard or in `mybase.config.ts`, then:
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
import { createRateLimiter, rateLimitHeaders } from "@mybase-dev/sdk/ratelimit";
|
|
58
|
+
|
|
59
|
+
const limiter = createRateLimiter({ apiUrl: "https://mybase.example.com", project: "shop", apiKey: process.env.MYBASE_API_KEY! });
|
|
60
|
+
const result = await limiter.limit("login", ip); // or limiter.limit({ limit: 10, window: "1m" }, ip)
|
|
61
|
+
if (!result.success) return new Response("Too many requests", { status: 429, headers: rateLimitHeaders(result) });
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
If mybase can't be reached, it allows the request (pass `failClosed: true` to deny instead). Full guide: [docs/rate-limits.md](docs/rate-limits.md).
|
|
65
|
+
|
|
66
|
+
## Queues (`@mybase-dev/sdk/queue`)
|
|
67
|
+
|
|
68
|
+
SQS-style standard and FIFO queues. Turn them on (dashboard, or `mybase queue enable <project>`), create an API key with `queue:write`, then:
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
import { createQueueClient } from "@mybase-dev/sdk/queue";
|
|
72
|
+
|
|
73
|
+
const queue = createQueueClient({ apiUrl: "https://mybase.example.com", project: "shop", apiKey: process.env.MYBASE_API_KEY! });
|
|
74
|
+
await queue.send("emails", { to: "ada@example.com" });
|
|
75
|
+
queue.consume("emails", async (message) => sendEmail(message.json()));
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Delivery is at least once; failed messages are retried and can go to a dead-letter queue. Full guide: [docs/queues.md](docs/queues.md).
|
|
79
|
+
|
|
80
|
+
## Cron (`@mybase-dev/sdk/cron`)
|
|
81
|
+
|
|
82
|
+
A mybase cron job POSTs to a URL in your app on a schedule. Each call is signed with your project's cron secret. You can find it on the project's Cron page, or get it with `mybase cron secret <project>`. Set it as `MYBASE_CRON_SECRET`, then use the handler, which checks the signature and runs the job by name:
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
// app/api/cron/route.ts (Next.js); any framework that takes (Request) => Response works the same
|
|
86
|
+
import { cronHandler } from "@mybase-dev/sdk/cron";
|
|
87
|
+
|
|
88
|
+
export const POST = cronHandler({
|
|
89
|
+
secret: process.env.MYBASE_CRON_SECRET!,
|
|
90
|
+
jobs: {
|
|
91
|
+
cleanup: async (run) => {
|
|
92
|
+
// run.payload: the job's JSON payload
|
|
93
|
+
// run.runId: same across retries, so you can dedupe
|
|
94
|
+
// run.attempt: 1, then 2, 3, … on retries
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Responses: `200` when the job finishes, `401` for a bad or stale signature, `404` for a job with no handler, `500` if the job throws. mybase retries timeouts, `429`, and `5xx` (2 retries by default). It doesn't retry other `4xx` responses.
|
|
101
|
+
|
|
102
|
+
**Rotating the secret:** pass both secrets, new first, while you deploy: `secret: [process.env.MYBASE_CRON_SECRET!, process.env.MYBASE_CRON_SECRET_OLD!]`.
|
|
103
|
+
|
|
104
|
+
### Lower level
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
import { verifyCronRequest, verifyCronSignature, CronVerificationError } from "@mybase-dev/sdk/cron";
|
|
108
|
+
|
|
109
|
+
// Fetch API Request: verifies, then returns the parsed CronRun. Throws CronVerificationError.
|
|
110
|
+
const run = await verifyCronRequest(request, process.env.MYBASE_CRON_SECRET!);
|
|
111
|
+
|
|
112
|
+
// Raw body + header (e.g. Express with express.text({ type: "application/json" })):
|
|
113
|
+
await verifyCronSignature({ body: req.body, signature: req.get("X-Mybase-Signature"), secret });
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Define jobs in code
|
|
117
|
+
|
|
118
|
+
Declare jobs next to your app and push them with the mybase CLI. Keep this file free of app code: the CLI imports only this file.
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
// mybase.config.ts
|
|
122
|
+
import { defineConfig } from "@mybase-dev/sdk/config";
|
|
123
|
+
|
|
124
|
+
export default defineConfig({
|
|
125
|
+
project: "shop",
|
|
126
|
+
cron: {
|
|
127
|
+
baseUrl: "https://shop.example.com", // must be reachable from the mybase server
|
|
128
|
+
jobs: {
|
|
129
|
+
cleanup: { schedule: "0 3 * * *" }, // POSTs to baseUrl + /api/cron
|
|
130
|
+
digest: { schedule: "0 8 * * 1", timezone: "America/Chicago", path: "/api/cron", payload: { kind: "weekly" } },
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
```sh
|
|
137
|
+
mybase cron sync --dry-run # show what would change
|
|
138
|
+
mybase cron sync # create, update, and delete code-defined jobs to match
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Jobs created in the dashboard are never touched by a sync. For typed job names in the handler, pass the config type: `cronHandler<typeof config>({ ... })`.
|
|
142
|
+
|
|
143
|
+
Job options: `schedule` (5-field cron, minute precision), `path` (default `/api/cron`), `timezone` (IANA, default `UTC`), `payload` (JSON, up to 16 KB), `retries` (0–5, default 2), `timeoutMs` (1000–120000, default 30000).
|
|
144
|
+
|
|
145
|
+
### One-off tasks
|
|
146
|
+
|
|
147
|
+
Schedule a single call to a handler from your app code, e.g. a reminder a day after an order. The task calls the same `cronHandler` as cron jobs: `run.job` is the name you pass, `run.trigger` is `"task"`, and `run.runId` is the task id.
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
import { createCronClient } from "@mybase-dev/sdk/cron";
|
|
151
|
+
|
|
152
|
+
const cron = createCronClient({
|
|
153
|
+
apiUrl: process.env.MYBASE_URL!, // the mybase API, as reachable from your app
|
|
154
|
+
project: "shop",
|
|
155
|
+
apiKey: process.env.MYBASE_API_KEY!, // a mybase API key with cron:write; server-side only
|
|
156
|
+
baseUrl: "https://shop.example.com", // your app, as reachable from mybase
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const task = await cron.schedule("send-reminder", {
|
|
160
|
+
in: "24h", // or at: new Date("2026-12-01T09:00:00Z"); neither = as soon as possible
|
|
161
|
+
payload: { orderId: 42 },
|
|
162
|
+
key: "reminder-42", // optional dedupe key, see below
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
await cron.get(task.id); // CronTask, or null
|
|
166
|
+
await cron.cancelByKey("reminder-42"); // or cron.cancel(task.id); only pending tasks
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
// the handler, next to your cron jobs
|
|
171
|
+
export const POST = cronHandler({
|
|
172
|
+
secret: process.env.MYBASE_CRON_SECRET!,
|
|
173
|
+
jobs: {
|
|
174
|
+
"send-reminder": async (run) => {
|
|
175
|
+
// run.payload: { orderId: 42 }, run.key: "reminder-42"
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
- **Delays:** milliseconds, or `"30s"`, `"15m"`, `"2h"`, `"7d"`. Up to a year out. Measured by the mybase server's clock.
|
|
182
|
+
- **Keys:** while a task with a key is pending or running, scheduling the same key returns that task instead of a new one. After it finishes, the key is free again.
|
|
183
|
+
- **Durable:** tasks live in Postgres. One that comes due while mybase is down runs when it's back; one interrupted mid-call runs again. Calls are at least once, so dedupe on `run.runId` if it matters.
|
|
184
|
+
- **Retries and timeouts** work like jobs: `retries` (0–5, default 2), `timeoutMs` (1000–120000, default 30000). `path` (default `/api/cron`) is appended to `baseUrl`; `url` overrides both.
|
|
185
|
+
- Finished and cancelled tasks are kept for 7 days. A project can have 10,000 pending tasks.
|
|
186
|
+
- Errors from the API throw `MybaseApiError` (with `status`); `get`/`cancel` resolve to `null` for an unknown task.
|
|
187
|
+
|
|
188
|
+
The client sends an API key with `cron:write` (not an admin one) as `Authorization: Bearer`.
|
|
189
|
+
|
|
190
|
+
### Signature format
|
|
191
|
+
|
|
192
|
+
For verifying in other languages:
|
|
193
|
+
|
|
194
|
+
```
|
|
195
|
+
X-Mybase-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Reject the call if `t` is more than 5 minutes from now, then compare `v1` in constant time. The body is JSON:
|
|
199
|
+
|
|
200
|
+
```json
|
|
201
|
+
{ "job": "cleanup", "runId": "…", "attempt": 1, "trigger": "schedule", "scheduledAt": "…", "firedAt": "…", "payload": null }
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
`X-Mybase-Job` and `X-Mybase-Run` headers carry the job name and run id too. Task calls add `"key"`, and `trigger` is `"task"`.
|
|
205
|
+
|
|
206
|
+
Requests from your app to the mybase API (`createCronClient`) carry an API key as `Authorization: Bearer`. Routes: `POST /cron/<project>/tasks`, `GET|DELETE /cron/<project>/tasks/<id>`, `GET|DELETE /cron/<project>/tasks/by-key/<key>`.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
export type AnalyticsEventType = "pageview" | "event" | "error";
|
|
2
|
+
/** Custom properties: flat, small, JSON-safe values. */
|
|
3
|
+
export type AnalyticsProps = Record<string, string | number | boolean | null>;
|
|
4
|
+
/** One event on the wire. */
|
|
5
|
+
export interface AnalyticsEvent {
|
|
6
|
+
type: AnalyticsEventType;
|
|
7
|
+
/** Custom events: the event name. Errors: the message. Unused for page views. */
|
|
8
|
+
name?: string;
|
|
9
|
+
/** The route, e.g. `/pricing`. Query string stripped unless the client keeps it. */
|
|
10
|
+
path?: string;
|
|
11
|
+
/** Page views: the page that linked here. mybase keeps only its host. */
|
|
12
|
+
referrer?: string;
|
|
13
|
+
props?: AnalyticsProps;
|
|
14
|
+
/** Errors: the stack trace. */
|
|
15
|
+
stack?: string;
|
|
16
|
+
/** The browser's IANA time zone, used to guess the country when the request doesn't say. */
|
|
17
|
+
tz?: string;
|
|
18
|
+
/** When it happened (ISO). Defaults to when mybase received it. */
|
|
19
|
+
ts?: string;
|
|
20
|
+
}
|
|
21
|
+
/** The body of `POST /analytics/<project>/events`. */
|
|
22
|
+
export interface AnalyticsIngestRequest {
|
|
23
|
+
key: string;
|
|
24
|
+
events: AnalyticsEvent[];
|
|
25
|
+
}
|
|
26
|
+
/** What the ingest route accepts. Bigger values are rejected (strings are not truncated for you, except by this client). */
|
|
27
|
+
export declare const ANALYTICS_LIMITS: {
|
|
28
|
+
/** Events per request. */
|
|
29
|
+
readonly batch: 50;
|
|
30
|
+
/** Request body, in bytes. */
|
|
31
|
+
readonly bodyBytes: number;
|
|
32
|
+
readonly props: 20;
|
|
33
|
+
readonly propKey: 64;
|
|
34
|
+
readonly propValue: 256;
|
|
35
|
+
readonly path: 512;
|
|
36
|
+
readonly referrer: 512;
|
|
37
|
+
/** Error messages. */
|
|
38
|
+
readonly message: 512;
|
|
39
|
+
readonly stack: 4096;
|
|
40
|
+
};
|
|
41
|
+
/** Custom event names: letters, digits, space, and `_ . : -`, 1–64 chars. */
|
|
42
|
+
export declare const ANALYTICS_EVENT_NAME_PATTERN: RegExp;
|
|
43
|
+
export interface AnalyticsOptions {
|
|
44
|
+
/** Where the mybase API is reachable from the browser (or server), e.g. `https://mybase.example.com`. */
|
|
45
|
+
apiUrl: string;
|
|
46
|
+
project: string;
|
|
47
|
+
/** The project's public write key (`mba_…`), from the Analytics page. */
|
|
48
|
+
key: string;
|
|
49
|
+
/** Browser: record a page view on load and on every client-side route change. Default true. */
|
|
50
|
+
autoPageviews?: boolean;
|
|
51
|
+
/** Browser: record uncaught errors and unhandled promise rejections. Default true. */
|
|
52
|
+
captureErrors?: boolean;
|
|
53
|
+
/** Keep the query string in paths. Default false (`/search?q=x` is recorded as `/search`). */
|
|
54
|
+
keepQuery?: boolean;
|
|
55
|
+
/** Send nothing when the browser's Do Not Track setting is on. Default false. */
|
|
56
|
+
respectDoNotTrack?: boolean;
|
|
57
|
+
/** How long to collect events before sending, in ms. Default 5000. */
|
|
58
|
+
flushIntervalMs?: number;
|
|
59
|
+
/** Send right away once this many events are queued. Default 20. */
|
|
60
|
+
batchSize?: number;
|
|
61
|
+
/** Called when a send fails. Analytics never throws into your app; failed events are dropped. */
|
|
62
|
+
onError?: (err: unknown) => void;
|
|
63
|
+
/** Default `globalThis.fetch`. */
|
|
64
|
+
fetch?: typeof fetch;
|
|
65
|
+
}
|
|
66
|
+
export interface Analytics {
|
|
67
|
+
/** Records a page view. `path` defaults to the current location (browser). */
|
|
68
|
+
pageview(path?: string, props?: AnalyticsProps): void;
|
|
69
|
+
/** Records a custom event, e.g. `track("signup", { plan: "pro" })`. */
|
|
70
|
+
track(name: string, props?: AnalyticsProps): void;
|
|
71
|
+
/** Records an error (an `Error`, or anything else, which is stringified). */
|
|
72
|
+
captureError(error: unknown, props?: AnalyticsProps): void;
|
|
73
|
+
/** Sends everything queued now. Call it before a server process exits. */
|
|
74
|
+
flush(): Promise<void>;
|
|
75
|
+
/** Sends what's queued and removes every listener this client added. */
|
|
76
|
+
stop(): void;
|
|
77
|
+
}
|
|
78
|
+
/** `/a/b?x=1#h` → `/a/b` (or `/a/b?x=1` with `keepQuery`). Full URLs are reduced to their path. */
|
|
79
|
+
export declare function normalizePath(input: string, keepQuery?: boolean): string;
|
|
80
|
+
/** `"TypeError: x is undefined"` and its stack, from anything thrown. */
|
|
81
|
+
export declare function describeError(error: unknown): {
|
|
82
|
+
message: string;
|
|
83
|
+
stack?: string;
|
|
84
|
+
};
|
|
85
|
+
/**
|
|
86
|
+
* const analytics = createAnalytics({ apiUrl: "https://mybase.example.com", project: "shop", key: "mba_…" });
|
|
87
|
+
* analytics.track("signup", { plan: "pro" });
|
|
88
|
+
*/
|
|
89
|
+
export declare function createAnalytics(options: AnalyticsOptions): Analytics;
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// src/analytics.ts
|
|
2
|
+
var ANALYTICS_LIMITS = {
|
|
3
|
+
batch: 50,
|
|
4
|
+
bodyBytes: 64 * 1024,
|
|
5
|
+
props: 20,
|
|
6
|
+
propKey: 64,
|
|
7
|
+
propValue: 256,
|
|
8
|
+
path: 512,
|
|
9
|
+
referrer: 512,
|
|
10
|
+
message: 512,
|
|
11
|
+
stack: 4096
|
|
12
|
+
};
|
|
13
|
+
var ANALYTICS_EVENT_NAME_PATTERN = /^[A-Za-z0-9_.:\- ]{1,64}$/;
|
|
14
|
+
function normalizePath(input, keepQuery = false) {
|
|
15
|
+
let path = input.trim();
|
|
16
|
+
const scheme = /^[a-z][a-z0-9+.-]*:\/\/[^/?#]*/i.exec(path);
|
|
17
|
+
if (scheme)
|
|
18
|
+
path = path.slice(scheme[0].length);
|
|
19
|
+
path = path.split("#", 1)[0];
|
|
20
|
+
if (!keepQuery)
|
|
21
|
+
path = path.split("?", 1)[0];
|
|
22
|
+
if (!path.startsWith("/"))
|
|
23
|
+
path = `/${path}`;
|
|
24
|
+
return path.slice(0, ANALYTICS_LIMITS.path);
|
|
25
|
+
}
|
|
26
|
+
function cleanProps(props) {
|
|
27
|
+
if (!props)
|
|
28
|
+
return;
|
|
29
|
+
const out = {};
|
|
30
|
+
let n = 0;
|
|
31
|
+
for (const [k, v] of Object.entries(props)) {
|
|
32
|
+
if (n >= ANALYTICS_LIMITS.props || !k || k.length > ANALYTICS_LIMITS.propKey)
|
|
33
|
+
continue;
|
|
34
|
+
if (typeof v === "string")
|
|
35
|
+
out[k] = v.slice(0, ANALYTICS_LIMITS.propValue);
|
|
36
|
+
else if (v === null || typeof v === "boolean" || typeof v === "number" && Number.isFinite(v))
|
|
37
|
+
out[k] = v;
|
|
38
|
+
else
|
|
39
|
+
continue;
|
|
40
|
+
n++;
|
|
41
|
+
}
|
|
42
|
+
return n > 0 ? out : undefined;
|
|
43
|
+
}
|
|
44
|
+
function describeError(error) {
|
|
45
|
+
if (error instanceof Error) {
|
|
46
|
+
const message = error.message ? `${error.name}: ${error.message}` : error.name;
|
|
47
|
+
return { message: message.slice(0, ANALYTICS_LIMITS.message), stack: error.stack?.slice(0, ANALYTICS_LIMITS.stack) };
|
|
48
|
+
}
|
|
49
|
+
let message;
|
|
50
|
+
try {
|
|
51
|
+
message = typeof error === "string" ? error : JSON.stringify(error) ?? String(error);
|
|
52
|
+
} catch {
|
|
53
|
+
message = String(error);
|
|
54
|
+
}
|
|
55
|
+
return { message: (message || "Unknown error").slice(0, ANALYTICS_LIMITS.message) };
|
|
56
|
+
}
|
|
57
|
+
var NAVIGATE_EVENT = "mybase:navigate";
|
|
58
|
+
var historyPatched = false;
|
|
59
|
+
function patchHistory() {
|
|
60
|
+
if (historyPatched || typeof history === "undefined")
|
|
61
|
+
return;
|
|
62
|
+
historyPatched = true;
|
|
63
|
+
for (const method of ["pushState", "replaceState"]) {
|
|
64
|
+
const original = history[method];
|
|
65
|
+
history[method] = function(...args) {
|
|
66
|
+
const result = original.apply(this, args);
|
|
67
|
+
window.dispatchEvent(new Event(NAVIGATE_EVENT));
|
|
68
|
+
return result;
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
var MAX_ERRORS_PER_PAGE = 20;
|
|
73
|
+
function createAnalytics(options) {
|
|
74
|
+
const doFetch = options.fetch ?? globalThis.fetch;
|
|
75
|
+
const url = `${options.apiUrl.replace(/\/+$/, "")}/analytics/${encodeURIComponent(options.project)}/events`;
|
|
76
|
+
const browser = typeof window !== "undefined" && typeof document !== "undefined";
|
|
77
|
+
const keepQuery = options.keepQuery ?? false;
|
|
78
|
+
const interval = options.flushIntervalMs ?? 5000;
|
|
79
|
+
const batchSize = Math.min(options.batchSize ?? 20, ANALYTICS_LIMITS.batch);
|
|
80
|
+
const tz = (() => {
|
|
81
|
+
try {
|
|
82
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || undefined;
|
|
83
|
+
} catch {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
})();
|
|
87
|
+
const dnt = browser && options.respectDoNotTrack && (navigator.doNotTrack === "1" || window.doNotTrack === "1");
|
|
88
|
+
const queue = [];
|
|
89
|
+
let timer = null;
|
|
90
|
+
let stopped = false;
|
|
91
|
+
const cleanups = [];
|
|
92
|
+
const reportFailure = (err) => options.onError?.(err);
|
|
93
|
+
function send(events, beacon) {
|
|
94
|
+
const body = JSON.stringify({ key: options.key, events });
|
|
95
|
+
if (beacon && browser && typeof navigator.sendBeacon === "function") {
|
|
96
|
+
if (navigator.sendBeacon(url, new Blob([body], { type: "text/plain" })))
|
|
97
|
+
return Promise.resolve();
|
|
98
|
+
}
|
|
99
|
+
return doFetch(url, { method: "POST", body, headers: { "Content-Type": "text/plain" }, keepalive: browser, credentials: "omit" }).then(async (res) => {
|
|
100
|
+
if (!res.ok) {
|
|
101
|
+
const data = await res.json().catch(() => ({}));
|
|
102
|
+
reportFailure(new Error(data.error ?? `mybase analytics answered ${res.status}`));
|
|
103
|
+
}
|
|
104
|
+
}, reportFailure);
|
|
105
|
+
}
|
|
106
|
+
function flushQueued(beacon) {
|
|
107
|
+
if (timer)
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
timer = null;
|
|
110
|
+
const sends = [];
|
|
111
|
+
while (queue.length > 0)
|
|
112
|
+
sends.push(send(queue.splice(0, batchSize), beacon));
|
|
113
|
+
return Promise.all(sends).then(() => {
|
|
114
|
+
return;
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
function enqueue(event) {
|
|
118
|
+
if (stopped || dnt)
|
|
119
|
+
return;
|
|
120
|
+
queue.push({ ...event, tz: event.tz ?? tz, ts: event.ts ?? new Date().toISOString() });
|
|
121
|
+
if (queue.length >= batchSize)
|
|
122
|
+
flushQueued(false);
|
|
123
|
+
else if (!timer)
|
|
124
|
+
timer = setTimeout(() => void flushQueued(false), interval);
|
|
125
|
+
}
|
|
126
|
+
let lastPath = null;
|
|
127
|
+
let firstView = true;
|
|
128
|
+
function pageview(path, props) {
|
|
129
|
+
const raw = path ?? (browser ? location.pathname + location.search : undefined);
|
|
130
|
+
if (raw === undefined)
|
|
131
|
+
return;
|
|
132
|
+
const normalized = normalizePath(raw, keepQuery);
|
|
133
|
+
const event = { type: "pageview", path: normalized, props: cleanProps(props) };
|
|
134
|
+
if (firstView && browser && document.referrer) {
|
|
135
|
+
try {
|
|
136
|
+
if (new URL(document.referrer).host !== location.host)
|
|
137
|
+
event.referrer = document.referrer.slice(0, ANALYTICS_LIMITS.referrer);
|
|
138
|
+
} catch {}
|
|
139
|
+
}
|
|
140
|
+
firstView = false;
|
|
141
|
+
lastPath = normalized;
|
|
142
|
+
enqueue(event);
|
|
143
|
+
}
|
|
144
|
+
if (browser && (options.autoPageviews ?? true)) {
|
|
145
|
+
const onRoute = () => {
|
|
146
|
+
const path = normalizePath(location.pathname + location.search, keepQuery);
|
|
147
|
+
if (path !== lastPath)
|
|
148
|
+
pageview();
|
|
149
|
+
};
|
|
150
|
+
patchHistory();
|
|
151
|
+
window.addEventListener(NAVIGATE_EVENT, onRoute);
|
|
152
|
+
window.addEventListener("popstate", onRoute);
|
|
153
|
+
cleanups.push(() => {
|
|
154
|
+
window.removeEventListener(NAVIGATE_EVENT, onRoute);
|
|
155
|
+
window.removeEventListener("popstate", onRoute);
|
|
156
|
+
});
|
|
157
|
+
pageview();
|
|
158
|
+
}
|
|
159
|
+
const seenErrors = new Set;
|
|
160
|
+
function captureError(error, props) {
|
|
161
|
+
const { message, stack } = describeError(error);
|
|
162
|
+
const id = `${message}
|
|
163
|
+
${stack?.split(`
|
|
164
|
+
`, 2)[1] ?? ""}`;
|
|
165
|
+
if (seenErrors.has(id) || seenErrors.size >= MAX_ERRORS_PER_PAGE)
|
|
166
|
+
return;
|
|
167
|
+
seenErrors.add(id);
|
|
168
|
+
enqueue({
|
|
169
|
+
type: "error",
|
|
170
|
+
name: message,
|
|
171
|
+
stack,
|
|
172
|
+
path: browser ? normalizePath(location.pathname + location.search, keepQuery) : undefined,
|
|
173
|
+
props: cleanProps(props)
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
if (browser && (options.captureErrors ?? true)) {
|
|
177
|
+
const onError = (e) => captureError(e.error ?? e.message);
|
|
178
|
+
const onRejection = (e) => captureError(e.reason);
|
|
179
|
+
window.addEventListener("error", onError);
|
|
180
|
+
window.addEventListener("unhandledrejection", onRejection);
|
|
181
|
+
cleanups.push(() => {
|
|
182
|
+
window.removeEventListener("error", onError);
|
|
183
|
+
window.removeEventListener("unhandledrejection", onRejection);
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
if (browser) {
|
|
187
|
+
const onHide = () => {
|
|
188
|
+
if (document.visibilityState === "hidden")
|
|
189
|
+
flushQueued(true);
|
|
190
|
+
};
|
|
191
|
+
const onPageHide = () => void flushQueued(true);
|
|
192
|
+
document.addEventListener("visibilitychange", onHide);
|
|
193
|
+
window.addEventListener("pagehide", onPageHide);
|
|
194
|
+
cleanups.push(() => {
|
|
195
|
+
document.removeEventListener("visibilitychange", onHide);
|
|
196
|
+
window.removeEventListener("pagehide", onPageHide);
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
pageview,
|
|
201
|
+
track(name, props) {
|
|
202
|
+
if (typeof name !== "string" || !ANALYTICS_EVENT_NAME_PATTERN.test(name)) {
|
|
203
|
+
reportFailure(new TypeError(`invalid event name "${String(name)}": use 1–64 letters, digits, spaces, or _ . : -`));
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
enqueue({ type: "event", name, path: browser ? normalizePath(location.pathname + location.search, keepQuery) : undefined, props: cleanProps(props) });
|
|
207
|
+
},
|
|
208
|
+
captureError,
|
|
209
|
+
flush: () => flushQueued(false),
|
|
210
|
+
stop() {
|
|
211
|
+
flushQueued(false);
|
|
212
|
+
stopped = true;
|
|
213
|
+
for (const cleanup of cleanups.splice(0))
|
|
214
|
+
cleanup();
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
export {
|
|
219
|
+
ANALYTICS_EVENT_NAME_PATTERN,
|
|
220
|
+
ANALYTICS_LIMITS,
|
|
221
|
+
createAnalytics,
|
|
222
|
+
describeError,
|
|
223
|
+
normalizePath
|
|
224
|
+
};
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
(()=>{var p={batch:50,bodyBytes:65536,props:20,propKey:64,propValue:256,path:512,referrer:512,message:512,stack:4096},D=/^[A-Za-z0-9_.:\- ]{1,64}$/;function g(e,i=!1){let n=e.trim(),r=/^[a-z][a-z0-9+.-]*:\/\/[^/?#]*/i.exec(n);if(r)n=n.slice(r[0].length);if(n=n.split("#",1)[0],!i)n=n.split("?",1)[0];if(!n.startsWith("/"))n=`/${n}`;return n.slice(0,p.path)}function b(e){if(!e)return;let i={},n=0;for(let[r,a]of Object.entries(e)){if(n>=p.props||!r||r.length>p.propKey)continue;if(typeof a==="string")i[r]=a.slice(0,p.propValue);else if(a===null||typeof a==="boolean"||typeof a==="number"&&Number.isFinite(a))i[r]=a;else continue;n++}return n>0?i:void 0}function M(e){if(e instanceof Error)return{message:(e.message?`${e.name}: ${e.message}`:e.name).slice(0,p.message),stack:e.stack?.slice(0,p.stack)};let i;try{i=typeof e==="string"?e:JSON.stringify(e)??String(e)}catch{i=String(e)}return{message:(i||"Unknown error").slice(0,p.message)}}var P="mybase:navigate",R=!1;function Q(){if(R||typeof history>"u")return;R=!0;for(let e of["pushState","replaceState"]){let i=history[e];history[e]=function(...n){let r=i.apply(this,n);return window.dispatchEvent(new Event(P)),r}}}var U=20;function x(e){let i=e.fetch??globalThis.fetch,n=`${e.apiUrl.replace(/\/+$/,"")}/analytics/${encodeURIComponent(e.project)}/events`,r=typeof window<"u"&&typeof document<"u",a=e.keepQuery??!1,j=e.flushIntervalMs??5000,T=Math.min(e.batchSize??20,p.batch),I=(()=>{try{return Intl.DateTimeFormat().resolvedOptions().timeZone||void 0}catch{return}})(),_=r&&e.respectDoNotTrack&&(navigator.doNotTrack==="1"||window.doNotTrack==="1"),y=[],f=null,S=!1,h=[],m=(t)=>e.onError?.(t);function O(t,s){let o=JSON.stringify({key:e.key,events:t});if(s&&r&&typeof navigator.sendBeacon==="function"){if(navigator.sendBeacon(n,new Blob([o],{type:"text/plain"})))return Promise.resolve()}return i(n,{method:"POST",body:o,headers:{"Content-Type":"text/plain"},keepalive:r,credentials:"omit"}).then(async(l)=>{if(!l.ok){let u=await l.json().catch(()=>({}));m(Error(u.error??`mybase analytics answered ${l.status}`))}},m)}function d(t){if(f)clearTimeout(f);f=null;let s=[];while(y.length>0)s.push(O(y.splice(0,T),t));return Promise.all(s).then(()=>{return})}function w(t){if(S||_)return;if(y.push({...t,tz:t.tz??I,ts:t.ts??new Date().toISOString()}),y.length>=T)d(!1);else if(!f)f=setTimeout(()=>void d(!1),j)}let L=null,N=!0;function E(t,s){let o=t??(r?location.pathname+location.search:void 0);if(o===void 0)return;let l=g(o,a),u={type:"pageview",path:l,props:b(s)};if(N&&r&&document.referrer)try{if(new URL(document.referrer).host!==location.host)u.referrer=document.referrer.slice(0,p.referrer)}catch{}N=!1,L=l,w(u)}if(r&&(e.autoPageviews??!0)){let t=()=>{if(g(location.pathname+location.search,a)!==L)E()};Q(),window.addEventListener(P,t),window.addEventListener("popstate",t),h.push(()=>{window.removeEventListener(P,t),window.removeEventListener("popstate",t)}),E()}let k=new Set;function A(t,s){let{message:o,stack:l}=M(t),u=`${o}
|
|
2
|
+
${l?.split(`
|
|
3
|
+
`,2)[1]??""}`;if(k.has(u)||k.size>=U)return;k.add(u),w({type:"error",name:o,stack:l,path:r?g(location.pathname+location.search,a):void 0,props:b(s)})}if(r&&(e.captureErrors??!0)){let t=(o)=>A(o.error??o.message),s=(o)=>A(o.reason);window.addEventListener("error",t),window.addEventListener("unhandledrejection",s),h.push(()=>{window.removeEventListener("error",t),window.removeEventListener("unhandledrejection",s)})}if(r){let t=()=>{if(document.visibilityState==="hidden")d(!0)},s=()=>void d(!0);document.addEventListener("visibilitychange",t),window.addEventListener("pagehide",s),h.push(()=>{document.removeEventListener("visibilitychange",t),window.removeEventListener("pagehide",s)})}return{pageview:E,track(t,s){if(typeof t!=="string"||!D.test(t)){m(TypeError(`invalid event name "${String(t)}": use 1–64 letters, digits, spaces, or _ . : -`));return}w({type:"event",name:t,path:r?g(location.pathname+location.search,a):void 0,props:b(s)})},captureError:A,flush:()=>d(!1),stop(){d(!1),S=!0;for(let t of h.splice(0))t()}}}var z=document.currentScript,c=z?.dataset??{},v=(e)=>e==="false"||e==="0";if(!c.project||!c.key)console.warn("mybase analytics: add data-project and data-key to the script tag");else if(!window.mybase?.analytics){let e=x({apiUrl:c.api||new URL(z.src,location.href).origin,project:c.project,key:c.key,autoPageviews:!v(c.pageviews),captureErrors:!v(c.errors),keepQuery:c.keepQuery!==void 0&&!v(c.keepQuery),respectDoNotTrack:c.respectDnt!==void 0&&!v(c.respectDnt)});window.mybase={...window.mybase,analytics:e,track:e.track,pageview:e.pageview,captureError:e.captureError}}})();
|
package/dist/cache.d.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { MybaseApiError } from "./cron";
|
|
2
|
+
export { MybaseApiError };
|
|
3
|
+
export type CacheArg = string | number;
|
|
4
|
+
/** One reply per command: Valkey errors don't fail the whole pipeline. */
|
|
5
|
+
export type PipelineResult = {
|
|
6
|
+
result: unknown;
|
|
7
|
+
} | {
|
|
8
|
+
error: string;
|
|
9
|
+
};
|
|
10
|
+
export interface CacheOptions {
|
|
11
|
+
/** Where the mybase API is reachable from your app, e.g. `https://mybase.example.com`. */
|
|
12
|
+
apiUrl: string;
|
|
13
|
+
project: string;
|
|
14
|
+
/** A mybase API key (`mb_…`) with `cache:write` for this project. Server-side only. */
|
|
15
|
+
apiKey: string;
|
|
16
|
+
/** Default `globalThis.fetch`. */
|
|
17
|
+
fetch?: typeof fetch;
|
|
18
|
+
}
|
|
19
|
+
export interface SetOptions {
|
|
20
|
+
/** Expire after this many seconds. */
|
|
21
|
+
ex?: number;
|
|
22
|
+
/** Expire after this many milliseconds. */
|
|
23
|
+
px?: number;
|
|
24
|
+
/** Only set if the key doesn't exist. */
|
|
25
|
+
nx?: boolean;
|
|
26
|
+
/** Only set if the key exists. */
|
|
27
|
+
xx?: boolean;
|
|
28
|
+
}
|
|
29
|
+
/** Builds a batch of commands sent in one request. Keys get the project prefix. */
|
|
30
|
+
export interface CachePipeline {
|
|
31
|
+
command(args: CacheArg[]): CachePipeline;
|
|
32
|
+
get(key: string): CachePipeline;
|
|
33
|
+
set(key: string, value: CacheArg, options?: SetOptions): CachePipeline;
|
|
34
|
+
del(...keys: string[]): CachePipeline;
|
|
35
|
+
incr(key: string, by?: number): CachePipeline;
|
|
36
|
+
expire(key: string, seconds: number): CachePipeline;
|
|
37
|
+
/** Sends the batch. `atomic: true` wraps it in MULTI/EXEC. */
|
|
38
|
+
exec(options?: {
|
|
39
|
+
atomic?: boolean;
|
|
40
|
+
}): Promise<PipelineResult[]>;
|
|
41
|
+
}
|
|
42
|
+
export interface Cache {
|
|
43
|
+
/** `key` with the project prefix, for raw commands. */
|
|
44
|
+
key(key: string): string;
|
|
45
|
+
/** A raw command, e.g. `["ZADD", cache.key("board"), 10, "ana"]`. Keys aren't prefixed. */
|
|
46
|
+
command<T = unknown>(args: CacheArg[]): Promise<T>;
|
|
47
|
+
get(key: string): Promise<string | null>;
|
|
48
|
+
/** JSON.parse of `get`, or null. */
|
|
49
|
+
getJson<T = unknown>(key: string): Promise<T | null>;
|
|
50
|
+
/** `true` when set, `false` when `nx`/`xx` stopped it. */
|
|
51
|
+
set(key: string, value: CacheArg, options?: SetOptions): Promise<boolean>;
|
|
52
|
+
setJson(key: string, value: unknown, options?: SetOptions): Promise<boolean>;
|
|
53
|
+
/** How many keys were deleted. */
|
|
54
|
+
del(...keys: string[]): Promise<number>;
|
|
55
|
+
exists(...keys: string[]): Promise<number>;
|
|
56
|
+
incr(key: string, by?: number): Promise<number>;
|
|
57
|
+
expire(key: string, seconds: number): Promise<boolean>;
|
|
58
|
+
/** Seconds left; -1 with no expiry, -2 when the key doesn't exist. */
|
|
59
|
+
ttl(key: string): Promise<number>;
|
|
60
|
+
mget(...keys: string[]): Promise<(string | null)[]>;
|
|
61
|
+
hget(key: string, field: string): Promise<string | null>;
|
|
62
|
+
hset(key: string, fields: Record<string, CacheArg>): Promise<number>;
|
|
63
|
+
hgetall(key: string): Promise<Record<string, string>>;
|
|
64
|
+
hdel(key: string, ...fields: string[]): Promise<number>;
|
|
65
|
+
lpush(key: string, ...values: CacheArg[]): Promise<number>;
|
|
66
|
+
rpush(key: string, ...values: CacheArg[]): Promise<number>;
|
|
67
|
+
lrange(key: string, start: number, stop: number): Promise<string[]>;
|
|
68
|
+
sadd(key: string, ...members: CacheArg[]): Promise<number>;
|
|
69
|
+
srem(key: string, ...members: CacheArg[]): Promise<number>;
|
|
70
|
+
smembers(key: string): Promise<string[]>;
|
|
71
|
+
sismember(key: string, member: CacheArg): Promise<boolean>;
|
|
72
|
+
zadd(key: string, ...entries: {
|
|
73
|
+
score: number;
|
|
74
|
+
member: string;
|
|
75
|
+
}[]): Promise<number>;
|
|
76
|
+
/** Members by rank, lowest score first. `withScores` adds the scores. */
|
|
77
|
+
zrange(key: string, start: number, stop: number, options?: {
|
|
78
|
+
withScores?: boolean;
|
|
79
|
+
rev?: boolean;
|
|
80
|
+
}): Promise<string[]>;
|
|
81
|
+
zincrby(key: string, by: number, member: string): Promise<number>;
|
|
82
|
+
publish(channel: string, message: string): Promise<number>;
|
|
83
|
+
pipeline(): CachePipeline;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* A cache client over HTTP. Each call is one request; batch with `pipeline()`.
|
|
87
|
+
*
|
|
88
|
+
* const cache = createCache({ apiUrl, project: "shop", apiKey: process.env.MYBASE_API_KEY! });
|
|
89
|
+
* await cache.set("session:abc", userId, { ex: 3600 }); // key is shop:session:abc
|
|
90
|
+
*/
|
|
91
|
+
export declare function createCache(options: CacheOptions): Cache;
|