@desert-ant-labs/desert-ant-web 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/README.md +34 -0
- package/dist/core.d.ts +66 -0
- package/dist/core.js +112 -0
- package/dist/identity.d.ts +4 -0
- package/dist/identity.js +54 -0
- package/dist/index.browser.d.ts +16 -0
- package/dist/index.browser.js +57 -0
- package/dist/index.node.d.ts +12 -0
- package/dist/index.node.js +63 -0
- package/dist/store-node.d.ts +7 -0
- package/dist/store-node.js +49 -0
- package/dist/transport.d.ts +2 -0
- package/dist/transport.js +42 -0
- package/package.json +67 -0
package/README.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# @desert-ant-labs/desert-ant-web
|
|
2
|
+
|
|
3
|
+
Internal shared web/JS code for Desert Ant's on-device SDKs (`shapes`, `emo`, …).
|
|
4
|
+
Currently it provides usage reporting: an active-device `load` event (the MAD
|
|
5
|
+
turnstile) sent to our ingestion API.
|
|
6
|
+
|
|
7
|
+
> **Not for direct use.** This package is published only so our SDKs can depend
|
|
8
|
+
> on it. It has no stable public API and may change without notice — integrate a
|
|
9
|
+
> Desert Ant SDK, not this.
|
|
10
|
+
|
|
11
|
+
## API
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { initUsage } from "@desert-ant-labs/desert-ant-web";
|
|
15
|
+
|
|
16
|
+
const usage = initUsage(); // keyless: the browser Origin identifies the site
|
|
17
|
+
usage.recordCall(); // once per inference; rides the session's load event
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
- One `load` per device per session (30-min idle timeout); the server dedups by
|
|
21
|
+
device per month and sums `callCount` across events.
|
|
22
|
+
- Zero dependencies, best-effort, never throws into the host.
|
|
23
|
+
- `initUsage({ key?, endpoint?, callCount?, context?, disabled? })` — `endpoint`
|
|
24
|
+
overrides the default (e.g. staging). `@desert-ant-labs/desert-ant-web/core`
|
|
25
|
+
exposes the transport/storage-free `createClient`.
|
|
26
|
+
|
|
27
|
+
## Develop
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm run build
|
|
31
|
+
npm test # offline unit tests
|
|
32
|
+
DAL_LIVE=1 npm test # also hits staging
|
|
33
|
+
npm run check:pkg # build + publint + attw
|
|
34
|
+
```
|
package/dist/core.d.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export declare const SDK_NAME = "desert-ant-web";
|
|
2
|
+
export declare const SDK_VERSION = "0.1.0";
|
|
3
|
+
export declare const DAY_MS: number;
|
|
4
|
+
export declare const WEB_SESSION_MS: number;
|
|
5
|
+
export type LoadContext = Record<string, unknown>;
|
|
6
|
+
export interface IngestEvent {
|
|
7
|
+
name: "load";
|
|
8
|
+
deviceId: string;
|
|
9
|
+
callCount?: number;
|
|
10
|
+
timestamp?: string;
|
|
11
|
+
context?: LoadContext;
|
|
12
|
+
}
|
|
13
|
+
export interface IngestBody {
|
|
14
|
+
platform: "web";
|
|
15
|
+
key?: string;
|
|
16
|
+
sdk: {
|
|
17
|
+
name: string;
|
|
18
|
+
version: string;
|
|
19
|
+
};
|
|
20
|
+
sentAt: string;
|
|
21
|
+
events: IngestEvent[];
|
|
22
|
+
}
|
|
23
|
+
/** Persisted per origin/instance, across sessions. */
|
|
24
|
+
export interface UsageState {
|
|
25
|
+
/** Epoch ms we last emitted or went inactive (0 = never). Gates the next emit. */
|
|
26
|
+
lastActiveAt: number;
|
|
27
|
+
/** Calls accrued during throttled sessions, awaiting the next emitted load. */
|
|
28
|
+
carryCallCount: number;
|
|
29
|
+
}
|
|
30
|
+
export interface SendOptions {
|
|
31
|
+
/** Use the unload-safe transport (sendBeacon) where available. */
|
|
32
|
+
beacon?: boolean;
|
|
33
|
+
}
|
|
34
|
+
export interface ClientDeps {
|
|
35
|
+
deviceId: string;
|
|
36
|
+
key?: string;
|
|
37
|
+
/** Authoritative call count read at emit time; overrides recordCall() when set. */
|
|
38
|
+
callCount?: () => number;
|
|
39
|
+
/** Default context attached to auto-emitted loads. */
|
|
40
|
+
context?: () => LoadContext | undefined;
|
|
41
|
+
/** Re-emit window (ms): DAY_MS off-browser, WEB_SESSION_MS on web. */
|
|
42
|
+
windowMs: number;
|
|
43
|
+
now?: () => number;
|
|
44
|
+
loadState: () => UsageState;
|
|
45
|
+
saveState: (s: UsageState) => void;
|
|
46
|
+
send: (body: IngestBody, opts: SendOptions) => void;
|
|
47
|
+
}
|
|
48
|
+
export interface UsageClient {
|
|
49
|
+
/** Host calls this once per inference/call to attribute to the turnstile. */
|
|
50
|
+
recordCall(n?: number): void;
|
|
51
|
+
/** Force a turnstile now, ignoring the window. */
|
|
52
|
+
load(opts?: {
|
|
53
|
+
context?: LoadContext;
|
|
54
|
+
}): void;
|
|
55
|
+
/** Flush any pending event. `beacon: true` uses the unload-safe path. */
|
|
56
|
+
flush(opts?: SendOptions): void;
|
|
57
|
+
/** Evaluate the window and, if a new session/day is due, queue a turnstile.
|
|
58
|
+
* Call on init and again on reactivation (e.g. tab becoming visible). */
|
|
59
|
+
start(): void;
|
|
60
|
+
/** Mark the app inactive (stamp the idle clock) and flush. Call on page hide. */
|
|
61
|
+
suspend(): void;
|
|
62
|
+
}
|
|
63
|
+
/** RFC 4122 v4, via crypto.randomUUID where available. */
|
|
64
|
+
export declare function uuid(): string;
|
|
65
|
+
export declare function buildBody(deviceId: string, key: string | undefined, events: IngestEvent[]): IngestBody;
|
|
66
|
+
export declare function createClient(deps: ClientDeps): UsageClient;
|
package/dist/core.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Wire format + client state machine for the usage turnstile.
|
|
2
|
+
//
|
|
3
|
+
// Transport- and storage-free by design: the platform entries (browser / node)
|
|
4
|
+
// inject a stable `deviceId`, persisted-state access, and a `send` transport.
|
|
5
|
+
// Exposed at "@desert-ant-labs/desert-ant-web/core" for hosts that already own
|
|
6
|
+
// identity and transport and just want the semantics.
|
|
7
|
+
//
|
|
8
|
+
// The one billed signal is a `load` event — the MAD turnstile. The server dedups
|
|
9
|
+
// by device (COUNT DISTINCT deviceId per company per month) and SUMS callCount
|
|
10
|
+
// across events, so emitting an extra `load` never over-bills and a session's
|
|
11
|
+
// calls can be split across several events and still add up.
|
|
12
|
+
export const SDK_NAME = "desert-ant-web";
|
|
13
|
+
export const SDK_VERSION = "0.1.0"; // keep in sync with package.json
|
|
14
|
+
// Re-emit windows. A node/mobile install is persistent, so a device re-emits at
|
|
15
|
+
// most once a DAY. Web is session-shaped and its identity is ephemeral, so it
|
|
16
|
+
// re-emits per SESSION — a new session being the first activity after this much
|
|
17
|
+
// inactivity (the ~30-min idle timeout every web-analytics tool uses). Neither
|
|
18
|
+
// affects billing: MAD is COUNT(DISTINCT deviceId) per month regardless of how
|
|
19
|
+
// often a device re-emits within it.
|
|
20
|
+
export const DAY_MS = 24 * 60 * 60 * 1000;
|
|
21
|
+
export const WEB_SESSION_MS = 30 * 60 * 1000;
|
|
22
|
+
const positive = (n) => (n > 0 ? n : undefined);
|
|
23
|
+
/** RFC 4122 v4, via crypto.randomUUID where available. */
|
|
24
|
+
export function uuid() {
|
|
25
|
+
const c = globalThis.crypto;
|
|
26
|
+
if (c?.randomUUID)
|
|
27
|
+
return c.randomUUID();
|
|
28
|
+
const b = new Uint8Array(16);
|
|
29
|
+
c.getRandomValues(b);
|
|
30
|
+
b[6] = (b[6] & 0x0f) | 0x40;
|
|
31
|
+
b[8] = (b[8] & 0x3f) | 0x80;
|
|
32
|
+
const h = Array.from(b, (x) => x.toString(16).padStart(2, "0"));
|
|
33
|
+
return `${h[0]}${h[1]}${h[2]}${h[3]}-${h[4]}${h[5]}-${h[6]}${h[7]}-${h[8]}${h[9]}-${h[10]}${h[11]}${h[12]}${h[13]}${h[14]}${h[15]}`;
|
|
34
|
+
}
|
|
35
|
+
export function buildBody(deviceId, key, events) {
|
|
36
|
+
const body = {
|
|
37
|
+
platform: "web",
|
|
38
|
+
sdk: { name: SDK_NAME, version: SDK_VERSION },
|
|
39
|
+
sentAt: new Date().toISOString(),
|
|
40
|
+
events,
|
|
41
|
+
};
|
|
42
|
+
// Key rides the body (never a header) so the text/plain + sendBeacon path stays
|
|
43
|
+
// a CORS "simple" request with no preflight.
|
|
44
|
+
if (key)
|
|
45
|
+
body.key = key;
|
|
46
|
+
return body;
|
|
47
|
+
}
|
|
48
|
+
export function createClient(deps) {
|
|
49
|
+
const now = deps.now ?? Date.now;
|
|
50
|
+
let sessionCalls = 0; // recordCall() accrued this session, not yet accounted
|
|
51
|
+
let pending = null; // queued turnstile, awaiting first flush
|
|
52
|
+
let emitted = false; // did we open a turnstile this session?
|
|
53
|
+
// Effective count for an emit: provider is authoritative when set, else the
|
|
54
|
+
// accumulated (carry + session) count.
|
|
55
|
+
const resolveCount = (accumulated) => positive(deps.callCount ? deps.callCount() : accumulated);
|
|
56
|
+
function queue(context) {
|
|
57
|
+
pending = { name: "load", deviceId: deps.deviceId, context: context ?? deps.context?.() };
|
|
58
|
+
emitted = true;
|
|
59
|
+
}
|
|
60
|
+
function start() {
|
|
61
|
+
const st = deps.loadState();
|
|
62
|
+
if (now() - st.lastActiveAt < deps.windowMs)
|
|
63
|
+
return; // still within the same session/day
|
|
64
|
+
// Reserve the slot up front so a second tab opening now won't double-emit.
|
|
65
|
+
deps.saveState({ ...st, lastActiveAt: now() });
|
|
66
|
+
queue();
|
|
67
|
+
}
|
|
68
|
+
function suspend() {
|
|
69
|
+
// Stamp the moment we go inactive so the next session is measured from here,
|
|
70
|
+
// then flush via the unload-safe path.
|
|
71
|
+
const st = deps.loadState();
|
|
72
|
+
deps.saveState({ ...st, lastActiveAt: now() });
|
|
73
|
+
flush({ beacon: true });
|
|
74
|
+
}
|
|
75
|
+
function recordCall(n = 1) {
|
|
76
|
+
if (n > 0)
|
|
77
|
+
sessionCalls += n;
|
|
78
|
+
}
|
|
79
|
+
function flush(opts = {}) {
|
|
80
|
+
const st = deps.loadState();
|
|
81
|
+
if (pending) {
|
|
82
|
+
// First flush of this session's turnstile: attach carry + session calls.
|
|
83
|
+
const ev = pending;
|
|
84
|
+
pending = null;
|
|
85
|
+
ev.callCount = resolveCount(st.carryCallCount + sessionCalls);
|
|
86
|
+
if (!deps.callCount)
|
|
87
|
+
deps.saveState({ ...st, carryCallCount: 0 });
|
|
88
|
+
sessionCalls = 0;
|
|
89
|
+
deps.send(buildBody(deps.deviceId, deps.key, [ev]), opts);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (emitted && sessionCalls > 0) {
|
|
93
|
+
// Turnstile already sent; late calls ride a delta load (server sums them).
|
|
94
|
+
const ev = { name: "load", deviceId: deps.deviceId, callCount: resolveCount(sessionCalls), context: deps.context?.() };
|
|
95
|
+
sessionCalls = 0;
|
|
96
|
+
deps.send(buildBody(deps.deviceId, deps.key, [ev]), opts);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (!emitted && sessionCalls > 0 && !deps.callCount) {
|
|
100
|
+
// Throttled session: no turnstile today. Carry the calls to the next emit.
|
|
101
|
+
deps.saveState({ ...st, carryCallCount: st.carryCallCount + sessionCalls });
|
|
102
|
+
sessionCalls = 0;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function load(opts = {}) {
|
|
106
|
+
const st = deps.loadState();
|
|
107
|
+
deps.saveState({ ...st, lastActiveAt: now() });
|
|
108
|
+
queue(opts.context);
|
|
109
|
+
flush();
|
|
110
|
+
}
|
|
111
|
+
return { recordCall, load, flush, start, suspend };
|
|
112
|
+
}
|
package/dist/identity.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Browser device identity + throttle state, persisted in localStorage.
|
|
2
|
+
//
|
|
3
|
+
// The deviceId is a random per-origin UUID — opaque, no PII, never a user id.
|
|
4
|
+
// The SDK always owns it; hosts can't supply or override it. Churn is inherent
|
|
5
|
+
// to the web (storage clears, Safari ITP eviction, private mode) and simply
|
|
6
|
+
// inflates device counts slightly; we accept that rather than fingerprint.
|
|
7
|
+
import { uuid } from "./core.js";
|
|
8
|
+
const KEY = "desert-ant-web/usage/v1";
|
|
9
|
+
// Used when localStorage is unavailable (private mode, sandboxed iframe, SSR).
|
|
10
|
+
// Per-session only — a fresh id each load, which is the honest fallback.
|
|
11
|
+
let memory = null;
|
|
12
|
+
function fresh() {
|
|
13
|
+
return { deviceId: uuid(), lastActiveAt: 0, carryCallCount: 0 };
|
|
14
|
+
}
|
|
15
|
+
function normalize(s) {
|
|
16
|
+
if (!s || typeof s.deviceId !== "string" || !s.deviceId)
|
|
17
|
+
return null;
|
|
18
|
+
return {
|
|
19
|
+
deviceId: s.deviceId,
|
|
20
|
+
lastActiveAt: Number(s.lastActiveAt) || 0,
|
|
21
|
+
carryCallCount: Number(s.carryCallCount) || 0,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function read() {
|
|
25
|
+
try {
|
|
26
|
+
const existing = normalize(JSON.parse(localStorage.getItem(KEY) ?? "null"));
|
|
27
|
+
if (existing)
|
|
28
|
+
return existing;
|
|
29
|
+
const created = fresh();
|
|
30
|
+
localStorage.setItem(KEY, JSON.stringify(created));
|
|
31
|
+
return created;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return (memory ??= fresh());
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function write(s) {
|
|
38
|
+
try {
|
|
39
|
+
localStorage.setItem(KEY, JSON.stringify(s));
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
memory = s;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export function getDeviceId() {
|
|
46
|
+
return read().deviceId;
|
|
47
|
+
}
|
|
48
|
+
export function loadState() {
|
|
49
|
+
const { lastActiveAt, carryCallCount } = read();
|
|
50
|
+
return { lastActiveAt, carryCallCount };
|
|
51
|
+
}
|
|
52
|
+
export function saveState(state) {
|
|
53
|
+
write({ ...read(), ...state });
|
|
54
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type LoadContext, type UsageClient } from "./core.js";
|
|
2
|
+
export { DAY_MS, WEB_SESSION_MS, createClient, uuid } from "./core.js";
|
|
3
|
+
export type { UsageClient, ClientDeps, IngestBody, IngestEvent, UsageState, LoadContext } from "./core.js";
|
|
4
|
+
export interface UsageConfig {
|
|
5
|
+
/** Publishable key. Optional: keyless requests are identified by the Origin. */
|
|
6
|
+
key?: string;
|
|
7
|
+
/** Override the ingestion endpoint (e.g. staging). */
|
|
8
|
+
endpoint?: string;
|
|
9
|
+
/** Authoritative call count, read at emit time. Alternative to recordCall(). */
|
|
10
|
+
callCount?: () => number;
|
|
11
|
+
/** Extra context merged onto each load (appVersion, etc.). */
|
|
12
|
+
context?: LoadContext | (() => LoadContext | undefined);
|
|
13
|
+
/** Disable entirely (returns an inert client). */
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
}
|
|
16
|
+
export declare function initUsage(config?: UsageConfig): UsageClient;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Browser entry. Batteries-included: wires the localStorage identity, the
|
|
2
|
+
// beacon/fetch transport, and the page lifecycle into the core client.
|
|
3
|
+
import { createClient, WEB_SESSION_MS } from "./core.js";
|
|
4
|
+
import { getDeviceId, loadState, saveState } from "./identity.js";
|
|
5
|
+
import { makeSend } from "./transport.js";
|
|
6
|
+
export { DAY_MS, WEB_SESSION_MS, createClient, uuid } from "./core.js";
|
|
7
|
+
// Production ingestion endpoint. Point at staging via `initUsage({ endpoint })`.
|
|
8
|
+
const DEFAULT_ENDPOINT = "https://platform.desertant.ai/api/v1/ingest";
|
|
9
|
+
// Lock in the turnstile shortly after load — capturing calls made early in the
|
|
10
|
+
// session — without waiting for pagehide, which a flaky unload can drop.
|
|
11
|
+
const SETTLE_MS = 3000;
|
|
12
|
+
function inert() {
|
|
13
|
+
return { recordCall() { }, load() { }, flush() { }, start() { }, suspend() { } };
|
|
14
|
+
}
|
|
15
|
+
export function initUsage(config = {}) {
|
|
16
|
+
// No-op under SSR or when disabled — never touch DOM/storage that isn't there.
|
|
17
|
+
if (config.disabled || typeof window === "undefined")
|
|
18
|
+
return inert();
|
|
19
|
+
const endpoint = config.endpoint ?? DEFAULT_ENDPOINT;
|
|
20
|
+
const client = createClient({
|
|
21
|
+
deviceId: getDeviceId(),
|
|
22
|
+
key: config.key,
|
|
23
|
+
callCount: config.callCount,
|
|
24
|
+
context: contextProvider(config.context),
|
|
25
|
+
windowMs: WEB_SESSION_MS,
|
|
26
|
+
loadState,
|
|
27
|
+
saveState,
|
|
28
|
+
send: makeSend(endpoint),
|
|
29
|
+
});
|
|
30
|
+
client.start();
|
|
31
|
+
// Tab hidden → the session may be ending: stamp the idle clock and flush.
|
|
32
|
+
// Tab visible again → re-evaluate; a return after the idle window is a new
|
|
33
|
+
// session and emits a fresh turnstile.
|
|
34
|
+
window.addEventListener("visibilitychange", () => {
|
|
35
|
+
if (document.visibilityState === "hidden")
|
|
36
|
+
client.suspend();
|
|
37
|
+
else
|
|
38
|
+
client.start();
|
|
39
|
+
});
|
|
40
|
+
window.addEventListener("pagehide", () => client.suspend());
|
|
41
|
+
setTimeout(() => client.flush(), SETTLE_MS);
|
|
42
|
+
return client;
|
|
43
|
+
}
|
|
44
|
+
function contextProvider(c) {
|
|
45
|
+
const base = {};
|
|
46
|
+
if (typeof navigator !== "undefined" && navigator.language)
|
|
47
|
+
base.locale = navigator.language;
|
|
48
|
+
if (typeof c === "function")
|
|
49
|
+
return () => merge(base, c());
|
|
50
|
+
if (c)
|
|
51
|
+
return () => merge(base, c);
|
|
52
|
+
return () => (Object.keys(base).length ? { ...base } : undefined);
|
|
53
|
+
}
|
|
54
|
+
function merge(base, extra) {
|
|
55
|
+
const out = { ...base, ...(extra ?? {}) };
|
|
56
|
+
return Object.keys(out).length ? out : undefined;
|
|
57
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type LoadContext, type UsageClient } from "./core.js";
|
|
2
|
+
export { DAY_MS, WEB_SESSION_MS, createClient, uuid } from "./core.js";
|
|
3
|
+
export type { UsageClient, ClientDeps, IngestBody, IngestEvent, UsageState, LoadContext } from "./core.js";
|
|
4
|
+
export interface UsageConfig {
|
|
5
|
+
/** Publishable key. Required off-browser: there's no Origin to identify by. */
|
|
6
|
+
key?: string;
|
|
7
|
+
endpoint?: string;
|
|
8
|
+
callCount?: () => number;
|
|
9
|
+
context?: LoadContext | (() => LoadContext | undefined);
|
|
10
|
+
disabled?: boolean;
|
|
11
|
+
}
|
|
12
|
+
export declare function initUsage(config?: UsageConfig): UsageClient;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Node / headless entry. No Origin off-browser, so a key is required for events
|
|
2
|
+
// to be attributable. No DOM lifecycle — best-effort flush on process exit.
|
|
3
|
+
import { createClient, DAY_MS } from "./core.js";
|
|
4
|
+
import { nodeStore } from "./store-node.js";
|
|
5
|
+
export { DAY_MS, WEB_SESSION_MS, createClient, uuid } from "./core.js";
|
|
6
|
+
// How often a long-running process re-checks the daily window. Servers don't
|
|
7
|
+
// relaunch daily the way a mobile app does, so without this a boot-once process
|
|
8
|
+
// would emit once and never again — missing subsequent days/months.
|
|
9
|
+
const RECHECK_MS = 60 * 60 * 1000;
|
|
10
|
+
const DEFAULT_ENDPOINT = "https://platform.desertant.ai/api/v1/ingest";
|
|
11
|
+
function inert() {
|
|
12
|
+
return { recordCall() { }, load() { }, flush() { }, start() { }, suspend() { } };
|
|
13
|
+
}
|
|
14
|
+
export function initUsage(config = {}) {
|
|
15
|
+
if (config.disabled)
|
|
16
|
+
return inert();
|
|
17
|
+
if (!config.key) {
|
|
18
|
+
// Never throw into the host — warn and carry on; the server drops unattributable events.
|
|
19
|
+
console.warn("[desert-ant-web] no key provided off-browser; events cannot be attributed. Pass { key }.");
|
|
20
|
+
}
|
|
21
|
+
const endpoint = config.endpoint ?? DEFAULT_ENDPOINT;
|
|
22
|
+
const store = nodeStore();
|
|
23
|
+
const client = createClient({
|
|
24
|
+
deviceId: store.deviceId(),
|
|
25
|
+
key: config.key,
|
|
26
|
+
callCount: config.callCount,
|
|
27
|
+
context: contextProvider(config.context),
|
|
28
|
+
windowMs: DAY_MS,
|
|
29
|
+
loadState: store.loadState,
|
|
30
|
+
saveState: store.saveState,
|
|
31
|
+
send: (body) => {
|
|
32
|
+
void post(endpoint, JSON.stringify(body));
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
client.start();
|
|
36
|
+
// Daily re-check for long-lived processes; unref'd so it never keeps the
|
|
37
|
+
// process alive on its own.
|
|
38
|
+
const timer = setInterval(() => client.start(), RECHECK_MS);
|
|
39
|
+
if (typeof timer.unref === "function")
|
|
40
|
+
timer.unref();
|
|
41
|
+
if (typeof process !== "undefined" && typeof process.once === "function") {
|
|
42
|
+
process.once("beforeExit", () => client.flush());
|
|
43
|
+
}
|
|
44
|
+
return client;
|
|
45
|
+
}
|
|
46
|
+
async function post(endpoint, json) {
|
|
47
|
+
try {
|
|
48
|
+
await fetch(endpoint, { method: "POST", body: json, headers: { "Content-Type": "application/json" } });
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// best-effort
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function contextProvider(c) {
|
|
55
|
+
if (typeof c === "function")
|
|
56
|
+
return () => normalize(c());
|
|
57
|
+
if (c)
|
|
58
|
+
return () => normalize(c);
|
|
59
|
+
return () => undefined;
|
|
60
|
+
}
|
|
61
|
+
function normalize(ctx) {
|
|
62
|
+
return ctx && Object.keys(ctx).length ? { ...ctx } : undefined;
|
|
63
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Node device identity + throttle state, persisted to a per-user cache file so a
|
|
2
|
+
// server instance keeps a stable device id across restarts. Node-only (uses
|
|
3
|
+
// node:fs); on any failure it degrades to an in-memory, process-lifetime id.
|
|
4
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { uuid } from "./core.js";
|
|
8
|
+
const FILE = join(homedir(), ".cache", "desert-ant-web", "usage.json");
|
|
9
|
+
export function nodeStore() {
|
|
10
|
+
let cache = null;
|
|
11
|
+
function read() {
|
|
12
|
+
if (cache)
|
|
13
|
+
return cache;
|
|
14
|
+
try {
|
|
15
|
+
const parsed = JSON.parse(readFileSync(FILE, "utf8"));
|
|
16
|
+
if (parsed && typeof parsed.deviceId === "string" && parsed.deviceId) {
|
|
17
|
+
return (cache = {
|
|
18
|
+
deviceId: parsed.deviceId,
|
|
19
|
+
lastActiveAt: Number(parsed.lastActiveAt) || 0,
|
|
20
|
+
carryCallCount: Number(parsed.carryCallCount) || 0,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// missing / corrupt / unreadable — regenerate below
|
|
26
|
+
}
|
|
27
|
+
const created = { deviceId: uuid(), lastActiveAt: 0, carryCallCount: 0 };
|
|
28
|
+
write(created);
|
|
29
|
+
return created;
|
|
30
|
+
}
|
|
31
|
+
function write(s) {
|
|
32
|
+
cache = s;
|
|
33
|
+
try {
|
|
34
|
+
mkdirSync(dirname(FILE), { recursive: true });
|
|
35
|
+
writeFileSync(FILE, JSON.stringify(s));
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// read-only fs — keep the id in memory for this process
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
deviceId: () => read().deviceId,
|
|
43
|
+
loadState: () => {
|
|
44
|
+
const { lastActiveAt, carryCallCount } = read();
|
|
45
|
+
return { lastActiveAt, carryCallCount };
|
|
46
|
+
},
|
|
47
|
+
saveState: (state) => write({ ...read(), ...state }),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Browser transport. Two paths, both preflight-free:
|
|
2
|
+
// - normal flush: fetch(keepalive), text/plain body (server parses it as JSON)
|
|
3
|
+
// - unload flush: navigator.sendBeacon (text/plain Blob)
|
|
4
|
+
// The key travels in the body (see buildBody), so we never set an Authorization
|
|
5
|
+
// header and every request stays a CORS "simple" request — no OPTIONS round-trip.
|
|
6
|
+
const CONTENT_TYPE = "text/plain;charset=UTF-8";
|
|
7
|
+
export function makeSend(endpoint) {
|
|
8
|
+
return (body, opts) => {
|
|
9
|
+
const json = JSON.stringify(body);
|
|
10
|
+
if (opts.beacon && typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function") {
|
|
11
|
+
try {
|
|
12
|
+
if (navigator.sendBeacon(endpoint, new Blob([json], { type: CONTENT_TYPE })))
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
// queued send failed (e.g. payload too large) — fall through to fetch
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
void post(endpoint, json);
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
async function post(endpoint, json, tries = 2) {
|
|
23
|
+
for (let attempt = 0; attempt < tries; attempt++) {
|
|
24
|
+
try {
|
|
25
|
+
const res = await fetch(endpoint, {
|
|
26
|
+
method: "POST",
|
|
27
|
+
body: json,
|
|
28
|
+
keepalive: true,
|
|
29
|
+
mode: "cors",
|
|
30
|
+
credentials: "omit",
|
|
31
|
+
headers: { "Content-Type": CONTENT_TYPE },
|
|
32
|
+
});
|
|
33
|
+
if (res.ok)
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// network error — retry
|
|
38
|
+
}
|
|
39
|
+
await new Promise((r) => setTimeout(r, 200 * 2 ** attempt));
|
|
40
|
+
}
|
|
41
|
+
// Best-effort: a dropped turnstile is recovered by the next active session.
|
|
42
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@desert-ant-labs/desert-ant-web",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Internal shared browser/JS code for Desert Ant on-device SDKs (currently: usage reporting — active-device 'load' events to the ingestion API).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.node.js",
|
|
7
|
+
"types": "./dist/index.node.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"browser": {
|
|
11
|
+
"types": "./dist/index.browser.d.ts",
|
|
12
|
+
"default": "./dist/index.browser.js"
|
|
13
|
+
},
|
|
14
|
+
"node": {
|
|
15
|
+
"types": "./dist/index.node.d.ts",
|
|
16
|
+
"default": "./dist/index.node.js"
|
|
17
|
+
},
|
|
18
|
+
"default": {
|
|
19
|
+
"types": "./dist/index.node.d.ts",
|
|
20
|
+
"default": "./dist/index.node.js"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"./core": {
|
|
24
|
+
"types": "./dist/core.d.ts",
|
|
25
|
+
"default": "./dist/core.js"
|
|
26
|
+
},
|
|
27
|
+
"./package.json": "./package.json"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"dist"
|
|
31
|
+
],
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "tsc",
|
|
34
|
+
"test": "node --import tsx --test test/*.test.ts",
|
|
35
|
+
"lint:publint": "publint --strict",
|
|
36
|
+
"lint:attw": "attw --pack . --profile esm-only",
|
|
37
|
+
"check:pkg": "npm run build && npm run lint:publint && npm run lint:attw",
|
|
38
|
+
"prepare": "npm run build"
|
|
39
|
+
},
|
|
40
|
+
"keywords": [
|
|
41
|
+
"usage",
|
|
42
|
+
"telemetry",
|
|
43
|
+
"active-devices",
|
|
44
|
+
"mad",
|
|
45
|
+
"desert-ant"
|
|
46
|
+
],
|
|
47
|
+
"license": "UNLICENSED",
|
|
48
|
+
"repository": {
|
|
49
|
+
"type": "git",
|
|
50
|
+
"url": "git+https://github.com/Desert-Ant-Labs/desert-ant-web.git"
|
|
51
|
+
},
|
|
52
|
+
"homepage": "https://github.com/Desert-Ant-Labs/desert-ant-web",
|
|
53
|
+
"engines": {
|
|
54
|
+
"node": ">=18"
|
|
55
|
+
},
|
|
56
|
+
"sideEffects": false,
|
|
57
|
+
"publishConfig": {
|
|
58
|
+
"access": "public"
|
|
59
|
+
},
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"@arethetypeswrong/cli": "^0.18",
|
|
62
|
+
"@types/node": "^22",
|
|
63
|
+
"publint": "^0.3",
|
|
64
|
+
"tsx": "^4",
|
|
65
|
+
"typescript": "^5"
|
|
66
|
+
}
|
|
67
|
+
}
|