diff --git a/package.json b/package.json index f92a07a..5acad97 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "test:e2e": "jest --config ./test/jest-e2e.json" }, "dependencies": { + "@amplitude/analytics-node": "^1.5.14", "@aws-sdk/client-s3": "^3.820.0", "@aws-sdk/s3-request-presigner": "^3.820.0", "@eslint/plugin-kit": "^0.3.5", @@ -40,7 +41,7 @@ "@types/passport-jwt": "^4.0.1", "axios": "^1.11.0", "class-transformer": "^0.5.1", - "class-validator": "^0.14.1", + "class-validator": "^0.14.2", "cookie-parser": "^1.4.7", "express": "^5.1.0", "mysql2": "^3.14.0", diff --git a/src/analytics/analytics.module.ts b/src/analytics/analytics.module.ts new file mode 100644 index 0000000..65377c2 --- /dev/null +++ b/src/analytics/analytics.module.ts @@ -0,0 +1,35 @@ +import { init } from '@amplitude/analytics-node'; +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { AnalyticsService } from './infrastructure/analytics.service'; +import { CqrsModule } from '@nestjs/cqrs'; +import { AuthCreatedEventHandler, LoginSucceededEventHandler } from './application/auth-event.handler'; +import { ScrapAddedEventHandler, ScrapDeletedEventHandler } from './application/scrap-event.handler'; + +export const AMPLITUDE_CLIENT = Symbol('AMPLITUDE_CLIENT'); + +const eventHandlers = [ + AuthCreatedEventHandler, + LoginSucceededEventHandler, + ScrapAddedEventHandler, + ScrapDeletedEventHandler, +]; + +@Module({ + imports: [ConfigModule, CqrsModule], + providers: [ + { + provide: 'AMPLITUDE_CLIENT', + useFactory: (configService: ConfigService) => { + return init(configService.getOrThrow('amplitude.apiKey'), { + flushIntervalMillis: 1000, + }); + }, + inject: [ConfigService], + }, + AnalyticsService, + ...eventHandlers, + ], + exports: [AnalyticsService], +}) +export class AnalyticsModule {} diff --git a/src/analytics/application/auth-event.handler.ts b/src/analytics/application/auth-event.handler.ts new file mode 100644 index 0000000..a79e984 --- /dev/null +++ b/src/analytics/application/auth-event.handler.ts @@ -0,0 +1,25 @@ +import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; +import { AuthCreatedEvent } from 'src/auth/domain/event/auth-created.event'; +import { AnalyticsService } from '../infrastructure/analytics.service'; +import { LoginSucceededEvent } from 'src/auth/domain/event/login-succeeded.event'; + +@EventsHandler(AuthCreatedEvent) +export class AuthCreatedEventHandler implements IEventHandler { + constructor(private readonly analyticsService: AnalyticsService) {} + + handle(event: AuthCreatedEvent) { + this.analyticsService.trackEvent(event.userId.value, 'Signed Up', { + email: event.email, + role: event.role, + }); + } +} + +@EventsHandler(LoginSucceededEvent) +export class LoginSucceededEventHandler implements IEventHandler { + constructor(private readonly analyticsService: AnalyticsService) {} + + handle(event: LoginSucceededEvent) { + this.analyticsService.trackEvent(event.userId.value, 'Logged In', {}); + } +} diff --git a/src/analytics/application/scrap-event.handler.ts b/src/analytics/application/scrap-event.handler.ts new file mode 100644 index 0000000..53dc9d4 --- /dev/null +++ b/src/analytics/application/scrap-event.handler.ts @@ -0,0 +1,26 @@ +import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; +import { ScrapAddedEvent } from 'src/scrap/command/domain/event/scrap-added.event'; +import { ScrapDeletedEvent } from 'src/scrap/command/domain/event/scrap-deleted.event'; +import { AnalyticsService } from '../infrastructure/analytics.service'; + +@EventsHandler(ScrapAddedEvent) +export class ScrapAddedEventHandler implements IEventHandler { + constructor(private readonly analyticsService: AnalyticsService) {} + + handle(event: ScrapAddedEvent) { + this.analyticsService.trackEvent(event.userId, 'Article Scrapped', { + articleId: event.articleId, + }); + } +} + +@EventsHandler(ScrapDeletedEvent) +export class ScrapDeletedEventHandler implements IEventHandler { + constructor(private readonly analyticsService: AnalyticsService) {} + + handle(event: ScrapDeletedEvent) { + this.analyticsService.trackEvent(event.userId, 'Article Unscrapped', { + articleId: event.articleId, + }); + } +} diff --git a/src/analytics/infrastructure/analytics.service.ts b/src/analytics/infrastructure/analytics.service.ts new file mode 100644 index 0000000..a6de819 --- /dev/null +++ b/src/analytics/infrastructure/analytics.service.ts @@ -0,0 +1,21 @@ +import { track } from '@amplitude/analytics-node'; +import { Injectable, Logger } from '@nestjs/common'; + +@Injectable() +export class AnalyticsService { + private readonly logger = new Logger(AnalyticsService.name); + + trackEvent(userId: string, eventType: string, eventProperties?: Record) { + try { + track({ + user_id: userId, + event_type: eventType, + event_properties: eventProperties, + }); + } catch (e: unknown) { + if (e instanceof Error) { + this.logger.error(`Amplitude tracking failed ${e.message}`); + } + } + } +} diff --git a/src/app.module.ts b/src/app.module.ts index 8b83b0a..cee699e 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -12,6 +12,7 @@ import { MediaModule } from './media/media.module'; import { ScrapModule } from './scrap/scrap.module'; import mikroOrmConfig from './shared/config/mikro-orm.config'; import config from 'src/shared/config/configuration'; +import { AnalyticsModule } from './analytics/analytics.module'; @Module({ imports: [ @@ -20,6 +21,7 @@ import config from 'src/shared/config/configuration'; isGlobal: true, load: [config], }), + AnalyticsModule, AuthModule, ArticleModule, UserModule, diff --git a/src/auth/application/oauth-login/oauth-login.handler.ts b/src/auth/application/oauth-login/oauth-login.handler.ts index 0c7d49f..bd5fc74 100644 --- a/src/auth/application/oauth-login/oauth-login.handler.ts +++ b/src/auth/application/oauth-login/oauth-login.handler.ts @@ -48,11 +48,14 @@ export class OAuthLoginUseCase { // 유저 생성 및 정보 가져오기 private async findOrCreateAuth(oauthId: string, provider: OAuthProviderType, email: string): Promise { const existingAuth = await this.authRepository.findByOAuthIdandProvider(oauthId, provider); - if (existingAuth) return existingAuth; + if (existingAuth) { + this.eventBus.publish(existingAuth); + return existingAuth; + } const userId = Identifier.create(); - await this.eventBus.publish(new AuthCreatedEvent(userId, email, Role.GENERAL)); + await this.eventBus.publish(new AuthCreatedEvent(userId, email, Role.GENERAL, provider)); const auth = Auth.create({ id: Identifier.create(), @@ -67,6 +70,8 @@ export class OAuthLoginUseCase { await this.authRepository.save(auth); + await this.eventBus.publishAll(auth.pullDomainEvents()); + return auth; } diff --git a/src/auth/application/renew-token/renew-token.use-case.ts b/src/auth/application/renew-token/renew-token.use-case.ts index 9795989..54b4905 100644 --- a/src/auth/application/renew-token/renew-token.use-case.ts +++ b/src/auth/application/renew-token/renew-token.use-case.ts @@ -6,6 +6,7 @@ import { RenewTokenRequestDto } from './dto/renew-token.request.dto'; import { RenewTokenResponseDto } from './dto/renew-token.response.dto'; import { CustomException } from 'src/shared/exception/custom-exception'; import { CustomExceptionCode } from 'src/shared/exception/custom-exception-code'; +import { EventBus } from '@nestjs/cqrs'; @Injectable() export class RenewTokenUseCase { @@ -13,6 +14,7 @@ export class RenewTokenUseCase { private readonly jwtProvider: JwtProvider, @Inject(AUTH_REPOSITORY) private readonly authRepository: AuthRepository, + private readonly eventBus: EventBus, ) {} async execute(reqeustDto: RenewTokenRequestDto): Promise { @@ -26,6 +28,8 @@ export class RenewTokenUseCase { auth.updateRefreshToken(newJti, new Date()); await this.authRepository.update(auth); + await this.eventBus.publishAll(auth.pullDomainEvents()); + return { accessToken, refreshToken }; } } diff --git a/src/auth/domain/entity/auth.ts b/src/auth/domain/entity/auth.ts index 458f177..6f46780 100644 --- a/src/auth/domain/entity/auth.ts +++ b/src/auth/domain/entity/auth.ts @@ -4,6 +4,7 @@ import { OAuthProviderType } from '../value-object/oauth-provider.enum'; import { AggregateRoot } from 'src/shared/core/domain/base.aggregate'; import { CustomException } from 'src/shared/exception/custom-exception'; import { CustomExceptionCode } from 'src/shared/exception/custom-exception-code'; +import { LoginSucceededEvent } from '../event/login-succeeded.event'; export interface AuthProps extends BaseEntityProps { oauthId: string; @@ -21,6 +22,7 @@ export class Auth extends AggregateRoot { public static create(props: AuthProps): Auth { const auth = new Auth(props); auth.validate(); + auth.addDomainEvent(new LoginSucceededEvent(auth.userId, auth.provider)); return auth; } diff --git a/src/auth/domain/event/auth-created.event.ts b/src/auth/domain/event/auth-created.event.ts index 5d483fe..f891d5b 100644 --- a/src/auth/domain/event/auth-created.event.ts +++ b/src/auth/domain/event/auth-created.event.ts @@ -1,6 +1,7 @@ import { BaseDomainEvent } from 'src/shared/core/domain/base.domain-event'; import { Identifier } from 'src/shared/core/domain/identifier'; import { Role } from 'src/user/command/domain/value-object/role.enum'; +import { OAuthProviderType } from '../value-object/oauth-provider.enum'; export class AuthCreatedEvent implements BaseDomainEvent { readonly timesstamp: Date; @@ -9,6 +10,7 @@ export class AuthCreatedEvent implements BaseDomainEvent { public readonly userId: Identifier, public readonly email: string, public readonly role: Role, + public readonly provider: OAuthProviderType, ) { this.timesstamp = new Date(); } diff --git a/src/auth/domain/event/login-succeeded.event.ts b/src/auth/domain/event/login-succeeded.event.ts new file mode 100644 index 0000000..fd3d01d --- /dev/null +++ b/src/auth/domain/event/login-succeeded.event.ts @@ -0,0 +1,14 @@ +import { BaseDomainEvent } from 'src/shared/core/domain/base.domain-event'; +import { Identifier } from 'src/shared/core/domain/identifier'; +import { OAuthProviderType } from '../value-object/oauth-provider.enum'; + +export class LoginSucceededEvent implements BaseDomainEvent { + readonly timesstamp: Date; + + constructor( + public readonly userId: Identifier, + public readonly provider: OAuthProviderType, + ) { + this.timesstamp = new Date(); + } +} diff --git a/src/scrap/command/application/add-scrap/add-scrap.handler.ts b/src/scrap/command/application/add-scrap/add-scrap.handler.ts index 8c0f736..2148660 100644 --- a/src/scrap/command/application/add-scrap/add-scrap.handler.ts +++ b/src/scrap/command/application/add-scrap/add-scrap.handler.ts @@ -2,14 +2,15 @@ import { Inject, Injectable } from '@nestjs/common'; import { SCRAP_COMMAND_REPOSITORY, ScrapCommandRepository } from '../../domain/scrap.command.repository'; import { Scrap } from '../../domain/scrap'; import { Identifier } from 'src/shared/core/domain/identifier'; -import { - ARTICLE_COMMAND_REPOSITORY, - ArticleCommandRepository, -} from 'src/article/command/domain/article.command.repository'; import { CommandHandler, EventBus } from '@nestjs/cqrs'; import { AddScrapCommand } from './add-scrap.command'; import { CustomException } from 'src/shared/exception/custom-exception'; import { CustomExceptionCode } from 'src/shared/exception/custom-exception-code'; +import { + ARTICLE_QUERY_REPOSITORY, + ArticleQueryRepository, +} from 'src/article/query/domain/repository/article.query.repository'; +import { ScrapAddedEvent } from '../../domain/event/scrap-added.event'; @Injectable() @CommandHandler(AddScrapCommand) @@ -18,8 +19,8 @@ export class AddScrapHandler { @Inject(SCRAP_COMMAND_REPOSITORY) private readonly scrapCommandRepository: ScrapCommandRepository, private readonly eventBus: EventBus, - @Inject(ARTICLE_COMMAND_REPOSITORY) - private readonly articleCommandRepository: ArticleCommandRepository, + @Inject(ARTICLE_QUERY_REPOSITORY) + private readonly articleQueryRepository: ArticleQueryRepository, ) {} async execute(command: AddScrapCommand): Promise { @@ -28,7 +29,7 @@ export class AddScrapHandler { const existingScrap = await this.scrapCommandRepository.findByArticleIdAndUserId(articleId, userId); if (existingScrap) throw new CustomException(CustomExceptionCode.SCRAP_ALREADY_EXISTS); - const article = await this.articleCommandRepository.findById(articleId); + const article = await this.articleQueryRepository.findById(articleId); if (!article) throw new CustomException(CustomExceptionCode.ARTICLE_NOT_FOUND); const scrap = Scrap.create({ @@ -41,10 +42,6 @@ export class AddScrapHandler { await this.scrapCommandRepository.save(scrap); - const events = scrap.pullDomainEvents(); - - for (const event of events) { - await this.eventBus.publish(event); - } + this.eventBus.publish(new ScrapAddedEvent(scrap.userId.value, scrap.articleId.value, article.tags)); } } diff --git a/src/scrap/command/application/delete-scrap/delete-scrap.handler.ts b/src/scrap/command/application/delete-scrap/delete-scrap.handler.ts index cd3768d..da7d339 100644 --- a/src/scrap/command/application/delete-scrap/delete-scrap.handler.ts +++ b/src/scrap/command/application/delete-scrap/delete-scrap.handler.ts @@ -4,6 +4,10 @@ import { CommandHandler, EventBus } from '@nestjs/cqrs'; import { DeleteScrapCommand } from './delete-scrap.command'; import { CustomException } from 'src/shared/exception/custom-exception'; import { CustomExceptionCode } from 'src/shared/exception/custom-exception-code'; +import { + ARTICLE_QUERY_REPOSITORY, + ArticleQueryRepository, +} from 'src/article/query/domain/repository/article.query.repository'; @Injectable() @CommandHandler(DeleteScrapCommand) @@ -12,6 +16,8 @@ export class DeleteScrapHandler { private readonly eventBus: EventBus, @Inject(SCRAP_COMMAND_REPOSITORY) private readonly scrapCommandRepository: ScrapCommandRepository, + @Inject(ARTICLE_QUERY_REPOSITORY) + private readonly articleQueryRepository: ArticleQueryRepository, ) {} async execute(command: DeleteScrapCommand) { @@ -19,9 +25,13 @@ export class DeleteScrapHandler { const scrap = await this.scrapCommandRepository.findByArticleIdAndUserId(articleId, userId); if (!scrap) throw new CustomException(CustomExceptionCode.SCRAP_NOT_FOUND); + const article = await this.articleQueryRepository.findById(articleId); + if (!article) throw new CustomException(CustomExceptionCode.ARTICLE_NOT_FOUND); await this.scrapCommandRepository.deleteByArticleIdAndUserId(articleId, userId); + scrap.delete(article.tags); + const events = scrap.pullDomainEvents(); await this.eventBus.publishAll(events); } diff --git a/src/scrap/command/domain/event/scrap-added.event.ts b/src/scrap/command/domain/event/scrap-added.event.ts index d88d481..4afbad9 100644 --- a/src/scrap/command/domain/event/scrap-added.event.ts +++ b/src/scrap/command/domain/event/scrap-added.event.ts @@ -3,7 +3,11 @@ import { BaseDomainEvent } from 'src/shared/core/domain/base.domain-event'; export class ScrapAddedEvent implements BaseDomainEvent { readonly timesstamp: Date; - constructor(public readonly articleId: string) { + constructor( + public readonly userId: string, + public readonly articleId: string, + public readonly tags: string[], + ) { this.timesstamp = new Date(); } } diff --git a/src/scrap/command/domain/event/scrap-deleted.event.ts b/src/scrap/command/domain/event/scrap-deleted.event.ts index 0a82121..d246ad4 100644 --- a/src/scrap/command/domain/event/scrap-deleted.event.ts +++ b/src/scrap/command/domain/event/scrap-deleted.event.ts @@ -3,7 +3,11 @@ import { BaseDomainEvent } from 'src/shared/core/domain/base.domain-event'; export class ScrapDeletedEvent implements BaseDomainEvent { readonly timesstamp: Date; - constructor(public readonly articleId: string) { + constructor( + public readonly userId: string, + public readonly articleId: string, + public readonly tags: string[], + ) { this.timesstamp = new Date(); } } diff --git a/src/scrap/command/domain/scrap.ts b/src/scrap/command/domain/scrap.ts index 5927e8b..7babe2f 100644 --- a/src/scrap/command/domain/scrap.ts +++ b/src/scrap/command/domain/scrap.ts @@ -1,7 +1,6 @@ import { AggregateRoot } from 'src/shared/core/domain/base.aggregate'; import { BaseEntityProps } from 'src/shared/core/domain/base.entity'; import { Identifier } from 'src/shared/core/domain/identifier'; -import { ScrapAddedEvent } from './event/scrap-added.event'; import { ScrapDeletedEvent } from './event/scrap-deleted.event'; import { CustomException } from 'src/shared/exception/custom-exception'; import { CustomExceptionCode } from 'src/shared/exception/custom-exception-code'; @@ -20,8 +19,6 @@ export class Scrap extends AggregateRoot { const scrap = new Scrap(props); scrap.validate(); - scrap.addDomainEvent(new ScrapAddedEvent(props.articleId.value)); - return scrap; } @@ -31,8 +28,8 @@ export class Scrap extends AggregateRoot { } } - public delete(): void { - this.addDomainEvent(new ScrapDeletedEvent(this.articleId.value)); + public delete(tags: string[]): void { + this.addDomainEvent(new ScrapDeletedEvent(this.userId.value, this.articleId.value, tags)); } get articleId(): Identifier { diff --git a/src/scrap/command/scrap.command.module.ts b/src/scrap/command/scrap.command.module.ts index 6c7ace0..2118a45 100644 --- a/src/scrap/command/scrap.command.module.ts +++ b/src/scrap/command/scrap.command.module.ts @@ -5,17 +5,17 @@ import { ScrapEntity } from './infrastructure/scrap.entity'; import { DeleteScrapHandler } from './application/delete-scrap/delete-scrap.handler'; import { SCRAP_COMMAND_REPOSITORY } from './domain/scrap.command.repository'; import { ScrapCommandRepositoryImpl } from './infrastructure/scrap.command.repository.impl'; -import { ArticleCommandModule } from 'src/article/command/article.command.module'; -import { ARTICLE_COMMAND_REPOSITORY } from 'src/article/command/domain/article.command.repository'; -import { ArticleCommandRepositoryImpl } from 'src/article/command/infrastructure/article.command.repository.impl'; import { ArticleEntity } from 'src/article/command/infrastructure/article.entity'; import { AddScrapHandler } from './application/add-scrap/add-scrap.handler'; import { CqrsModule } from '@nestjs/cqrs'; +import { ARTICLE_QUERY_REPOSITORY } from 'src/article/query/domain/repository/article.query.repository'; +import { ArticleQueryRepositoryImpl } from 'src/article/query/infrastructure/article.query.repository.impl'; +import { ArticleQueryModule } from 'src/article/query/article.query.module'; const usecases = [AddScrapHandler, DeleteScrapHandler]; @Module({ - imports: [MikroOrmModule.forFeature([ScrapEntity, ArticleEntity]), ArticleCommandModule, CqrsModule], + imports: [MikroOrmModule.forFeature([ScrapEntity, ArticleEntity]), ArticleQueryModule, CqrsModule], providers: [ ...usecases, { @@ -23,8 +23,8 @@ const usecases = [AddScrapHandler, DeleteScrapHandler]; useClass: ScrapCommandRepositoryImpl, }, { - provide: ARTICLE_COMMAND_REPOSITORY, - useClass: ArticleCommandRepositoryImpl, + provide: ARTICLE_QUERY_REPOSITORY, + useClass: ArticleQueryRepositoryImpl, }, ], controllers: [ScrapCommandController], diff --git a/src/shared/config/configuration.ts b/src/shared/config/configuration.ts index 9bdff1c..7521b32 100644 --- a/src/shared/config/configuration.ts +++ b/src/shared/config/configuration.ts @@ -41,4 +41,9 @@ export default () => ({ url: process.env.FRONTEND_URL, loginRedirectPath: process.env.FRONTEND_LOGIN_REDIRECT_PATH, }, + + // Amplitude + amplitude: { + apiKey: process.env.AMPLITUDE_API_KEY, + }, }); diff --git a/yarn.lock b/yarn.lock index dcf5b4a..b79170a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,6 +2,27 @@ # yarn lockfile v1 +"@amplitude/analytics-connector@^1.6.4": + version "1.6.4" + resolved "https://registry.yarnpkg.com/@amplitude/analytics-connector/-/analytics-connector-1.6.4.tgz#8a811ff5c8ee46bdfea0e8f61c7578769b5778ed" + integrity sha512-SpIv0IQMNIq6SH3UqFGiaZyGSc7PBZwRdq7lvP0pBxW8i4Ny+8zwI0pV+VMfMHQwWY3wdIbWw5WQphNjpdq1/Q== + +"@amplitude/analytics-core@^2.26.2": + version "2.26.2" + resolved "https://registry.yarnpkg.com/@amplitude/analytics-core/-/analytics-core-2.26.2.tgz#e156e7815760d3a214d9ec7fa87b79f717533049" + integrity sha512-XIOzNiUCxzJwKuoK+N8rVjl0OlrfTszM+C9GyFxOYwn1zgZZEYCq0AqX1OIpy+vl+Bx3mLKZbRzxTl3eX46hLQ== + dependencies: + "@amplitude/analytics-connector" "^1.6.4" + tslib "^2.4.1" + +"@amplitude/analytics-node@^1.5.14": + version "1.5.14" + resolved "https://registry.yarnpkg.com/@amplitude/analytics-node/-/analytics-node-1.5.14.tgz#67a444030fa29831407b3b149a311f6d98f42264" + integrity sha512-vNby1oxqOuFYvvxWTJJ+DH4byhuzwJO+qerZQrM/D/ZgDWK11TFu8FxuxMK39BHaYpaEo5vpdSGV/5gXRaDeNQ== + dependencies: + "@amplitude/analytics-core" "^2.26.2" + tslib "^2.4.1" + "@angular-devkit/core@19.2.15": version "19.2.15" resolved "https://registry.yarnpkg.com/@angular-devkit/core/-/core-19.2.15.tgz#35af566f9c69d3eca9c183936ee8527d9725a006" @@ -3594,7 +3615,7 @@ class-transformer@^0.5.1: resolved "https://registry.yarnpkg.com/class-transformer/-/class-transformer-0.5.1.tgz#24147d5dffd2a6cea930a3250a677addf96ab336" integrity sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw== -class-validator@^0.14.1: +class-validator@^0.14.2: version "0.14.2" resolved "https://registry.yarnpkg.com/class-validator/-/class-validator-0.14.2.tgz#a3de95edd26b703e89c151a2023d3c115030340d" integrity sha512-3kMVRF2io8N8pY1IFIXlho9r8IPUUIfHe2hYVtiebvAzU2XeQFXTv+XI4WX+TnXmtwXMDcjngcpkiPM0O9PvLw== @@ -7065,7 +7086,7 @@ tsconfig-paths@4.2.0, tsconfig-paths@^4.1.2, tsconfig-paths@^4.2.0: minimist "^1.2.6" strip-bom "^3.0.0" -tslib@2.8.1, tslib@^2.1.0, tslib@^2.6.2: +tslib@2.8.1, tslib@^2.1.0, tslib@^2.4.1, tslib@^2.6.2: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==