@agentuity/frontend 0.0.110 → 0.0.112
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/dist/analytics/beacon.d.ts +15 -0
- package/dist/analytics/beacon.d.ts.map +1 -0
- package/dist/analytics/beacon.js +177 -0
- package/dist/analytics/beacon.js.map +1 -0
- package/dist/analytics/collectors/clicks.d.ts +10 -0
- package/dist/analytics/collectors/clicks.d.ts.map +1 -0
- package/dist/analytics/collectors/clicks.js +84 -0
- package/dist/analytics/collectors/clicks.js.map +1 -0
- package/dist/analytics/collectors/errors.d.ts +5 -0
- package/dist/analytics/collectors/errors.d.ts.map +1 -0
- package/dist/analytics/collectors/errors.js +43 -0
- package/dist/analytics/collectors/errors.js.map +1 -0
- package/dist/analytics/collectors/forms.d.ts +5 -0
- package/dist/analytics/collectors/forms.d.ts.map +1 -0
- package/dist/analytics/collectors/forms.js +55 -0
- package/dist/analytics/collectors/forms.js.map +1 -0
- package/dist/analytics/collectors/pageview.d.ts +15 -0
- package/dist/analytics/collectors/pageview.d.ts.map +1 -0
- package/dist/analytics/collectors/pageview.js +64 -0
- package/dist/analytics/collectors/pageview.js.map +1 -0
- package/dist/analytics/collectors/scroll.d.ts +17 -0
- package/dist/analytics/collectors/scroll.d.ts.map +1 -0
- package/dist/analytics/collectors/scroll.js +93 -0
- package/dist/analytics/collectors/scroll.js.map +1 -0
- package/dist/analytics/collectors/spa.d.ts +10 -0
- package/dist/analytics/collectors/spa.d.ts.map +1 -0
- package/dist/analytics/collectors/spa.js +53 -0
- package/dist/analytics/collectors/spa.js.map +1 -0
- package/dist/analytics/collectors/visibility.d.ts +18 -0
- package/dist/analytics/collectors/visibility.d.ts.map +1 -0
- package/dist/analytics/collectors/visibility.js +81 -0
- package/dist/analytics/collectors/visibility.js.map +1 -0
- package/dist/analytics/collectors/webvitals.d.ts +6 -0
- package/dist/analytics/collectors/webvitals.d.ts.map +1 -0
- package/dist/analytics/collectors/webvitals.js +111 -0
- package/dist/analytics/collectors/webvitals.js.map +1 -0
- package/dist/analytics/events.d.ts +18 -0
- package/dist/analytics/events.d.ts.map +1 -0
- package/dist/analytics/events.js +126 -0
- package/dist/analytics/events.js.map +1 -0
- package/dist/analytics/index.d.ts +12 -0
- package/dist/analytics/index.d.ts.map +1 -0
- package/dist/analytics/index.js +12 -0
- package/dist/analytics/index.js.map +1 -0
- package/dist/analytics/offline.d.ts +19 -0
- package/dist/analytics/offline.d.ts.map +1 -0
- package/dist/analytics/offline.js +145 -0
- package/dist/analytics/offline.js.map +1 -0
- package/dist/analytics/types.d.ts +113 -0
- package/dist/analytics/types.d.ts.map +1 -0
- package/dist/analytics/types.js +2 -0
- package/dist/analytics/types.js.map +1 -0
- package/dist/analytics/utils/storage.d.ts +13 -0
- package/dist/analytics/utils/storage.d.ts.map +1 -0
- package/dist/analytics/utils/storage.js +63 -0
- package/dist/analytics/utils/storage.js.map +1 -0
- package/dist/analytics/utils/utm.d.ts +12 -0
- package/dist/analytics/utils/utm.d.ts.map +1 -0
- package/dist/analytics/utils/utm.js +27 -0
- package/dist/analytics/utils/utm.js.map +1 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +93 -15
- package/dist/client/index.js.map +1 -1
- package/dist/client/types.d.ts +51 -5
- package/dist/client/types.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/analytics/beacon.ts +203 -0
- package/src/analytics/collectors/clicks.ts +100 -0
- package/src/analytics/collectors/errors.ts +49 -0
- package/src/analytics/collectors/forms.ts +64 -0
- package/src/analytics/collectors/pageview.ts +76 -0
- package/src/analytics/collectors/scroll.ts +112 -0
- package/src/analytics/collectors/spa.ts +60 -0
- package/src/analytics/collectors/visibility.ts +94 -0
- package/src/analytics/collectors/webvitals.ts +129 -0
- package/src/analytics/events.ts +144 -0
- package/src/analytics/index.ts +21 -0
- package/src/analytics/offline.ts +163 -0
- package/src/analytics/types.ts +139 -0
- package/src/analytics/utils/storage.ts +64 -0
- package/src/analytics/utils/utm.ts +36 -0
- package/src/client/index.ts +109 -15
- package/src/client/types.ts +104 -17
- package/src/index.ts +18 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import type { AnalyticsEvent } from './types';
|
|
2
|
+
|
|
3
|
+
const DB_NAME = 'agentuity_analytics';
|
|
4
|
+
const STORE_NAME = 'events';
|
|
5
|
+
const DB_VERSION = 1;
|
|
6
|
+
const MAX_QUEUE_SIZE = 1000;
|
|
7
|
+
|
|
8
|
+
let db: IDBDatabase | null = null;
|
|
9
|
+
let dbInitPromise: Promise<IDBDatabase | null> | null = null;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Initialize IndexedDB for offline event storage
|
|
13
|
+
*/
|
|
14
|
+
async function initDB(): Promise<IDBDatabase | null> {
|
|
15
|
+
if (typeof indexedDB === 'undefined') {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return new Promise((resolve) => {
|
|
20
|
+
try {
|
|
21
|
+
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
|
22
|
+
|
|
23
|
+
request.onerror = () => {
|
|
24
|
+
resolve(null);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
request.onsuccess = () => {
|
|
28
|
+
resolve(request.result);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
request.onupgradeneeded = (e) => {
|
|
32
|
+
const database = (e.target as IDBOpenDBRequest).result;
|
|
33
|
+
if (!database.objectStoreNames.contains(STORE_NAME)) {
|
|
34
|
+
database.createObjectStore(STORE_NAME, { keyPath: 'id' });
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
} catch {
|
|
38
|
+
resolve(null);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Get database instance
|
|
45
|
+
*/
|
|
46
|
+
async function getDB(): Promise<IDBDatabase | null> {
|
|
47
|
+
if (db) {
|
|
48
|
+
return db;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!dbInitPromise) {
|
|
52
|
+
dbInitPromise = initDB();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
db = await dbInitPromise;
|
|
56
|
+
return db;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Store event in IndexedDB for offline persistence
|
|
61
|
+
*/
|
|
62
|
+
export async function storeOfflineEvent(event: AnalyticsEvent): Promise<void> {
|
|
63
|
+
const database = await getDB();
|
|
64
|
+
if (!database) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const transaction = database.transaction(STORE_NAME, 'readwrite');
|
|
70
|
+
const store = transaction.objectStore(STORE_NAME);
|
|
71
|
+
|
|
72
|
+
// Check current count and evict old events if needed before adding
|
|
73
|
+
const count = await new Promise<number>((resolve) => {
|
|
74
|
+
const countRequest = store.count();
|
|
75
|
+
countRequest.onsuccess = () => resolve(countRequest.result);
|
|
76
|
+
countRequest.onerror = () => resolve(0);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
if (count >= MAX_QUEUE_SIZE) {
|
|
80
|
+
// Evict oldest event (FIFO) before adding new one
|
|
81
|
+
await new Promise<void>((resolve) => {
|
|
82
|
+
const cursorRequest = store.openCursor();
|
|
83
|
+
cursorRequest.onsuccess = () => {
|
|
84
|
+
const cursor = cursorRequest.result;
|
|
85
|
+
if (cursor) {
|
|
86
|
+
const deleteRequest = cursor.delete();
|
|
87
|
+
deleteRequest.onsuccess = () => resolve();
|
|
88
|
+
deleteRequest.onerror = () => resolve();
|
|
89
|
+
} else {
|
|
90
|
+
resolve();
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
cursorRequest.onerror = () => resolve();
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
store.add(event);
|
|
98
|
+
} catch {
|
|
99
|
+
// Silent failure
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Get all offline events and clear them
|
|
105
|
+
*/
|
|
106
|
+
export async function getAndClearOfflineEvents(): Promise<AnalyticsEvent[]> {
|
|
107
|
+
const database = await getDB();
|
|
108
|
+
if (!database) {
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return new Promise((resolve) => {
|
|
113
|
+
try {
|
|
114
|
+
const transaction = database.transaction(STORE_NAME, 'readwrite');
|
|
115
|
+
const store = transaction.objectStore(STORE_NAME);
|
|
116
|
+
|
|
117
|
+
const events: AnalyticsEvent[] = [];
|
|
118
|
+
const request = store.openCursor();
|
|
119
|
+
|
|
120
|
+
request.onsuccess = () => {
|
|
121
|
+
const cursor = request.result;
|
|
122
|
+
if (cursor) {
|
|
123
|
+
events.push(cursor.value as AnalyticsEvent);
|
|
124
|
+
cursor.delete();
|
|
125
|
+
cursor.continue();
|
|
126
|
+
} else {
|
|
127
|
+
resolve(events);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
request.onerror = () => {
|
|
132
|
+
resolve([]);
|
|
133
|
+
};
|
|
134
|
+
} catch {
|
|
135
|
+
resolve([]);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Check if we're online
|
|
142
|
+
*/
|
|
143
|
+
export function isOnline(): boolean {
|
|
144
|
+
if (typeof navigator === 'undefined') {
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
return navigator.onLine !== false;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Initialize offline support
|
|
152
|
+
* Listens for online event to flush queued events
|
|
153
|
+
*/
|
|
154
|
+
export function initOfflineSupport(flushCallback: () => void): void {
|
|
155
|
+
if (typeof window === 'undefined') {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
window.addEventListener('online', () => {
|
|
160
|
+
// Flush offline events when coming back online
|
|
161
|
+
flushCallback();
|
|
162
|
+
});
|
|
163
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Analytics event types
|
|
3
|
+
*/
|
|
4
|
+
export type AnalyticsEventType =
|
|
5
|
+
| 'pageview'
|
|
6
|
+
| 'click'
|
|
7
|
+
| 'scroll'
|
|
8
|
+
| 'visibility'
|
|
9
|
+
| 'error'
|
|
10
|
+
| 'custom'
|
|
11
|
+
| 'web_vital'
|
|
12
|
+
| 'form_submit'
|
|
13
|
+
| 'outbound_link';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Analytics event sent to the collection endpoint
|
|
17
|
+
*/
|
|
18
|
+
export interface AnalyticsEvent {
|
|
19
|
+
id: string;
|
|
20
|
+
timestamp: number;
|
|
21
|
+
timezone_offset: number;
|
|
22
|
+
|
|
23
|
+
event_type: AnalyticsEventType;
|
|
24
|
+
event_name?: string;
|
|
25
|
+
event_data?: Record<string, unknown>;
|
|
26
|
+
|
|
27
|
+
url: string;
|
|
28
|
+
path: string;
|
|
29
|
+
referrer: string;
|
|
30
|
+
title: string;
|
|
31
|
+
|
|
32
|
+
screen_width: number;
|
|
33
|
+
screen_height: number;
|
|
34
|
+
viewport_width: number;
|
|
35
|
+
viewport_height: number;
|
|
36
|
+
device_pixel_ratio: number;
|
|
37
|
+
user_agent: string;
|
|
38
|
+
language: string;
|
|
39
|
+
|
|
40
|
+
load_time?: number;
|
|
41
|
+
dom_ready?: number;
|
|
42
|
+
ttfb?: number;
|
|
43
|
+
fcp?: number;
|
|
44
|
+
lcp?: number;
|
|
45
|
+
cls?: number;
|
|
46
|
+
inp?: number;
|
|
47
|
+
|
|
48
|
+
scroll_depth?: number;
|
|
49
|
+
time_on_page?: number;
|
|
50
|
+
|
|
51
|
+
utm_source?: string;
|
|
52
|
+
utm_medium?: string;
|
|
53
|
+
utm_campaign?: string;
|
|
54
|
+
utm_term?: string;
|
|
55
|
+
utm_content?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Batch payload sent to /_agentuity/webanalytics/collect
|
|
60
|
+
*/
|
|
61
|
+
export interface AnalyticsBatchPayload {
|
|
62
|
+
org_id: string;
|
|
63
|
+
project_id: string;
|
|
64
|
+
session_id: string;
|
|
65
|
+
thread_id: string;
|
|
66
|
+
visitor_id: string;
|
|
67
|
+
is_devmode: boolean;
|
|
68
|
+
events: AnalyticsEvent[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Configuration injected by SDK runtime into window.__AGENTUITY_ANALYTICS__
|
|
73
|
+
*/
|
|
74
|
+
export interface AnalyticsPageConfig {
|
|
75
|
+
enabled: boolean;
|
|
76
|
+
orgId: string;
|
|
77
|
+
projectId: string;
|
|
78
|
+
sessionId: string;
|
|
79
|
+
threadId: string;
|
|
80
|
+
isDevmode: boolean;
|
|
81
|
+
|
|
82
|
+
trackClicks?: boolean;
|
|
83
|
+
trackScroll?: boolean;
|
|
84
|
+
trackOutboundLinks?: boolean;
|
|
85
|
+
trackForms?: boolean;
|
|
86
|
+
trackWebVitals?: boolean;
|
|
87
|
+
trackErrors?: boolean;
|
|
88
|
+
trackSPANavigation?: boolean;
|
|
89
|
+
requireConsent?: boolean;
|
|
90
|
+
sampleRate?: number;
|
|
91
|
+
excludePatterns?: string[];
|
|
92
|
+
globalProperties?: Record<string, unknown>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Public analytics client interface
|
|
97
|
+
*/
|
|
98
|
+
export interface AnalyticsClient {
|
|
99
|
+
/**
|
|
100
|
+
* Track a custom event
|
|
101
|
+
*/
|
|
102
|
+
track(eventName: string, properties?: Record<string, unknown>): void;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Identify the current user (sets visitor properties)
|
|
106
|
+
*/
|
|
107
|
+
identify(userId: string, traits?: Record<string, unknown>): void;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Manually track a page view
|
|
111
|
+
*/
|
|
112
|
+
pageview(path?: string): void;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Flush pending events immediately
|
|
116
|
+
*/
|
|
117
|
+
flush(): Promise<void>;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Opt out of analytics
|
|
121
|
+
*/
|
|
122
|
+
optOut(): void;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Opt back in to analytics
|
|
126
|
+
*/
|
|
127
|
+
optIn(): void;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Check if analytics is currently enabled
|
|
131
|
+
*/
|
|
132
|
+
isEnabled(): boolean;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
declare global {
|
|
136
|
+
interface Window {
|
|
137
|
+
__AGENTUITY_ANALYTICS__?: AnalyticsPageConfig;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
const VISITOR_ID_KEY = 'agentuity_visitor_id';
|
|
2
|
+
const OPT_OUT_KEY = 'agentuity_analytics_optout';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Generate a random UUID v4
|
|
6
|
+
*/
|
|
7
|
+
function generateUUID(): string {
|
|
8
|
+
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
|
9
|
+
return crypto.randomUUID();
|
|
10
|
+
}
|
|
11
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
12
|
+
const r = (Math.random() * 16) | 0;
|
|
13
|
+
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
|
14
|
+
return v.toString(16);
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Get or create the visitor ID from localStorage
|
|
20
|
+
*/
|
|
21
|
+
export function getVisitorId(): string {
|
|
22
|
+
if (typeof localStorage === 'undefined') {
|
|
23
|
+
return generateUUID();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let visitorId = localStorage.getItem(VISITOR_ID_KEY);
|
|
27
|
+
if (!visitorId) {
|
|
28
|
+
visitorId = `vid_${generateUUID()}`;
|
|
29
|
+
try {
|
|
30
|
+
localStorage.setItem(VISITOR_ID_KEY, visitorId);
|
|
31
|
+
} catch {
|
|
32
|
+
// localStorage might be full or disabled
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return visitorId;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Check if user has opted out
|
|
40
|
+
*/
|
|
41
|
+
export function isOptedOut(): boolean {
|
|
42
|
+
if (typeof localStorage === 'undefined') {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
return localStorage.getItem(OPT_OUT_KEY) === 'true';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Set opt-out status
|
|
50
|
+
*/
|
|
51
|
+
export function setOptOut(optOut: boolean): void {
|
|
52
|
+
if (typeof localStorage === 'undefined') {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
if (optOut) {
|
|
57
|
+
localStorage.setItem(OPT_OUT_KEY, 'true');
|
|
58
|
+
} else {
|
|
59
|
+
localStorage.removeItem(OPT_OUT_KEY);
|
|
60
|
+
}
|
|
61
|
+
} catch {
|
|
62
|
+
// localStorage might be full or disabled
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export interface UTMParams {
|
|
2
|
+
utm_source?: string;
|
|
3
|
+
utm_medium?: string;
|
|
4
|
+
utm_campaign?: string;
|
|
5
|
+
utm_term?: string;
|
|
6
|
+
utm_content?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Extract UTM parameters from the current URL
|
|
11
|
+
*/
|
|
12
|
+
export function getUTMParams(): UTMParams {
|
|
13
|
+
if (typeof window === 'undefined') {
|
|
14
|
+
return {};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const params = new URLSearchParams(window.location.search);
|
|
18
|
+
const utm: UTMParams = {};
|
|
19
|
+
|
|
20
|
+
const source = params.get('utm_source');
|
|
21
|
+
if (source) utm.utm_source = source;
|
|
22
|
+
|
|
23
|
+
const medium = params.get('utm_medium');
|
|
24
|
+
if (medium) utm.utm_medium = medium;
|
|
25
|
+
|
|
26
|
+
const campaign = params.get('utm_campaign');
|
|
27
|
+
if (campaign) utm.utm_campaign = campaign;
|
|
28
|
+
|
|
29
|
+
const term = params.get('utm_term');
|
|
30
|
+
if (term) utm.utm_term = term;
|
|
31
|
+
|
|
32
|
+
const content = params.get('utm_content');
|
|
33
|
+
if (content) utm.utm_content = content;
|
|
34
|
+
|
|
35
|
+
return utm;
|
|
36
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -24,6 +24,40 @@ function resolveHeaders(
|
|
|
24
24
|
return typeof headers === 'function' ? headers() : headers;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Escape special regex characters in a string.
|
|
29
|
+
*/
|
|
30
|
+
function escapeRegExp(str: string): string {
|
|
31
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Substitute path parameters in a URL path template.
|
|
36
|
+
* E.g., '/api/users/:id' with { id: '123' } becomes '/api/users/123'
|
|
37
|
+
*/
|
|
38
|
+
function substitutePathParams(pathTemplate: string, pathParams?: Record<string, string>): string {
|
|
39
|
+
if (!pathParams) return pathTemplate;
|
|
40
|
+
|
|
41
|
+
let result = pathTemplate;
|
|
42
|
+
for (const [key, value] of Object.entries(pathParams)) {
|
|
43
|
+
const escapedKey = escapeRegExp(key);
|
|
44
|
+
result = result.replace(new RegExp(`:${escapedKey}\\??`, 'g'), encodeURIComponent(value));
|
|
45
|
+
result = result.replace(new RegExp(`\\*${escapedKey}`, 'g'), encodeURIComponent(value));
|
|
46
|
+
}
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Build URL with query params.
|
|
52
|
+
*/
|
|
53
|
+
function buildUrlWithQuery(baseUrl: string, path: string, query?: Record<string, string>): string {
|
|
54
|
+
const url = `${baseUrl}${path}`;
|
|
55
|
+
if (!query || Object.keys(query).length === 0) return url;
|
|
56
|
+
|
|
57
|
+
const params = new URLSearchParams(query);
|
|
58
|
+
return `${url}?${params.toString()}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
27
61
|
/**
|
|
28
62
|
* Create a type-safe API client from a RouteRegistry.
|
|
29
63
|
*
|
|
@@ -76,37 +110,97 @@ export function createClient<R>(options: ClientOptions = {}, metadata?: unknown)
|
|
|
76
110
|
if (isTerminalMethod && currentPath.length >= 1) {
|
|
77
111
|
const method = prop;
|
|
78
112
|
const pathSegments = currentPath;
|
|
79
|
-
const urlPath = '/api/' + pathSegments.join('/');
|
|
80
113
|
|
|
81
|
-
//
|
|
114
|
+
// Look up route metadata
|
|
82
115
|
let routeType = 'api';
|
|
116
|
+
let routePath: string | undefined;
|
|
117
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
118
|
+
let metaNode: any = metadata;
|
|
119
|
+
|
|
83
120
|
if (isStreamMethod) {
|
|
84
121
|
// Stream methods directly specify the route type
|
|
85
122
|
if (method === 'websocket') routeType = 'websocket';
|
|
86
123
|
else if (method === 'eventstream') routeType = 'sse';
|
|
87
124
|
else if (method === 'stream') routeType = 'stream';
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
let metaNode: any = metadata;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (metadata) {
|
|
92
128
|
for (const segment of pathSegments) {
|
|
93
129
|
if (metaNode && typeof metaNode === 'object') {
|
|
94
130
|
metaNode = metaNode[segment];
|
|
95
131
|
}
|
|
96
132
|
}
|
|
97
|
-
if (metaNode && typeof metaNode === 'object' && metaNode[method]
|
|
98
|
-
|
|
133
|
+
if (metaNode && typeof metaNode === 'object' && metaNode[method]) {
|
|
134
|
+
if (metaNode[method].type) {
|
|
135
|
+
routeType = metaNode[method].type;
|
|
136
|
+
}
|
|
137
|
+
if (metaNode[method].path) {
|
|
138
|
+
routePath = metaNode[method].path;
|
|
139
|
+
}
|
|
99
140
|
}
|
|
100
141
|
}
|
|
101
142
|
|
|
102
|
-
|
|
143
|
+
// Fallback URL path if no metadata
|
|
144
|
+
const fallbackPath = '/api/' + pathSegments.join('/');
|
|
145
|
+
|
|
146
|
+
return (...args: unknown[]) => {
|
|
103
147
|
const resolvedBaseUrl = resolveBaseUrl(baseUrl);
|
|
104
148
|
const resolvedHeaders = resolveHeaders(defaultHeaders);
|
|
105
149
|
|
|
150
|
+
// Get path param names from metadata if available
|
|
151
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
152
|
+
const pathParamNames: string[] | undefined = (metaNode as any)?.[method]?.pathParams;
|
|
153
|
+
const hasPathParams = pathParamNames && pathParamNames.length > 0;
|
|
154
|
+
|
|
155
|
+
let pathParams: Record<string, string> | undefined;
|
|
156
|
+
let input: unknown;
|
|
157
|
+
let query: Record<string, string> | undefined;
|
|
158
|
+
|
|
159
|
+
if (hasPathParams) {
|
|
160
|
+
// Route has path params - positional arguments API
|
|
161
|
+
// Args are: param1, param2, ..., [options?]
|
|
162
|
+
// Example: client.user.get('123', 12) or client.user.get('123', 12, { query: {...} })
|
|
163
|
+
pathParams = {};
|
|
164
|
+
|
|
165
|
+
for (let i = 0; i < pathParamNames.length; i++) {
|
|
166
|
+
const arg = args[i];
|
|
167
|
+
if (arg === undefined || arg === null) {
|
|
168
|
+
throw new Error(
|
|
169
|
+
`Missing required path parameter '${pathParamNames[i]}' at position ${i + 1}. ` +
|
|
170
|
+
`Expected ${pathParamNames.length} path parameter(s): ${pathParamNames.join(', ')}`
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
pathParams[pathParamNames[i]] = String(arg);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Check if there's an options object after the path params
|
|
177
|
+
const optionsArg = args[pathParamNames.length];
|
|
178
|
+
if (optionsArg && typeof optionsArg === 'object') {
|
|
179
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
180
|
+
const opts = optionsArg as any;
|
|
181
|
+
input = opts.input;
|
|
182
|
+
query = opts.query;
|
|
183
|
+
}
|
|
184
|
+
} else {
|
|
185
|
+
// No path params - use existing behavior
|
|
186
|
+
const options = args[0];
|
|
187
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
188
|
+
const opts = options as any;
|
|
189
|
+
const isOptionsObject =
|
|
190
|
+
opts && typeof opts === 'object' && ('input' in opts || 'query' in opts);
|
|
191
|
+
|
|
192
|
+
input = isOptionsObject ? opts.input : options;
|
|
193
|
+
query = isOptionsObject ? opts.query : undefined;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Substitute path params in the route path
|
|
197
|
+
const basePath = routePath || fallbackPath;
|
|
198
|
+
const urlPath = substitutePathParams(basePath, pathParams);
|
|
199
|
+
|
|
106
200
|
// WebSocket endpoint
|
|
107
201
|
if (routeType === 'websocket') {
|
|
108
202
|
const wsBaseUrl = resolvedBaseUrl.replace(/^http/, 'ws');
|
|
109
|
-
const wsUrl =
|
|
203
|
+
const wsUrl = buildUrlWithQuery(wsBaseUrl, urlPath, query);
|
|
110
204
|
const ws = createWebSocketClient(wsUrl);
|
|
111
205
|
if (input) {
|
|
112
206
|
ws.on('open', () => ws.send(input));
|
|
@@ -116,9 +210,7 @@ export function createClient<R>(options: ClientOptions = {}, metadata?: unknown)
|
|
|
116
210
|
|
|
117
211
|
// SSE endpoint
|
|
118
212
|
if (routeType === 'sse') {
|
|
119
|
-
const sseUrl =
|
|
120
|
-
// Note: Native EventSource doesn't support custom headers
|
|
121
|
-
// For auth, use withCredentials or consider fetch-based alternatives like @microsoft/fetch-event-source
|
|
213
|
+
const sseUrl = buildUrlWithQuery(resolvedBaseUrl, urlPath, query);
|
|
122
214
|
return createEventStreamClient(sseUrl, {
|
|
123
215
|
withCredentials: Object.keys(resolvedHeaders).length > 0,
|
|
124
216
|
});
|
|
@@ -126,7 +218,8 @@ export function createClient<R>(options: ClientOptions = {}, metadata?: unknown)
|
|
|
126
218
|
|
|
127
219
|
// Stream endpoint
|
|
128
220
|
if (routeType === 'stream') {
|
|
129
|
-
|
|
221
|
+
const streamUrl = buildUrlWithQuery(resolvedBaseUrl, urlPath, query);
|
|
222
|
+
return fetch(streamUrl, {
|
|
130
223
|
method: method.toUpperCase(),
|
|
131
224
|
headers: { 'Content-Type': contentType, ...resolvedHeaders },
|
|
132
225
|
body: input ? JSON.stringify(input) : undefined,
|
|
@@ -138,7 +231,8 @@ export function createClient<R>(options: ClientOptions = {}, metadata?: unknown)
|
|
|
138
231
|
}
|
|
139
232
|
|
|
140
233
|
// Regular API endpoint
|
|
141
|
-
|
|
234
|
+
const apiUrl = buildUrlWithQuery(resolvedBaseUrl, urlPath, query);
|
|
235
|
+
return fetch(apiUrl, {
|
|
142
236
|
method: method.toUpperCase(),
|
|
143
237
|
headers: { 'Content-Type': contentType, ...resolvedHeaders },
|
|
144
238
|
body: method.toUpperCase() !== 'GET' && input ? JSON.stringify(input) : undefined,
|