Package not found. Please check the package name and try again.

@wool-so/node 0.3.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.
Files changed (3) hide show
  1. package/package.json +28 -0
  2. package/src/index.d.ts +195 -0
  3. package/src/index.js +404 -0
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@wool-so/node",
3
+ "version": "0.3.0",
4
+ "description": "Server-side analytics SDK for Wool.",
5
+ "type": "module",
6
+ "license": "UNLICENSED",
7
+ "main": "./src/index.js",
8
+ "types": "./src/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./src/index.d.ts",
12
+ "import": "./src/index.js",
13
+ "default": "./src/index.js"
14
+ }
15
+ },
16
+ "files": [
17
+ "src"
18
+ ],
19
+ "sideEffects": false,
20
+ "publishConfig": {
21
+ "access": "public",
22
+ "registry": "https://registry.npmjs.org/"
23
+ },
24
+ "scripts": {
25
+ "build": "node --check src/index.js",
26
+ "test": "node --test test/*.test.js"
27
+ }
28
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,195 @@
1
+ export const VERSION: '0.3.0'
2
+ export const DEFAULT_ENDPOINT: 'https://wool-api.gurjas1882.workers.dev/v1/events'
3
+
4
+ export type WoolPropertyValue = string | number | boolean | null | undefined
5
+ export type WoolProperties = Record<string, WoolPropertyValue>
6
+
7
+ export interface WoolFetchResponse {
8
+ ok: boolean
9
+ status: number
10
+ text?: () => Promise<string>
11
+ }
12
+
13
+ export type WoolFetch = (
14
+ input: string,
15
+ init: {
16
+ method: 'POST'
17
+ headers: Record<string, string>
18
+ body: string
19
+ }
20
+ ) => Promise<WoolFetchResponse>
21
+
22
+ export interface WoolBaseOptions {
23
+ endpoint?: string
24
+ apiUrl?: string
25
+ apiHost?: string
26
+ fetch?: WoolFetch
27
+ enabled?: boolean
28
+ referrer?: string
29
+ referer?: string
30
+ retentionId?: string
31
+ retention_id?: string
32
+ subjectKey?: string
33
+ subject_key?: string
34
+ groupKey?: string
35
+ group_key?: string
36
+ }
37
+
38
+ export type WoolInitOptions = WoolBaseOptions &
39
+ (
40
+ | {
41
+ projectId: string
42
+ project_id?: string
43
+ }
44
+ | {
45
+ projectId?: string
46
+ project_id: string
47
+ }
48
+ ) &
49
+ (
50
+ | {
51
+ apiKey: string
52
+ api_key?: string
53
+ }
54
+ | {
55
+ apiKey?: string
56
+ api_key: string
57
+ }
58
+ ) &
59
+ (
60
+ | {
61
+ siteUrl: string
62
+ site_url?: string
63
+ url?: string
64
+ }
65
+ | {
66
+ siteUrl?: string
67
+ site_url: string
68
+ url?: string
69
+ }
70
+ | {
71
+ siteUrl?: string
72
+ site_url?: string
73
+ url: string
74
+ }
75
+ )
76
+
77
+ export interface WoolIdentifyOptions {
78
+ groupKey?: string
79
+ group_key?: string
80
+ }
81
+
82
+ export interface WoolRequestLike {
83
+ url?: string
84
+ originalUrl?: string
85
+ path?: string
86
+ protocol?: string
87
+ hostname?: string
88
+ headers?: Headers | Record<string, string | string[] | undefined>
89
+ socket?: {
90
+ encrypted?: boolean
91
+ }
92
+ connection?: {
93
+ encrypted?: boolean
94
+ }
95
+ nextUrl?: {
96
+ pathname?: string
97
+ }
98
+ }
99
+
100
+ export interface WoolRequestContextOptions {
101
+ url?: string
102
+ referrer?: string
103
+ referer?: string
104
+ }
105
+
106
+ export interface WoolRequestContext {
107
+ url: string
108
+ referrer: string
109
+ }
110
+
111
+ export interface WoolTrackOptions extends WoolRequestContextOptions {
112
+ request?: Request | WoolRequestLike
113
+ req?: Request | WoolRequestLike
114
+ source?: string
115
+ eventId?: string
116
+ id?: string
117
+ visitorKey?: string
118
+ visitor_key?: string
119
+ value?: number | string
120
+ currency?: string
121
+ duration?: number | string
122
+ nonInteractive?: boolean
123
+ non_interactive?: boolean
124
+ retentionId?: string
125
+ retention_id?: string
126
+ subjectKey?: string
127
+ subject_key?: string
128
+ anonymousId?: string
129
+ anonymous_id?: string
130
+ groupKey?: string
131
+ group_key?: string
132
+ }
133
+
134
+ export interface WoolPageviewOptions extends WoolRequestContextOptions {
135
+ request?: Request | WoolRequestLike
136
+ req?: Request | WoolRequestLike
137
+ }
138
+
139
+ export interface WoolResult {
140
+ ok: boolean
141
+ status: number
142
+ skipped?: boolean
143
+ response?: WoolFetchResponse
144
+ }
145
+
146
+ export interface WoolClient {
147
+ identify(retentionId: string, options?: WoolIdentifyOptions): void
148
+ reset(): void
149
+ track(name: string, properties?: WoolProperties, options?: WoolTrackOptions): Promise<WoolResult>
150
+ pageview(options?: WoolPageviewOptions): Promise<WoolResult>
151
+ requestContext(input?: Request | WoolRequestLike | string, options?: WoolRequestContextOptions): WoolRequestContext
152
+ }
153
+
154
+ export class WoolError extends Error {
155
+ status: number
156
+ response: WoolFetchResponse | null
157
+ constructor(message: string, status?: number, response?: WoolFetchResponse | null)
158
+ }
159
+
160
+ export function createClient(options: WoolInitOptions): WoolClient
161
+ export function init(options: WoolInitOptions): WoolClient
162
+ export function identify(retentionId: string, options?: WoolIdentifyOptions): void
163
+ export function reset(): void
164
+ export function track(name: string, properties?: WoolProperties, options?: WoolTrackOptions): Promise<WoolResult>
165
+ export function pageview(options?: WoolPageviewOptions): Promise<WoolResult>
166
+ export function requestContext(
167
+ input?: Request | WoolRequestLike | string,
168
+ options?: WoolRequestContextOptions
169
+ ): WoolRequestContext
170
+
171
+ export type ExpressMiddleware = (req: WoolRequestLike & { wool?: WoolClient }, res: unknown, next?: () => void) => void
172
+ export type NestMiddleware = ExpressMiddleware
173
+ export type FastifyPlugin = (
174
+ fastify: { decorate?: (name: string, value: WoolClient) => void; wool?: WoolClient },
175
+ options?: unknown,
176
+ done?: () => void
177
+ ) => void
178
+ export type HonoMiddleware = (context: { set?: (name: string, value: WoolClient) => void; wool?: WoolClient }, next?: () => Promise<void> | void) => Promise<void>
179
+
180
+ export function expressMiddleware(options: WoolInitOptions | WoolClient): ExpressMiddleware
181
+ export function nestMiddleware(options: WoolInitOptions | WoolClient): NestMiddleware
182
+ export function fastifyPlugin(options: WoolInitOptions | WoolClient): FastifyPlugin
183
+ export function honoMiddleware(options: WoolInitOptions | WoolClient): HonoMiddleware
184
+
185
+ export const wool: WoolClient & {
186
+ init: typeof init
187
+ createClient: typeof createClient
188
+ requestContext: typeof requestContext
189
+ expressMiddleware: typeof expressMiddleware
190
+ fastifyPlugin: typeof fastifyPlugin
191
+ honoMiddleware: typeof honoMiddleware
192
+ nestMiddleware: typeof nestMiddleware
193
+ }
194
+
195
+ export default wool
package/src/index.js ADDED
@@ -0,0 +1,404 @@
1
+ export const VERSION = '0.3.0'
2
+ export const DEFAULT_ENDPOINT = 'https://wool-api.gurjas1882.workers.dev/v1/events'
3
+
4
+ const IDENTITY_KEY_PATTERN =
5
+ /^(anonymousId|anonymous_id|retentionId|retention_id|subjectKey|subject_key|groupKey|group_key)$/i
6
+ const TRACK_OPTION_KEYS = new Set([
7
+ 'source',
8
+ 'eventId',
9
+ 'id',
10
+ 'visitorKey',
11
+ 'visitor_key',
12
+ 'value',
13
+ 'currency',
14
+ 'duration',
15
+ 'nonInteractive',
16
+ 'non_interactive',
17
+ 'retentionId',
18
+ 'retention_id',
19
+ 'subjectKey',
20
+ 'subject_key',
21
+ 'anonymousId',
22
+ 'anonymous_id',
23
+ 'groupKey',
24
+ 'group_key',
25
+ 'url',
26
+ 'referrer',
27
+ 'referer',
28
+ 'request',
29
+ 'req'
30
+ ])
31
+
32
+ export class WoolError extends Error {
33
+ constructor(message, status = 0, response = null) {
34
+ super(message)
35
+ this.name = 'WoolError'
36
+ this.status = status
37
+ this.response = response
38
+ }
39
+ }
40
+
41
+ function cleanText(value, maxLength = 128) {
42
+ const text = String(value || '').trim()
43
+ return text.length > maxLength ? text.slice(0, maxLength) : text
44
+ }
45
+
46
+ function endpointFromApiUrl(value) {
47
+ const text = cleanText(value, 2048)
48
+ if (!text) return ''
49
+
50
+ try {
51
+ return new URL('/v1/events', text).toString()
52
+ } catch {
53
+ return ''
54
+ }
55
+ }
56
+
57
+ function resolveEndpoint(options) {
58
+ return cleanText(options.endpoint, 2048) || endpointFromApiUrl(options.apiUrl || options.apiHost) || DEFAULT_ENDPOINT
59
+ }
60
+
61
+ function isPlainObject(value) {
62
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value))
63
+ }
64
+
65
+ function cleanProperties(properties) {
66
+ if (!isPlainObject(properties)) return {}
67
+
68
+ const clean = {}
69
+ Object.keys(properties)
70
+ .filter((key) => !IDENTITY_KEY_PATTERN.test(key))
71
+ .slice(0, 12)
72
+ .forEach((key) => {
73
+ const value = properties[key]
74
+ if (value === undefined || value === null) return
75
+ if (typeof value === 'object') return
76
+ clean[key] = value
77
+ })
78
+
79
+ return clean
80
+ }
81
+
82
+ function identityValueFrom(source, keys) {
83
+ if (!isPlainObject(source)) return ''
84
+
85
+ for (const key of keys) {
86
+ const value = source[key]
87
+ if (value !== undefined && value !== null && String(value).trim()) return value
88
+ }
89
+
90
+ return ''
91
+ }
92
+
93
+ function applyIdentity(payload, state, properties, options) {
94
+ const retentionId =
95
+ identityValueFrom(options, ['retentionId', 'retention_id', 'subjectKey', 'subject_key', 'anonymousId', 'anonymous_id']) ||
96
+ identityValueFrom(properties, ['retentionId', 'retention_id', 'subjectKey', 'subject_key', 'anonymousId', 'anonymous_id']) ||
97
+ state.identity.retentionId
98
+ const groupKey =
99
+ identityValueFrom(options, ['groupKey', 'group_key']) ||
100
+ identityValueFrom(properties, ['groupKey', 'group_key']) ||
101
+ state.identity.groupKey
102
+
103
+ if (retentionId) payload.retentionId = cleanText(retentionId)
104
+ if (groupKey) payload.groupKey = cleanText(groupKey)
105
+ }
106
+
107
+ function createEventId() {
108
+ const crypto = globalThis.crypto
109
+ if (crypto?.randomUUID) return crypto.randomUUID()
110
+ return `${Date.now()}-${Math.random().toString(36).slice(2)}`
111
+ }
112
+
113
+ function headersFrom(value) {
114
+ if (!value) return null
115
+
116
+ if (value instanceof Headers) return value
117
+ if (value.headers instanceof Headers) return value.headers
118
+ if (value.raw instanceof Headers) return value.raw
119
+
120
+ return {
121
+ get(name) {
122
+ const lower = String(name).toLowerCase()
123
+ const headers = value.headers || value
124
+ const direct = headers[name] ?? headers[lower]
125
+ if (Array.isArray(direct)) return direct.join(', ')
126
+ return direct === undefined || direct === null ? '' : String(direct)
127
+ }
128
+ }
129
+ }
130
+
131
+ function headerValue(headers, names) {
132
+ if (!headers) return ''
133
+
134
+ for (const name of names) {
135
+ const value = headers.get?.(name)
136
+ if (value) return String(value)
137
+ }
138
+
139
+ return ''
140
+ }
141
+
142
+ function hostFromRequest(input, headers) {
143
+ const host = headerValue(headers, ['x-forwarded-host', 'host'])
144
+ if (host) return host.split(',')[0].trim()
145
+
146
+ const hostname = input?.hostname || input?.headers?.host || ''
147
+ return Array.isArray(hostname) ? hostname[0] || '' : String(hostname || '')
148
+ }
149
+
150
+ function protocolFromRequest(input, headers) {
151
+ const protocol = headerValue(headers, ['x-forwarded-proto'])
152
+ if (protocol) return protocol.split(',')[0].trim()
153
+
154
+ if (input?.protocol) return String(input.protocol).replace(/:$/, '')
155
+ if (input?.socket?.encrypted || input?.connection?.encrypted) return 'https'
156
+ return 'https'
157
+ }
158
+
159
+ function urlFromRequest(input, headers) {
160
+ if (!input) return ''
161
+ if (typeof input === 'string') return input
162
+ if (input.url && /^https?:\/\//i.test(String(input.url))) return String(input.url)
163
+
164
+ const path = input.originalUrl || input.url || input.path || input.nextUrl?.pathname || ''
165
+ if (!path) return ''
166
+
167
+ const host = hostFromRequest(input, headers)
168
+ if (!host) return ''
169
+
170
+ return `${protocolFromRequest(input, headers)}://${host}${path.startsWith('/') ? path : `/${path}`}`
171
+ }
172
+
173
+ export function requestContext(input = {}, options = {}) {
174
+ const headers = headersFrom(input)
175
+ const url = cleanText(options.url, 2048) || urlFromRequest(input, headers)
176
+ const referrer =
177
+ cleanText(options.referrer || options.referer, 2048) || headerValue(headers, ['referer', 'referrer']) || ''
178
+
179
+ return {
180
+ url,
181
+ referrer
182
+ }
183
+ }
184
+
185
+ function trackOptionsFrom(options) {
186
+ if (!isPlainObject(options)) return {}
187
+
188
+ const clean = {}
189
+ for (const key of TRACK_OPTION_KEYS) {
190
+ if (options[key] !== undefined) clean[key] = options[key]
191
+ }
192
+
193
+ return clean
194
+ }
195
+
196
+ function resolveFetch(options) {
197
+ const fetcher = options.fetch || globalThis.fetch
198
+ if (typeof fetcher !== 'function') {
199
+ throw new WoolError('A fetch implementation is required.')
200
+ }
201
+
202
+ return fetcher
203
+ }
204
+
205
+ function createState(options) {
206
+ const projectId = cleanText(options.projectId || options.project_id, 96)
207
+ const apiKey = cleanText(options.apiKey || options.api_key, 4096)
208
+ const siteUrl = cleanText(options.siteUrl || options.site_url || options.url, 2048)
209
+
210
+ if (!projectId) throw new WoolError('projectId is required.')
211
+ if (!apiKey) throw new WoolError('apiKey is required.')
212
+
213
+ return {
214
+ projectId,
215
+ apiKey,
216
+ endpoint: resolveEndpoint(options),
217
+ defaultUrl: siteUrl,
218
+ defaultReferrer: cleanText(options.referrer || options.referer, 2048),
219
+ enabled: options.enabled !== false,
220
+ fetch: resolveFetch(options),
221
+ identity: {
222
+ retentionId: cleanText(options.retentionId || options.retention_id || options.subjectKey || options.subject_key),
223
+ groupKey: cleanText(options.groupKey || options.group_key)
224
+ }
225
+ }
226
+ }
227
+
228
+ function bodyFromResponse(response) {
229
+ return response?.text ? response.text().catch(() => '') : Promise.resolve('')
230
+ }
231
+
232
+ export function createClient(options = {}) {
233
+ const state = createState(options)
234
+
235
+ async function send(payload) {
236
+ if (!state.enabled) {
237
+ return { ok: true, status: 0, skipped: true }
238
+ }
239
+
240
+ const response = await state.fetch(state.endpoint, {
241
+ method: 'POST',
242
+ headers: {
243
+ Authorization: `Bearer ${state.apiKey}`,
244
+ 'Content-Type': 'application/json',
245
+ 'X-Wool-SDK': `@wool-so/node/${VERSION}`
246
+ },
247
+ body: JSON.stringify(payload)
248
+ })
249
+
250
+ if (!response?.ok && response?.status !== 204) {
251
+ const body = await bodyFromResponse(response)
252
+ throw new WoolError(body || 'Unable to send Wool analytics event.', response?.status || 0, response || null)
253
+ }
254
+
255
+ return { ok: true, status: response?.status || 0, response }
256
+ }
257
+
258
+ function basePayload(type, options = {}) {
259
+ const context = requestContext(options.request || options.req, options)
260
+ const url = cleanText(context.url || state.defaultUrl, 2048)
261
+
262
+ if (!url) throw new WoolError('url or siteUrl is required.')
263
+
264
+ const payload = {
265
+ projectId: state.projectId,
266
+ type,
267
+ url,
268
+ referrer: cleanText(context.referrer || state.defaultReferrer, 2048),
269
+ v: VERSION
270
+ }
271
+
272
+ if (state.identity.retentionId) payload.retentionId = state.identity.retentionId
273
+ if (state.identity.groupKey) payload.groupKey = state.identity.groupKey
274
+
275
+ return payload
276
+ }
277
+
278
+ async function track(name, properties = {}, options = {}) {
279
+ const eventName = cleanText(name, 96)
280
+ if (!eventName) throw new WoolError('Event name is required.')
281
+
282
+ const opts = trackOptionsFrom(options)
283
+ const eventId = cleanText(opts.eventId || opts.id || createEventId(), 96)
284
+ const payload = {
285
+ ...basePayload('event', opts),
286
+ name: eventName,
287
+ properties: cleanProperties(properties),
288
+ source: cleanText(opts.source || 'server', 32),
289
+ eventId,
290
+ visitorKey: cleanText(opts.visitorKey || opts.visitor_key || eventId, 96)
291
+ }
292
+
293
+ applyIdentity(payload, state, properties, opts)
294
+
295
+ if (Number.isFinite(Number(opts.value))) payload.value = Number(opts.value)
296
+ if (opts.currency) payload.currency = cleanText(opts.currency, 16)
297
+ if (Number.isFinite(Number(opts.duration))) payload.duration = Number(opts.duration)
298
+ if (opts.nonInteractive || opts.non_interactive) payload.nonInteractive = true
299
+
300
+ return send(payload)
301
+ }
302
+
303
+ async function pageview(options = {}) {
304
+ return send(basePayload('pageview', options))
305
+ }
306
+
307
+ function identify(retentionId, options = {}) {
308
+ state.identity = {
309
+ retentionId: cleanText(retentionId),
310
+ groupKey: cleanText(options.groupKey || options.group_key)
311
+ }
312
+ }
313
+
314
+ function reset() {
315
+ state.identity = {
316
+ retentionId: '',
317
+ groupKey: ''
318
+ }
319
+ }
320
+
321
+ return {
322
+ identify,
323
+ pageview,
324
+ requestContext,
325
+ reset,
326
+ track
327
+ }
328
+ }
329
+
330
+ function clientFrom(input) {
331
+ return typeof input?.track === 'function' ? input : createClient(input)
332
+ }
333
+
334
+ export function expressMiddleware(input) {
335
+ const client = clientFrom(input)
336
+ return function woolExpressMiddleware(req, _res, next) {
337
+ req.wool = client
338
+ if (typeof next === 'function') next()
339
+ }
340
+ }
341
+
342
+ export const nestMiddleware = expressMiddleware
343
+
344
+ export function fastifyPlugin(input) {
345
+ const client = clientFrom(input)
346
+ return function woolFastifyPlugin(fastify, _options, done) {
347
+ if (typeof fastify?.decorate === 'function') fastify.decorate('wool', client)
348
+ else if (fastify && typeof fastify === 'object') fastify.wool = client
349
+ if (typeof done === 'function') done()
350
+ }
351
+ }
352
+
353
+ export function honoMiddleware(input) {
354
+ const client = clientFrom(input)
355
+ return async function woolHonoMiddleware(c, next) {
356
+ if (typeof c?.set === 'function') c.set('wool', client)
357
+ else if (c && typeof c === 'object') c.wool = client
358
+ if (typeof next === 'function') await next()
359
+ }
360
+ }
361
+
362
+ let defaultClient = null
363
+
364
+ export function init(options = {}) {
365
+ defaultClient = createClient(options)
366
+ return defaultClient
367
+ }
368
+
369
+ function requireDefaultClient() {
370
+ if (!defaultClient) throw new WoolError('Call init before using the default Wool client.')
371
+ return defaultClient
372
+ }
373
+
374
+ export function identify(retentionId, options) {
375
+ return requireDefaultClient().identify(retentionId, options)
376
+ }
377
+
378
+ export function reset() {
379
+ return requireDefaultClient().reset()
380
+ }
381
+
382
+ export function track(name, properties, options) {
383
+ return requireDefaultClient().track(name, properties, options)
384
+ }
385
+
386
+ export function pageview(options) {
387
+ return requireDefaultClient().pageview(options)
388
+ }
389
+
390
+ export const wool = {
391
+ init,
392
+ identify,
393
+ reset,
394
+ track,
395
+ pageview,
396
+ createClient,
397
+ requestContext,
398
+ expressMiddleware,
399
+ fastifyPlugin,
400
+ honoMiddleware,
401
+ nestMiddleware
402
+ }
403
+
404
+ export default wool