Update from Vibe Studio

This commit is contained in:
Vibe Studio
2026-01-09 14:52:46 +00:00
parent 42a0efe70b
commit 47fa6d98b2
28661 changed files with 2421771 additions and 0 deletions

21
node_modules/@segment/analytics-node/LICENSE generated vendored Normal file
View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright © 2021 Segment
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

63
node_modules/@segment/analytics-node/README.md generated vendored Normal file
View File

@@ -0,0 +1,63 @@
# @segment/analytics-node
https://www.npmjs.com/package/@segment/analytics-node
### OFFICIAL DOCUMENTATION (FULL)
- https://segment.com/docs/connections/sources/catalog/libraries/server/node
### LEGACY NODE SDK MIGRATION GUIDE:
- https://segment.com/docs/connections/sources/catalog/libraries/server/node/migration
## Runtime Support
- Node.js >= 18
- AWS Lambda
- Cloudflare Workers
- Vercel Edge Functions
- Web Workers / Browser (no device mode destination support)
## Quick Start
### Install library
```bash
# npm
npm install @segment/analytics-node
# yarn
yarn add @segment/analytics-node
# pnpm
pnpm install @segment/analytics-node
```
### Usage
Assuming some express-like web framework.
```ts
import { Analytics } from '@segment/analytics-node'
// or, if you use require:
const { Analytics } = require('@segment/analytics-node')
// instantiation
const analytics = new Analytics({ writeKey: '<MY_WRITE_KEY>' })
app.post('/login', (req, res) => {
analytics.identify({
userId: req.body.userId,
previousId: req.body.previousId
})
res.sendStatus(200)
})
app.post('/cart', (req, res) => {
analytics.track({
userId: req.body.userId,
event: 'Add to cart',
properties: { productId: '123456' }
})
res.sendStatus(201)
});
```
See our [official documentation](https://segment.com/docs/connections/sources/catalog/libraries/server/node) for more examples and information.
## Settings & Configuration
See the documentation: https://segment.com/docs/connections/sources/catalog/libraries/server/node/#configuration
You can also see the complete list of settings in the [AnalyticsSettings interface](src/app/settings.ts).

52
node_modules/@segment/analytics-node/package.json generated vendored Normal file
View File

@@ -0,0 +1,52 @@
{
"name": "@segment/analytics-node",
"version": "2.3.0",
"main": "./dist/cjs/index.js",
"module": "./dist/esm/index.js",
"types": "./dist/types/index.d.ts",
"license": "MIT",
"repository": {
"directory": "packages/node",
"type": "git",
"url": "https://github.com/segmentio/analytics-next"
},
"files": [
"dist/",
"src/",
"!**/__tests__/**",
"!*.tsbuildinfo"
],
"engines": {
"node": ">=20"
},
"scripts": {
".": "yarn run -T turbo run --filter=@segment/analytics-node",
"test": "yarn jest",
"lint": "yarn concurrently 'yarn:eslint .' 'yarn:tsc --noEmit'",
"build": "rm -rf dist && yarn concurrently 'yarn:build:*'",
"build:cjs": "yarn tsc -p tsconfig.build.json --outDir ./dist/cjs --module commonjs",
"version": "sh scripts/version.sh",
"build:esm": "yarn tsc -p tsconfig.build.json",
"watch": "yarn build:esm --watch",
"watch:test": "yarn test --watch",
"tsc": "yarn run -T tsc",
"eslint": "yarn run -T eslint",
"concurrently": "yarn run -T concurrently",
"jest": "yarn run -T jest"
},
"dependencies": {
"@lukeed/uuid": "^2.0.0",
"@segment/analytics-core": "1.8.2",
"@segment/analytics-generic-utils": "1.2.0",
"buffer": "^6.0.3",
"jose": "^5.1.0",
"node-fetch": "^2.6.7",
"tslib": "^2.4.1"
},
"devDependencies": {
"@internal/config": "0.0.0",
"@types/node": "^18",
"axios": "^1.6.2"
},
"packageManager": "yarn@3.4.1"
}

View File

@@ -0,0 +1,350 @@
import { CoreAnalytics, bindAll, pTimeout } from '@segment/analytics-core'
import { AnalyticsSettings, validateSettings } from './settings'
import { version } from '../generated/version'
import { createConfiguredNodePlugin } from '../plugins/segmentio'
import { NodeEventFactory } from './event-factory'
import { Callback, dispatchAndEmit } from './dispatch-emit'
import { NodeEmitter } from './emitter'
import {
AliasParams,
GroupParams,
IdentifyParams,
PageParams,
TrackParams,
Plugin,
SegmentEvent,
FlushParams,
CloseAndFlushParams,
} from './types'
import { Context } from './context'
import { NodeEventQueue } from './event-queue'
import { FetchHTTPClient } from '../lib/http-client'
export class Analytics extends NodeEmitter implements CoreAnalytics {
private readonly _eventFactory: NodeEventFactory
private _isClosed = false
private _pendingEvents = 0
private readonly _closeAndFlushDefaultTimeout: number
private readonly _publisher: ReturnType<
typeof createConfiguredNodePlugin
>['publisher']
private _isFlushing = false
private readonly _queue: NodeEventQueue
ready: Promise<void>
constructor(settings: AnalyticsSettings) {
super()
validateSettings(settings)
this._eventFactory = new NodeEventFactory()
this._queue = new NodeEventQueue()
const flushInterval = settings.flushInterval ?? 10000
this._closeAndFlushDefaultTimeout = flushInterval * 1.25 // add arbitrary multiplier in case an event is in a plugin.
const { plugin, publisher } = createConfiguredNodePlugin(
{
writeKey: settings.writeKey,
host: settings.host,
path: settings.path,
maxRetries: settings.maxRetries ?? 3,
flushAt: settings.flushAt ?? settings.maxEventsInBatch ?? 15,
httpRequestTimeout: settings.httpRequestTimeout,
disable: settings.disable,
flushInterval,
httpClient:
typeof settings.httpClient === 'function'
? new FetchHTTPClient(settings.httpClient)
: settings.httpClient ?? new FetchHTTPClient(),
oauthSettings: settings.oauthSettings,
},
this as NodeEmitter
)
this._publisher = publisher
this.ready = this.register(plugin).then(() => undefined)
this.emit('initialize', settings)
bindAll(this)
}
get VERSION() {
return version
}
/**
* Call this method to stop collecting new events and flush all existing events.
* This method also waits for any event method-specific callbacks to be triggered,
* and any of their subsequent promises to be resolved/rejected.
*/
public closeAndFlush({
timeout = this._closeAndFlushDefaultTimeout,
}: CloseAndFlushParams = {}): Promise<void> {
return this.flush({ timeout, close: true })
}
/**
* Call this method to flush all existing events..
* This method also waits for any event method-specific callbacks to be triggered,
* and any of their subsequent promises to be resolved/rejected.
*/
public async flush({
timeout,
close = false,
}: FlushParams = {}): Promise<void> {
if (this._isFlushing) {
// if we're already flushing, then we don't need to do anything
console.warn(
'Overlapping flush calls detected. Please wait for the previous flush to finish before calling .flush again'
)
return
} else {
this._isFlushing = true
}
if (close) {
this._isClosed = true
}
this._publisher.flush(this._pendingEvents)
const promise = new Promise<void>((resolve) => {
if (!this._pendingEvents) {
resolve()
} else {
this.once('drained', () => {
resolve()
})
}
}).finally(() => {
this._isFlushing = false
})
return timeout ? pTimeout(promise, timeout).catch(() => undefined) : promise
}
private _dispatch(segmentEvent: SegmentEvent, callback?: Callback) {
if (this._isClosed) {
this.emit('call_after_close', segmentEvent as SegmentEvent)
return undefined
}
this._pendingEvents++
dispatchAndEmit(segmentEvent, this._queue, this, callback)
.catch((ctx) => ctx)
.finally(() => {
this._pendingEvents--
if (!this._pendingEvents) {
this.emit('drained')
}
})
}
/**
* Combines two unassociated user identities.
* @link https://segment.com/docs/connections/sources/catalog/libraries/server/node/#alias
*/
alias(
{
userId,
previousId,
context,
timestamp,
integrations,
messageId,
}: AliasParams,
callback?: Callback
): void {
const segmentEvent = this._eventFactory.alias(userId, previousId, {
context,
integrations,
timestamp,
messageId,
})
this._dispatch(segmentEvent, callback)
}
/**
* Associates an identified user with a collective.
* @link https://segment.com/docs/connections/sources/catalog/libraries/server/node/#group
*/
group(
{
timestamp,
groupId,
userId,
anonymousId,
traits = {},
context,
integrations,
messageId,
}: GroupParams,
callback?: Callback
): void {
const segmentEvent = this._eventFactory.group(groupId, traits, {
context,
anonymousId,
userId,
timestamp,
integrations,
messageId,
})
this._dispatch(segmentEvent, callback)
}
/**
* Includes a unique userId and (maybe anonymousId) and any optional traits you know about them.
* @link https://segment.com/docs/connections/sources/catalog/libraries/server/node/#identify
*/
identify(
{
userId,
anonymousId,
traits = {},
context,
timestamp,
integrations,
messageId,
}: IdentifyParams,
callback?: Callback
): void {
const segmentEvent = this._eventFactory.identify(userId, traits, {
context,
anonymousId,
userId,
timestamp,
integrations,
messageId,
})
this._dispatch(segmentEvent, callback)
}
/**
* The page method lets you record page views on your website, along with optional extra information about the page being viewed.
* @link https://segment.com/docs/connections/sources/catalog/libraries/server/node/#page
*/
page(
{
userId,
anonymousId,
category,
name,
properties,
context,
timestamp,
integrations,
messageId,
}: PageParams,
callback?: Callback
): void {
const segmentEvent = this._eventFactory.page(
category ?? null,
name ?? null,
properties,
{ context, anonymousId, userId, timestamp, integrations, messageId }
)
this._dispatch(segmentEvent, callback)
}
/**
* Records screen views on your app, along with optional extra information
* about the screen viewed by the user.
*
* TODO: This is not documented on the segment docs ATM (for node).
*/
screen(
{
userId,
anonymousId,
category,
name,
properties,
context,
timestamp,
integrations,
messageId,
}: PageParams,
callback?: Callback
): void {
const segmentEvent = this._eventFactory.screen(
category ?? null,
name ?? null,
properties,
{ context, anonymousId, userId, timestamp, integrations, messageId }
)
this._dispatch(segmentEvent, callback)
}
/**
* Records actions your users perform.
* @link https://segment.com/docs/connections/sources/catalog/libraries/server/node/#track
*/
track(
{
userId,
anonymousId,
event,
properties,
context,
timestamp,
integrations,
messageId,
}: TrackParams,
callback?: Callback
): void {
const segmentEvent = this._eventFactory.track(event, properties, {
context,
userId,
anonymousId,
timestamp,
integrations,
messageId,
})
this._dispatch(segmentEvent, callback)
}
/**
* Registers one or more plugins to augment Analytics functionality.
* @param plugins
*/
register(...plugins: Plugin[]): Promise<void> {
return this._queue.criticalTasks.run(async () => {
const ctx = Context.system()
const registrations = plugins.map((xt) =>
this._queue.register(ctx, xt, this)
)
await Promise.all(registrations)
this.emit(
'register',
plugins.map((el) => el.name)
)
})
}
/**
* Deregisters one or more plugins based on their names.
* @param pluginNames - The names of one or more plugins to deregister.
*/
async deregister(...pluginNames: string[]): Promise<void> {
const ctx = Context.system()
const deregistrations = pluginNames.map((pl) => {
const plugin = this._queue.plugins.find((p) => p.name === pl)
if (plugin) {
return this._queue.deregister(ctx, plugin, this)
} else {
ctx.log('warn', `plugin ${pl} not found`)
}
})
await Promise.all(deregistrations)
this.emit('deregister', pluginNames)
}
}

View File

@@ -0,0 +1,11 @@
// create a derived class since we may want to add node specific things to Context later
import { CoreContext } from '@segment/analytics-core'
import { SegmentEvent } from './types'
// While this is not a type, it is a definition
export class Context extends CoreContext<SegmentEvent> {
static override system() {
return new this({ type: 'track', event: 'system' })
}
}

View File

@@ -0,0 +1,42 @@
import { dispatch } from '@segment/analytics-core'
import type { NodeEmitter } from './emitter'
import { Context } from './context'
import { NodeEventQueue } from './event-queue'
import { SegmentEvent } from './types'
export type Callback = (err?: unknown, ctx?: Context) => void
const normalizeDispatchCb = (cb: Callback) => (ctx: Context) => {
const failedDelivery = ctx.failedDelivery()
return failedDelivery ? cb(failedDelivery.reason, ctx) : cb(undefined, ctx)
}
/* Dispatch function, but swallow promise rejections and use event emitter instead */
export const dispatchAndEmit = async (
event: SegmentEvent,
queue: NodeEventQueue,
emitter: NodeEmitter,
callback?: Callback
): Promise<void> => {
try {
const context = new Context(event)
const ctx = await dispatch(context, queue, emitter, {
...(callback ? { callback: normalizeDispatchCb(callback) } : {}),
})
const failedDelivery = ctx.failedDelivery()
if (failedDelivery) {
emitter.emit('error', {
code: 'delivery_failure',
reason: failedDelivery.reason,
ctx: ctx,
})
} else {
emitter.emit(event.type, ctx)
}
} catch (err) {
emitter.emit('error', {
code: 'unknown',
reason: err,
})
}
}

View File

@@ -0,0 +1,24 @@
import type { CoreEmitterContract } from '@segment/analytics-core'
import { Emitter } from '@segment/analytics-generic-utils'
import { Context } from './context'
import type { AnalyticsSettings } from './settings'
import { SegmentEvent } from './types'
/**
* Map of emitter event names to method args.
*/
export type NodeEmitterEvents = CoreEmitterContract<Context> & {
initialize: [AnalyticsSettings]
call_after_close: [SegmentEvent] // any event that did not get dispatched due to close
http_request: [
{
url: string
method: string
headers: Record<string, string>
body: string
}
]
drained: []
}
export class NodeEmitter extends Emitter<NodeEmitterEvents> {}

View File

@@ -0,0 +1,25 @@
import { assertUserIdentity, CoreEventFactory } from '@segment/analytics-core'
import { createMessageId } from '../lib/get-message-id'
import { SegmentEvent } from './types'
// use declaration merging to downcast CoreSegmentEvent without adding any runtime code.
// if/when we decide to add an actual implementation to NodeEventFactory that actually changes the event shape, we can remove this.
export interface NodeEventFactory {
alias(...args: Parameters<CoreEventFactory['alias']>): SegmentEvent
group(...args: Parameters<CoreEventFactory['group']>): SegmentEvent
identify(...args: Parameters<CoreEventFactory['identify']>): SegmentEvent
track(...args: Parameters<CoreEventFactory['track']>): SegmentEvent
page(...args: Parameters<CoreEventFactory['page']>): SegmentEvent
screen(...args: Parameters<CoreEventFactory['screen']>): SegmentEvent
}
export class NodeEventFactory extends CoreEventFactory {
constructor() {
super({
createMessageId,
onFinishedEvent: (event) => {
assertUserIdentity(event)
},
})
}
}

View File

@@ -0,0 +1,23 @@
import { CoreEventQueue, PriorityQueue } from '@segment/analytics-core'
import type { Plugin } from '../app/types'
import type { Context } from './context'
class NodePriorityQueue extends PriorityQueue<Context> {
constructor() {
super(1, [])
}
// do not use an internal "seen" map
getAttempts(ctx: Context): number {
return ctx.attempts ?? 0
}
updateAttempts(ctx: Context): number {
ctx.attempts = this.getAttempts(ctx) + 1
return this.getAttempts(ctx)
}
}
export class NodeEventQueue extends CoreEventQueue<Context, Plugin> {
constructor() {
super(new NodePriorityQueue())
}
}

View File

@@ -0,0 +1,59 @@
import { ValidationError } from '@segment/analytics-core'
import { HTTPClient, HTTPFetchFn } from '../lib/http-client'
import { OAuthSettings } from '../lib/types'
export interface AnalyticsSettings {
/**
* Key that corresponds to your Segment.io project
*/
writeKey: string
/**
* The base URL of the API. Default: "https://api.segment.io"
*/
host?: string
/**
* The API path route. Default: "/v1/batch"
*/
path?: string
/**
* The number of times to retry flushing a batch. Default: 3
*/
maxRetries?: number
/**
* The number of events to enqueue before flushing. Default: 15.
*/
flushAt?: number
/**
* @deprecated
* The number of events to enqueue before flushing. This is deprecated in favor of `flushAt`. Default: 15.
*/
maxEventsInBatch?: number
/**
* The number of milliseconds to wait before flushing the queue automatically. Default: 10000
*/
flushInterval?: number
/**
* The maximum number of milliseconds to wait for an http request. Default: 10000
*/
httpRequestTimeout?: number
/**
* Disable the analytics library. All calls will be a noop. Default: false.
*/
disable?: boolean
/**
* Supply a default http client implementation (such as one supporting proxy).
* Accepts either an HTTPClient instance or a fetch function.
* Default: an HTTP client that uses globalThis.fetch, with node-fetch as a fallback.
*/
httpClient?: HTTPFetchFn | HTTPClient
/**
* Set up OAuth2 authentication between the client and Segment's endpoints
*/
oauthSettings?: OAuthSettings
}
export const validateSettings = (settings: AnalyticsSettings) => {
if (!settings.writeKey) {
throw new ValidationError('writeKey', 'writeKey is missing.')
}
}

View File

@@ -0,0 +1,3 @@
export * from './params'
export * from './segment-event'
export * from './plugin'

View File

@@ -0,0 +1,121 @@
import type {
GroupTraits,
UserTraits,
CoreExtraContext,
EventProperties,
IntegrationsOptions,
Timestamp,
} from '@segment/analytics-core'
export type { GroupTraits, UserTraits }
/**
* A dictionary of extra context to attach to the call.
* Note: context differs from traits because it is not attributes of the user itself.
*/
export interface ExtraContext extends CoreExtraContext {}
/**
* An ID associated with the user. Note: at least one of userId or anonymousId must be included.
**/
type IdentityOptions =
| { userId: string; anonymousId?: string | undefined }
| { userId?: string | undefined; anonymousId: string }
export type AliasParams = {
/* The new user id you want to associate with the user. */
userId: string
/* The previous id that the user was recognized by (this can be either a userId or an anonymousId). */
previousId: string
context?: ExtraContext | undefined
timestamp?: Timestamp | undefined
integrations?: IntegrationsOptions | undefined
/**
* Override the default messageId for the purposes of deduping events. Using a uuid library is strongly encouraged.
* @link https://segment.com/docs/partners/faqs/#does-segment-de-dupe-messages
*/
messageId?: string | undefined
}
export type GroupParams = {
groupId: string
/**
* Traits are pieces of information you know about a group.
* This interface represents reserved traits that Segment has standardized.
* @link https://segment.com/docs/connections/spec/group/#traits
*/
traits?: GroupTraits | undefined
context?: ExtraContext | undefined
timestamp?: Timestamp | undefined
integrations?: IntegrationsOptions | undefined
/**
* Override the default messageId for the purposes of deduping events. Using a uuid library is strongly encouraged.
* @link https://segment.com/docs/partners/faqs/#does-segment-de-dupe-messages
*/
messageId?: string | undefined
} & IdentityOptions
export type IdentifyParams = {
/**
* Traits are pieces of information you know about a group.
* This interface represents reserved traits that Segment has standardized.
* @link https://segment.com/docs/connections/spec/group/#traits
*/
traits?: UserTraits | undefined
context?: ExtraContext | undefined
timestamp?: Timestamp | undefined
integrations?: IntegrationsOptions | undefined
/**
* Override the default messageId for the purposes of deduping events. Using a uuid library is strongly encouraged.
* @link https://segment.com/docs/partners/faqs/#does-segment-de-dupe-messages
*/
messageId?: string | undefined
} & IdentityOptions
export type PageParams = {
/* The category of the page. Useful for cases like ecommerce where many pages might live under a single category. */
category?: string | undefined
/* The name of the page.*/
name?: string | undefined
/* A dictionary of properties of the page. */
properties?: EventProperties | undefined
timestamp?: Timestamp | undefined
context?: ExtraContext | undefined
integrations?: IntegrationsOptions | undefined
/**
* Override the default messageId for the purposes of deduping events. Using a uuid library is strongly encouraged.
* @link https://segment.com/docs/partners/faqs/#does-segment-de-dupe-messages
*/
messageId?: string | undefined
} & IdentityOptions
export type TrackParams = {
event: string
properties?: EventProperties | undefined
context?: ExtraContext | undefined
timestamp?: Timestamp | undefined
integrations?: IntegrationsOptions | undefined
/**
* Override the default messageId for the purposes of deduping events. Using a uuid library is strongly encouraged.
* @link https://segment.com/docs/partners/faqs/#does-segment-de-dupe-messages
*/
messageId?: string | undefined
} & IdentityOptions
export type FlushParams = {
/**
* Max time in milliseconds to wait until the resulting promise resolves.
*/
timeout?: number | undefined
/**
* If true, will prevent new events from entering the pipeline. Default: false
*/
close?: boolean | undefined
}
export type CloseAndFlushParams = {
/**
* Max time in milliseconds to wait until the resulting promise resolves.
*/
timeout?: FlushParams['timeout'] | undefined
}

View File

@@ -0,0 +1,5 @@
import type { CorePlugin } from '@segment/analytics-core'
import type { Analytics } from '../analytics-node'
import type { Context } from '../context'
export interface Plugin extends CorePlugin<Context, Analytics> {}

View File

@@ -0,0 +1,7 @@
import type { CoreSegmentEvent } from '@segment/analytics-core'
type SegmentEventType = 'track' | 'page' | 'identify' | 'alias' | 'screen'
export interface SegmentEvent extends CoreSegmentEvent {
type: SegmentEventType
}

View File

@@ -0,0 +1,2 @@
// This file is generated.
export const version = '2.3.0'

View File

@@ -0,0 +1,24 @@
export { Analytics } from './app/analytics-node'
export { Context } from './app/context'
export {
HTTPClient,
FetchHTTPClient,
HTTPFetchRequest,
HTTPResponse,
HTTPFetchFn,
HTTPClientRequest,
} from './lib/http-client'
export { OAuthSettings } from './lib/types'
export type {
Plugin,
GroupTraits,
UserTraits,
TrackParams,
IdentifyParams,
AliasParams,
GroupParams,
PageParams,
} from './app/types'
export type { AnalyticsSettings } from './app/settings'

5
node_modules/@segment/analytics-node/src/index.ts generated vendored Normal file
View File

@@ -0,0 +1,5 @@
export * from './index.common'
// export Analytics as both a named export and a default export (for backwards-compat. reasons)
import { Analytics } from './index.common'
export default Analytics

77
node_modules/@segment/analytics-node/src/lib/abort.ts generated vendored Normal file
View File

@@ -0,0 +1,77 @@
/**
* use non-native event emitter for the benefit of non-node runtimes like CF workers.
*/
import { Emitter } from '@segment/analytics-generic-utils'
import { detectRuntime } from './env'
/**
* adapted from: https://www.npmjs.com/package/node-abort-controller
*/
export class AbortSignal {
onabort: globalThis.AbortSignal['onabort'] = null
aborted = false
eventEmitter = new Emitter()
toString() {
return '[object AbortSignal]'
}
get [Symbol.toStringTag]() {
return 'AbortSignal'
}
removeEventListener(...args: Parameters<Emitter['off']>) {
this.eventEmitter.off(...args)
}
addEventListener(...args: Parameters<Emitter['on']>) {
this.eventEmitter.on(...args)
}
dispatchEvent(type: string) {
const event = { type, target: this }
const handlerName = `on${type}`
if (typeof (this as any)[handlerName] === 'function') {
;(this as any)[handlerName](event)
}
this.eventEmitter.emit(type, event)
}
}
/**
* This polyfill is only neccessary to support versions of node < 14.17.
* Can be removed once node 14 support is dropped.
*/
export class AbortController {
signal = new AbortSignal()
abort() {
if (this.signal.aborted) return
this.signal.aborted = true
this.signal.dispatchEvent('abort')
}
toString() {
return '[object AbortController]'
}
get [Symbol.toStringTag]() {
return 'AbortController'
}
}
/**
* @param timeoutMs - Set a request timeout, after which the request is cancelled.
*/
export const abortSignalAfterTimeout = (timeoutMs: number) => {
if (detectRuntime() === 'cloudflare-worker') {
return [] // TODO: this is broken in cloudflare workers, otherwise results in "A hanging Promise was canceled..." error.
}
const ac = new (globalThis.AbortController || AbortController)()
const timeoutId = setTimeout(() => {
ac.abort()
}, timeoutMs)
// Allow Node.js processes to exit early if only the timeout is running
timeoutId?.unref?.()
return [ac.signal, timeoutId] as const
}

View File

@@ -0,0 +1,8 @@
// eslint-disable-next-line import/no-nodejs-modules
import { Buffer } from 'buffer'
/**
* Base64 encoder that works in browser, worker, node runtimes.
*/
export const b64encode = (str: string): string => {
return Buffer.from(str).toString('base64')
}

View File

@@ -0,0 +1,11 @@
const stripTrailingSlash = (str: string) => str.replace(/\/$/, '')
/**
*
* @param host e.g. "http://foo.com"
* @param path e.g. "/bar"
* @returns "e.g." "http://foo.com/bar"
*/
export const tryCreateFormattedUrl = (host: string, path?: string) => {
return stripTrailingSlash(new URL(path || '', host).href)
}

45
node_modules/@segment/analytics-node/src/lib/env.ts generated vendored Normal file
View File

@@ -0,0 +1,45 @@
/* eslint-disable no-restricted-globals */
export type RuntimeEnv =
| 'node'
| 'browser'
| 'web-worker'
| 'cloudflare-worker'
| 'vercel-edge'
| 'unknown'
export const detectRuntime = (): RuntimeEnv => {
if (
typeof process === 'object' &&
process &&
typeof process.env === 'object' &&
process.env &&
typeof process.version === 'string'
) {
return 'node'
}
if (typeof window === 'object') {
return 'browser'
}
// @ts-ignore
if (typeof WebSocketPair !== 'undefined') {
return 'cloudflare-worker'
}
// @ts-ignore
if (typeof EdgeRuntime === 'string') {
return 'vercel-edge'
}
if (
// @ts-ignore
typeof WorkerGlobalScope !== 'undefined' &&
// @ts-ignore
typeof importScripts === 'function'
) {
return 'web-worker'
}
return 'unknown'
}

16
node_modules/@segment/analytics-node/src/lib/fetch.ts generated vendored Normal file
View File

@@ -0,0 +1,16 @@
import type { HTTPFetchFn } from './http-client'
export const fetch: HTTPFetchFn = async (...args) => {
if (globalThis.fetch) {
return globalThis.fetch(...args)
}
// This guard causes is important, as it causes dead-code elimination to be enabled inside this block.
// @ts-ignore
else if (typeof EdgeRuntime !== 'string') {
return (await import('node-fetch')).default(...args)
} else {
throw new Error(
'Invariant: an edge runtime that does not support fetch should not exist'
)
}
}

View File

@@ -0,0 +1,10 @@
import { uuid } from './uuid'
/**
* get a unique messageId with a very low chance of collisions
* using @lukeed/uuid/secure uses the node crypto module, which is the fastest
* @example "node-next-1668208232027-743be593-7789-4b74-8078-cbcc8894c586"
*/
export const createMessageId = (): string => {
return `node-next-${Date.now()}-${uuid()}`
}

View File

@@ -0,0 +1,106 @@
import { abortSignalAfterTimeout } from './abort'
import { fetch as defaultFetch } from './fetch'
/**
* This interface is meant to be compatible with different fetch implementations (node and browser).
* Using the ambient fetch type is not possible because the AbortSignal type is not compatible with node-fetch.
* @link https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API
*/
export interface HTTPFetchFn {
(url: string, requestInit: HTTPFetchRequest): Promise<HTTPResponse>
}
/**
* This interface is meant to be compatible with the Request interface.
* @link https://developer.mozilla.org/en-US/docs/Web/API/Request
*/
export interface HTTPFetchRequest {
headers: Record<string, string>
body: string
method: HTTPClientRequest['method']
signal: any // AbortSignal type does not play nicely with node-fetch
}
/**
* This interface is meant to be compatible with the Headers interface.
* @link https://developer.mozilla.org/en-US/docs/Web/API/Headers
*/
export interface HTTPHeaders {
get: (key: string) => string | null
has: (key: string) => boolean
entries: () => IterableIterator<[string, any]>
}
/**
* This interface is meant to very minimally conform to the Response interface.
* @link https://developer.mozilla.org/en-US/docs/Web/API/Response
*/
export interface HTTPResponse {
headers?: Record<string, any> | HTTPHeaders
text?: () => Promise<string>
status: number
statusText: string
}
/**
* This interface is meant to be a generic interface for making HTTP requests.
* While it may overlap with fetch's Request interface, it is not coupled to it.
*/
export interface HTTPClientRequest {
/**
* URL to be used for the request
* @example 'https://api.segment.io/v1/batch'
*/
url: string
/**
* HTTP method to be used for the request. This will always be a 'POST' request.
**/
method: 'POST'
/**
* Headers to be sent with the request
*/
headers: Record<string, string>
/**
* Data to be sent with the request
*/
body: string
/**
* Specifies the timeout (in milliseconds) for an HTTP client to get an HTTP response from the server
* @example 10000
*/
httpRequestTimeout: number
}
/**
* HTTP client interface for making requests
*/
export interface HTTPClient {
makeRequest(_options: HTTPClientRequest): Promise<HTTPResponse>
}
/**
* Default HTTP client implementation using fetch
*/
export class FetchHTTPClient implements HTTPClient {
private _fetch: HTTPFetchFn
constructor(fetchFn?: HTTPFetchFn) {
this._fetch = fetchFn ?? defaultFetch
}
async makeRequest(options: HTTPClientRequest): Promise<HTTPResponse> {
const [signal, timeoutId] = abortSignalAfterTimeout(
options.httpRequestTimeout
)
const requestInit = {
url: options.url,
method: options.method,
headers: options.headers,
body: options.body,
signal: signal,
}
return this._fetch(options.url, requestInit).finally(() =>
clearTimeout(timeoutId)
)
}
}

View File

@@ -0,0 +1,327 @@
import { uuid } from './uuid'
import { HTTPClient, HTTPClientRequest, HTTPResponse } from './http-client'
import { SignJWT, importPKCS8 } from 'jose'
import { backoff, sleep } from '@segment/analytics-core'
import { Emitter } from '@segment/analytics-generic-utils'
import type {
AccessToken,
OAuthSettings,
TokenManager as ITokenManager,
} from './types'
const isAccessToken = (thing: unknown): thing is AccessToken => {
return Boolean(
thing &&
typeof thing === 'object' &&
'access_token' in thing &&
'expires_in' in thing &&
typeof thing.access_token === 'string' &&
typeof thing.expires_in === 'number'
)
}
const isValidCustomResponse = (
response: HTTPResponse
): response is HTTPResponse & Required<Pick<HTTPResponse, 'text'>> => {
return typeof response.text === 'function'
}
function convertHeaders(
headers: HTTPResponse['headers']
): Record<string, string> {
const lowercaseHeaders: Record<string, string> = {}
if (!headers) return {}
if (isHeaders(headers)) {
for (const [name, value] of headers.entries()) {
lowercaseHeaders[name.toLowerCase()] = value
}
return lowercaseHeaders
}
for (const [name, value] of Object.entries(headers)) {
lowercaseHeaders[name.toLowerCase()] = value as string
}
return lowercaseHeaders
}
function isHeaders(thing: unknown): thing is HTTPResponse['headers'] {
if (
typeof thing === 'object' &&
thing !== null &&
'entries' in Object(thing) &&
typeof Object(thing).entries === 'function'
) {
return true
}
return false
}
export interface TokenManagerSettings extends OAuthSettings {
httpClient: HTTPClient
maxRetries: number
}
export class TokenManager implements ITokenManager {
private alg = 'RS256' as const
private grantType = 'client_credentials' as const
private clientAssertionType =
'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' as const
private clientId: string
private clientKey: string
private keyId: string
private scope: string
private authServer: string
private httpClient: HTTPClient
private maxRetries: number
private clockSkewInSeconds = 0
private accessToken?: AccessToken
private tokenEmitter = new Emitter<{
access_token: [{ token: AccessToken } | { error: unknown }]
}>()
private retryCount: number
private pollerTimer?: ReturnType<typeof setTimeout>
constructor(props: TokenManagerSettings) {
this.keyId = props.keyId
this.clientId = props.clientId
this.clientKey = props.clientKey
this.authServer = props.authServer ?? 'https://oauth2.segment.io'
this.scope = props.scope ?? 'tracking_api:write'
this.httpClient = props.httpClient
this.maxRetries = props.maxRetries
this.tokenEmitter.on('access_token', (event) => {
if ('token' in event) {
this.accessToken = event.token
}
})
this.retryCount = 0
}
stopPoller() {
clearTimeout(this.pollerTimer)
}
async pollerLoop() {
let timeUntilRefreshInMs = 25
let response: HTTPResponse
try {
response = await this.requestAccessToken()
} catch (err) {
// Error without a status code - likely networking, retry
return this.handleTransientError({ error: err })
}
if (!isValidCustomResponse(response)) {
return this.handleInvalidCustomResponse()
}
const headers = convertHeaders(response.headers)
if (headers['date']) {
this.updateClockSkew(Date.parse(headers['date']))
}
// Handle status codes!
if (response.status === 200) {
try {
const body = await response.text()
const token = JSON.parse(body)
if (!isAccessToken(token)) {
throw new Error(
'Response did not contain a valid access_token and expires_in'
)
}
// Success, we have a token!
token.expires_at = Math.round(Date.now() / 1000) + token.expires_in
this.tokenEmitter.emit('access_token', { token })
// Reset our failure count
this.retryCount = 0
// Refresh the token after half the expiry time passes
timeUntilRefreshInMs = (token.expires_in / 2) * 1000
return this.queueNextPoll(timeUntilRefreshInMs)
} catch (err) {
// Something went really wrong with the body, lets surface an error and try again?
return this.handleTransientError({ error: err, forceEmitError: true })
}
} else if (response.status === 429) {
// Rate limited, wait for the reset time
return await this.handleRateLimited(
response,
headers,
timeUntilRefreshInMs
)
} else if ([400, 401, 415].includes(response.status)) {
// Unrecoverable errors, stops the poller
return this.handleUnrecoverableErrors(response)
} else {
return this.handleTransientError({
error: new Error(`[${response.status}] ${response.statusText}`),
})
}
}
private handleTransientError({
error,
forceEmitError,
}: {
error: unknown
forceEmitError?: boolean
}) {
this.incrementRetries({ error, forceEmitError })
const timeUntilRefreshInMs = backoff({
attempt: this.retryCount,
minTimeout: 25,
maxTimeout: 1000,
})
this.queueNextPoll(timeUntilRefreshInMs)
}
private handleInvalidCustomResponse() {
this.tokenEmitter.emit('access_token', {
error: new Error('HTTPClient does not implement response.text method'),
})
}
private async handleRateLimited(
response: HTTPResponse,
headers: Record<string, string>,
timeUntilRefreshInMs: number
) {
this.incrementRetries({
error: new Error(`[${response.status}] ${response.statusText}`),
})
if (headers['x-ratelimit-reset']) {
const rateLimitResetTimestamp = parseInt(headers['x-ratelimit-reset'], 10)
if (isFinite(rateLimitResetTimestamp)) {
timeUntilRefreshInMs =
rateLimitResetTimestamp - Date.now() + this.clockSkewInSeconds * 1000
} else {
timeUntilRefreshInMs = 5 * 1000
}
// We want subsequent calls to get_token to be able to interrupt our
// Timeout when it's waiting for e.g. a long normal expiration, but
// not when we're waiting for a rate limit reset. Sleep instead.
await sleep(timeUntilRefreshInMs)
timeUntilRefreshInMs = 0
}
this.queueNextPoll(timeUntilRefreshInMs)
}
private handleUnrecoverableErrors(response: HTTPResponse) {
this.retryCount = 0
this.tokenEmitter.emit('access_token', {
error: new Error(`[${response.status}] ${response.statusText}`),
})
this.stopPoller()
}
private updateClockSkew(dateInMs: number) {
this.clockSkewInSeconds = (Date.now() - dateInMs) / 1000
}
private incrementRetries({
error,
forceEmitError,
}: {
error: unknown
forceEmitError?: boolean
}) {
this.retryCount++
if (forceEmitError || this.retryCount % this.maxRetries === 0) {
this.retryCount = 0
this.tokenEmitter.emit('access_token', { error: error })
}
}
private queueNextPoll(timeUntilRefreshInMs: number) {
this.pollerTimer = setTimeout(() => this.pollerLoop(), timeUntilRefreshInMs)
if (this.pollerTimer.unref) {
this.pollerTimer.unref()
}
}
/**
* Solely responsible for building the HTTP request and calling the token service.
*/
private async requestAccessToken(): Promise<HTTPResponse> {
// Set issued at time to 5 seconds in the past to account for clock skew
const ISSUED_AT_BUFFER_IN_SECONDS = 5
const MAX_EXPIRY_IN_SECONDS = 60
// Final expiry time takes into account the issued at time, so need to subtract IAT buffer
const EXPIRY_IN_SECONDS =
MAX_EXPIRY_IN_SECONDS - ISSUED_AT_BUFFER_IN_SECONDS
const jti = uuid()
const currentUTCInSeconds =
Math.round(Date.now() / 1000) - this.clockSkewInSeconds
const jwtBody = {
iss: this.clientId,
sub: this.clientId,
aud: this.authServer,
iat: currentUTCInSeconds - ISSUED_AT_BUFFER_IN_SECONDS,
exp: currentUTCInSeconds + EXPIRY_IN_SECONDS,
jti,
}
const key = await importPKCS8(this.clientKey, 'RS256')
const signedJwt = await new SignJWT(jwtBody)
.setProtectedHeader({ alg: this.alg, kid: this.keyId, typ: 'JWT' })
.sign(key)
const requestBody = `grant_type=${this.grantType}&client_assertion_type=${this.clientAssertionType}&client_assertion=${signedJwt}&scope=${this.scope}`
const accessTokenEndpoint = `${this.authServer}/token`
const requestOptions: HTTPClientRequest = {
method: 'POST',
url: accessTokenEndpoint,
body: requestBody,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
httpRequestTimeout: 10000,
}
return this.httpClient.makeRequest(requestOptions)
}
async getAccessToken(): Promise<AccessToken> {
// Use the cached token if it is still valid, otherwise wait for a new token.
if (this.isValidToken(this.accessToken)) {
return this.accessToken
}
// stop poller first in order to make sure that it's not sleeping if we need a token immediately
// Otherwise it could be hours before the expiration time passes normally
this.stopPoller()
// startPoller needs to be called somewhere, either lazily when a token is first requested, or at instantiation.
// Doing it lazily currently
this.pollerLoop().catch(() => {})
return new Promise((resolve, reject) => {
this.tokenEmitter.once('access_token', (event) => {
if ('token' in event) {
resolve(event.token)
} else {
reject(event.error)
}
})
})
}
clearToken() {
this.accessToken = undefined
}
isValidToken(token?: AccessToken): token is AccessToken {
return (
typeof token !== 'undefined' &&
token !== null &&
token.expires_in < Date.now() / 1000
)
}
}

58
node_modules/@segment/analytics-node/src/lib/types.ts generated vendored Normal file
View File

@@ -0,0 +1,58 @@
import { HTTPClient } from './http-client'
import { TokenManagerSettings } from './token-manager'
export interface OAuthSettings {
/**
* The OAuth App ID from Access Management under Workspace Settings in the Segment Dashboard.
*/
clientId: string
/**
* The private key that matches the public key set in the OAuth app in the Segment Dashboard.
*/
clientKey: string
/**
* The ID for the matching public key as given in the Segment Dashboard after it is uploaded.
*/
keyId: string
/**
* The Authorization server. Defaults to https://oauth2.segment.io
* If your TAPI endpoint is not https://api.segment.io you will need to set this value.
* e.g. https://oauth2.eu1.segmentapis.com/ for TAPI endpoint https://events.eu1.segmentapis.com/
*/
authServer?: string
/**
* The scope of permissions. Defaults to `tracking_api:write`.
* Must match a scope from the OAuth app settings in the Segment Dashboard.
*/
scope?: string
/**
* Custom number of retries before a recoverable error is reported.
* Defaults to the custom value set in the Analytics settings, or 3 if unset
*/
maxRetries?: number
/**
* Custom HTTP Client implementation.
* Defaults to the custom value set in the Analytics settings, or uses the default fetch client.
* Note: This would only be need to be set in a complex environment that may have different access
* rules for the TAPI and Auth endpoints.
*/
httpClient?: HTTPClient
}
export type AccessToken = {
access_token: string
expires_in: number
expires_at?: number
}
export interface TokenManager {
pollerLoop(): Promise<void>
stopPoller(): void
getAccessToken(): Promise<AccessToken>
clearToken(): void
isValidToken(token?: AccessToken): token is AccessToken
}
export interface TokenManagerConstructor {
new (settings: TokenManagerSettings): TokenManager
}

1
node_modules/@segment/analytics-node/src/lib/uuid.ts generated vendored Normal file
View File

@@ -0,0 +1 @@
export { v4 as uuid } from '@lukeed/uuid'

View File

@@ -0,0 +1,71 @@
import { uuid } from '../../lib/uuid'
import type { Context } from '../../app/context'
import { SegmentEvent } from '../../app/types'
const MAX_EVENT_SIZE_IN_KB = 32
const MAX_BATCH_SIZE_IN_KB = 480 // (500 KB is the limit, leaving some padding)
interface PendingItem {
resolver: (ctx: Context) => void
context: Context
}
export class ContextBatch {
public id = uuid()
private items: PendingItem[] = []
private sizeInBytes = 0
private maxEventCount: number
constructor(maxEventCount: number) {
this.maxEventCount = Math.max(1, maxEventCount)
}
public tryAdd(
item: PendingItem
): { success: true } | { success: false; message: string } {
if (this.length === this.maxEventCount)
return {
success: false,
message: `Event limit of ${this.maxEventCount} has been exceeded.`,
}
const eventSize = this.calculateSize(item.context)
if (eventSize > MAX_EVENT_SIZE_IN_KB * 1024) {
return {
success: false,
message: `Event exceeds maximum event size of ${MAX_EVENT_SIZE_IN_KB} KB`,
}
}
if (this.sizeInBytes + eventSize > MAX_BATCH_SIZE_IN_KB * 1024) {
return {
success: false,
message: `Event has caused batch size to exceed ${MAX_BATCH_SIZE_IN_KB} KB`,
}
}
this.items.push(item)
this.sizeInBytes += eventSize
return { success: true }
}
get length(): number {
return this.items.length
}
private calculateSize(ctx: Context): number {
return encodeURI(JSON.stringify(ctx.event)).split(/%..|i/).length
}
getEvents(): SegmentEvent[] {
const events = this.items.map(({ context }) => context.event)
return events
}
getContexts(): Context[] {
return this.items.map((item) => item.context)
}
resolveEvents(): void {
this.items.forEach(({ resolver, context }) => resolver(context))
}
}

View File

@@ -0,0 +1,66 @@
import { Publisher, PublisherProps } from './publisher'
import { version } from '../../generated/version'
import { detectRuntime } from '../../lib/env'
import { Plugin } from '../../app/types'
import { Context } from '../../app/context'
import { NodeEmitter } from '../../app/emitter'
function normalizeEvent(ctx: Context) {
ctx.updateEvent('context.library.name', '@segment/analytics-node')
ctx.updateEvent('context.library.version', version)
const runtime = detectRuntime()
if (runtime === 'node') {
// eslint-disable-next-line no-restricted-globals
ctx.updateEvent('_metadata.nodeVersion', process.version)
}
ctx.updateEvent('_metadata.jsRuntime', runtime)
}
type DefinedPluginFields =
| 'name'
| 'type'
| 'version'
| 'isLoaded'
| 'load'
| 'alias'
| 'group'
| 'identify'
| 'page'
| 'screen'
| 'track'
type SegmentNodePlugin = Plugin & Required<Pick<Plugin, DefinedPluginFields>>
export type ConfigureNodePluginProps = PublisherProps
export function createNodePlugin(publisher: Publisher): SegmentNodePlugin {
function action(ctx: Context): Promise<Context> {
normalizeEvent(ctx)
return publisher.enqueue(ctx)
}
return {
name: 'Segment.io',
type: 'destination',
version: '1.0.0',
isLoaded: () => true,
load: () => Promise.resolve(),
alias: action,
group: action,
identify: action,
page: action,
screen: action,
track: action,
}
}
export const createConfiguredNodePlugin = (
props: ConfigureNodePluginProps,
emitter: NodeEmitter
) => {
const publisher = new Publisher(props, emitter)
return {
publisher: publisher,
plugin: createNodePlugin(publisher),
}
}

View File

@@ -0,0 +1,331 @@
import { backoff } from '@segment/analytics-core'
import type { Context } from '../../app/context'
import { tryCreateFormattedUrl } from '../../lib/create-url'
import { createDeferred } from '@segment/analytics-generic-utils'
import { ContextBatch } from './context-batch'
import { NodeEmitter } from '../../app/emitter'
import { HTTPClient, HTTPClientRequest } from '../../lib/http-client'
import { OAuthSettings } from '../../lib/types'
import { TokenManager } from '../../lib/token-manager'
function sleep(timeoutInMs: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, timeoutInMs))
}
function noop() {}
interface PendingItem {
resolver: (ctx: Context) => void
context: Context
}
export interface PublisherProps {
host?: string
path?: string
flushInterval: number
flushAt: number
maxRetries: number
writeKey: string
httpRequestTimeout?: number
disable?: boolean
httpClient: HTTPClient
oauthSettings?: OAuthSettings
}
/**
* The Publisher is responsible for batching events and sending them to the Segment API.
*/
export class Publisher {
private pendingFlushTimeout?: ReturnType<typeof setTimeout>
private _batch?: ContextBatch
private _flushInterval: number
private _flushAt: number
private _maxRetries: number
private _url: string
private _flushPendingItemsCount?: number
private _httpRequestTimeout: number
private _emitter: NodeEmitter
private _disable: boolean
private _httpClient: HTTPClient
private _writeKey: string
private _tokenManager: TokenManager | undefined
constructor(
{
host,
path,
maxRetries,
flushAt,
flushInterval,
writeKey,
httpRequestTimeout,
httpClient,
disable,
oauthSettings,
}: PublisherProps,
emitter: NodeEmitter
) {
this._emitter = emitter
this._maxRetries = maxRetries
this._flushAt = Math.max(flushAt, 1)
this._flushInterval = flushInterval
this._url = tryCreateFormattedUrl(
host ?? 'https://api.segment.io',
path ?? '/v1/batch'
)
this._httpRequestTimeout = httpRequestTimeout ?? 10000
this._disable = Boolean(disable)
this._httpClient = httpClient
this._writeKey = writeKey
if (oauthSettings) {
this._tokenManager = new TokenManager({
...oauthSettings,
httpClient: oauthSettings.httpClient ?? httpClient,
maxRetries: oauthSettings.maxRetries ?? maxRetries,
})
}
}
private createBatch(): ContextBatch {
this.pendingFlushTimeout && clearTimeout(this.pendingFlushTimeout)
const batch = new ContextBatch(this._flushAt)
this._batch = batch
this.pendingFlushTimeout = setTimeout(() => {
if (batch === this._batch) {
this._batch = undefined
}
this.pendingFlushTimeout = undefined
if (batch.length) {
this.send(batch).catch(noop)
}
}, this._flushInterval)
return batch
}
private clearBatch() {
this.pendingFlushTimeout && clearTimeout(this.pendingFlushTimeout)
this._batch = undefined
}
flush(pendingItemsCount: number): void {
if (!pendingItemsCount) {
// if number of pending items is 0, there will never be anything else entering the batch, since the app is closed.
if (this._tokenManager) {
this._tokenManager.stopPoller()
}
return
}
this._flushPendingItemsCount = pendingItemsCount
// if batch is empty, there's nothing to flush, and when things come in, enqueue will handle them.
if (!this._batch) return
// the number of globally pending items will always be larger or the same as batch size.
// Any mismatch is because some globally pending items are in plugins.
const isExpectingNoMoreItems = this._batch.length === pendingItemsCount
if (isExpectingNoMoreItems) {
this.send(this._batch)
.catch(noop)
.finally(() => {
// stop poller so program can exit ().
if (this._tokenManager) {
this._tokenManager.stopPoller()
}
})
this.clearBatch()
}
}
/**
* Enqueues the context for future delivery.
* @param ctx - Context containing a Segment event.
* @returns a promise that resolves with the context after the event has been delivered.
*/
enqueue(ctx: Context): Promise<Context> {
const batch = this._batch ?? this.createBatch()
const { promise: ctxPromise, resolve } = createDeferred<Context>()
const pendingItem: PendingItem = {
context: ctx,
resolver: resolve,
}
/*
The following logic ensures that a batch is never orphaned,
and is always sent before a new batch is created.
Add an event to the existing batch.
Success: Check if batch is full or no more items are expected to come in (i.e. closing). If so, send batch.
Failure: Assume event is too big to fit in current batch - send existing batch.
Add an event to the new batch.
Success: Check if batch is full and send if it is.
Failure: Event exceeds maximum size (it will never fit), fail the event.
*/
const addStatus = batch.tryAdd(pendingItem)
if (addStatus.success) {
const isExpectingNoMoreItems =
batch.length === this._flushPendingItemsCount
const isFull = batch.length === this._flushAt
if (isFull || isExpectingNoMoreItems) {
this.send(batch).catch(noop)
this.clearBatch()
}
return ctxPromise
}
// If the new item causes the maximimum event size to be exceeded, send the current batch and create a new one.
if (batch.length) {
this.send(batch).catch(noop)
this.clearBatch()
}
const fallbackBatch = this.createBatch()
const fbAddStatus = fallbackBatch.tryAdd(pendingItem)
if (fbAddStatus.success) {
const isExpectingNoMoreItems =
fallbackBatch.length === this._flushPendingItemsCount
if (isExpectingNoMoreItems) {
this.send(fallbackBatch).catch(noop)
this.clearBatch()
}
return ctxPromise
} else {
// this should only occur if max event size is exceeded
ctx.setFailedDelivery({
reason: new Error(fbAddStatus.message),
})
return Promise.resolve(ctx)
}
}
private async send(batch: ContextBatch) {
if (this._flushPendingItemsCount) {
this._flushPendingItemsCount -= batch.length
}
const events = batch.getEvents()
const maxAttempts = this._maxRetries + 1
let currentAttempt = 0
while (currentAttempt < maxAttempts) {
currentAttempt++
let requestedRetryTimeout: number | undefined
let failureReason: unknown
try {
if (this._disable) {
return batch.resolveEvents()
}
let authString = undefined
if (this._tokenManager) {
const token = await this._tokenManager.getAccessToken()
if (token && token.access_token) {
authString = `Bearer ${token.access_token}`
}
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'User-Agent': 'analytics-node-next/latest',
...(authString ? { Authorization: authString } : {}),
}
const request: HTTPClientRequest = {
url: this._url,
method: 'POST',
headers: headers,
body: JSON.stringify({
batch: events,
writeKey: this._writeKey,
sentAt: new Date(),
}),
httpRequestTimeout: this._httpRequestTimeout,
}
this._emitter.emit('http_request', {
body: request.body,
method: request.method,
url: request.url,
headers: request.headers,
})
const response = await this._httpClient.makeRequest(request)
if (response.status >= 200 && response.status < 300) {
// Successfully sent events, so exit!
batch.resolveEvents()
return
} else if (
this._tokenManager &&
(response.status === 400 ||
response.status === 401 ||
response.status === 403)
) {
// Retry with a new OAuth token if we have OAuth data
this._tokenManager.clearToken()
failureReason = new Error(
`[${response.status}] ${response.statusText}`
)
} else if (response.status === 400) {
// https://segment.com/docs/connections/sources/catalog/libraries/server/http-api/#max-request-size
// Request either malformed or size exceeded - don't retry.
resolveFailedBatch(
batch,
new Error(`[${response.status}] ${response.statusText}`)
)
return
} else if (response.status === 429) {
// Rate limited, wait for the reset time
if (response.headers && 'x-ratelimit-reset' in response.headers) {
const rateLimitResetTimestamp = parseInt(
response.headers['x-ratelimit-reset'],
10
)
if (isFinite(rateLimitResetTimestamp)) {
requestedRetryTimeout = rateLimitResetTimestamp - Date.now()
}
}
failureReason = new Error(
`[${response.status}] ${response.statusText}`
)
} else {
// Treat other errors as transient and retry.
failureReason = new Error(
`[${response.status}] ${response.statusText}`
)
}
} catch (err) {
// Network errors get thrown, retry them.
failureReason = err
}
// Final attempt failed, update context and resolve events.
if (currentAttempt === maxAttempts) {
resolveFailedBatch(batch, failureReason)
return
}
// Retry after attempt-based backoff.
await sleep(
requestedRetryTimeout
? requestedRetryTimeout
: backoff({
attempt: currentAttempt,
minTimeout: 25,
maxTimeout: 1000,
})
)
}
}
}
function resolveFailedBatch(batch: ContextBatch, reason: unknown) {
batch.getContexts().forEach((ctx) => ctx.setFailedDelivery({ reason }))
batch.resolveEvents()
}