diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f8e9f9..b513766 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,14 +12,12 @@ jobs: strategy: matrix: - node-version: [18, 20, 22] + node-version: [20, 22] steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - with: - version: 9 - uses: actions/setup-node@v4 with: diff --git a/CLAUDE.md b/CLAUDE.md index 1842f7f..c361d84 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,7 @@ cd apps/log-blaster && pnpm start # Node.js CLI stress-test tool ### Core Components (packages/core/src/) -- **client.ts** - `GunsoleClient` class: main API for logging (`log()`, `info()`, `debug()`, `warn()`, `error()`), user/session tracking, log batching, global error handler, `destroy()` lifecycle method +- **client.ts** - `GunsoleClient` class: main API for logging (`log()`, `info()`, `debug()`, `warn()`, `error()`, `fatal()`), user/session tracking, log batching, global error handler (uses `fatal` level), `destroy()` lifecycle method - **transport.ts** - HTTP layer with retry logic (exponential backoff, max 3 retries), gzip compression via native Compression Streams API (disabled when `isDebug: true`) - **config.ts** - Configuration validation and endpoint resolution by mode (cloud/desktop/local), supports custom `fetch` implementation - **factory.ts** - `createGunsoleClient()` factory function diff --git a/LICENSE b/LICENSE index 2ae2663..2f8f1fd 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 push1kb +Copyright (c) 2026 push1kb Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/apps/angular-app/angular.json b/apps/angular-app/angular.json index 0104fec..7875d89 100644 --- a/apps/angular-app/angular.json +++ b/apps/angular-app/angular.json @@ -2,7 +2,8 @@ "$schema": "./node_modules/@angular/cli/lib/config/schema.json", "version": 1, "cli": { - "packageManager": "pnpm" + "packageManager": "pnpm", + "analytics": false }, "newProjectRoot": "projects", "projects": { diff --git a/apps/angular-app/package.json b/apps/angular-app/package.json index 84e952d..07fc782 100644 --- a/apps/angular-app/package.json +++ b/apps/angular-app/package.json @@ -5,8 +5,7 @@ "ng": "ng", "start": "ng serve", "build": "ng build", - "watch": "ng build --watch --configuration development", - "test": "ng test" + "watch": "ng build --watch --configuration development" }, "prettier": { "printWidth": 100, @@ -31,7 +30,7 @@ "@angular/router": "^21.0.0", "rxjs": "~7.8.0", "tslib": "^2.3.0", - "@gunsole/core": "workspace:*" + "@gunsole/web": "workspace:*" }, "devDependencies": { "@angular/build": "^21.0.2", diff --git a/apps/angular-app/public/angular.svg b/apps/angular-app/public/angular.svg new file mode 100644 index 0000000..5a566ef --- /dev/null +++ b/apps/angular-app/public/angular.svg @@ -0,0 +1 @@ + diff --git a/apps/angular-app/public/gunsole.svg b/apps/angular-app/public/gunsole.svg new file mode 100644 index 0000000..d2e6243 --- /dev/null +++ b/apps/angular-app/public/gunsole.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/angular-app/src/app/app.config.ts b/apps/angular-app/src/app/app.config.ts index a3aedd8..7c45b42 100644 --- a/apps/angular-app/src/app/app.config.ts +++ b/apps/angular-app/src/app/app.config.ts @@ -1,11 +1,42 @@ import { type ApplicationConfig, + ErrorHandler, provideBrowserGlobalErrorListeners, } from "@angular/core"; import { provideRouter } from "@angular/router"; +import { createGunsoleClient } from "@gunsole/web"; import { routes } from "./app.routes"; +const gunsole = createGunsoleClient({ + projectId: "test-project-angular", + apiKey: "test-api-key", + mode: "local", + env: "development", + appName: "Angular App", + appVersion: "1.0.0", + defaultTags: { framework: "angular" }, +}); + +class GunsoleErrorHandler implements ErrorHandler { + handleError(error: unknown): void { + const err = error instanceof Error ? error : new Error(String(error)); + gunsole.fatal({ + message: err.message, + bucket: "fatal", + context: { + name: err.name, + stack: err.stack, + }, + }); + console.error(err); + } +} + export const appConfig: ApplicationConfig = { - providers: [provideBrowserGlobalErrorListeners(), provideRouter(routes)], + providers: [ + provideBrowserGlobalErrorListeners(), + provideRouter(routes), + { provide: ErrorHandler, useClass: GunsoleErrorHandler }, + ], }; diff --git a/apps/angular-app/src/app/app.css b/apps/angular-app/src/app/app.css index ce9ec49..90abc7b 100644 --- a/apps/angular-app/src/app/app.css +++ b/apps/angular-app/src/app/app.css @@ -1,87 +1,331 @@ .app { - max-width: 800px; - margin: 0 auto; + min-height: 100vh; padding: 2rem; - text-align: center; +} + +.container { + max-width: 42rem; + margin: 0 auto; +} + +.logo-row { + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; + margin-bottom: 1.5rem; +} + +.logo { + height: 3rem; + width: 3rem; +} + +.logo-sm { + height: 2.5rem; + width: 2.5rem; } h1 { - font-size: 2.5em; - margin-bottom: 1rem; - background: linear-gradient(45deg, #dd0031, #c3002f); + font-size: 2.25rem; + font-weight: 700; + text-align: center; + margin-bottom: 2rem; + background: linear-gradient(to right, #6366f1, #a855f7); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; } -.card { - padding: 2em; - background: rgba(255, 255, 255, 0.05); - border-radius: 8px; - border: 1px solid rgba(255, 255, 255, 0.1); +.sections { + display: flex; + flex-direction: column; + gap: 1.5rem; } .section { - margin: 2rem 0; + background-color: #27272a; + border-radius: 0.5rem; padding: 1.5rem; - background: rgba(0, 0, 0, 0.2); - border-radius: 8px; } .section h2 { - margin-top: 0; - font-size: 1.5em; + font-size: 1.25rem; + font-weight: 600; + margin-bottom: 1rem; } .button-group { display: flex; - gap: 1rem; flex-wrap: wrap; + gap: 0.75rem; justify-content: center; } button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; + padding: 0.5rem 1rem; + border-radius: 0.5rem; + border: none; + font-size: 0.875rem; font-weight: 500; font-family: inherit; - background-color: #1a1a1a; + color: white; cursor: pointer; - transition: border-color 0.25s; + transition: background-color 0.15s; +} + +.btn-blue { + background-color: #2563eb; +} +.btn-blue:hover { + background-color: #1d4ed8; +} + +.btn-gray { + background-color: #4b5563; +} +.btn-gray:hover { + background-color: #374151; +} + +.btn-yellow { + background-color: #ca8a04; +} +.btn-yellow:hover { + background-color: #a16207; +} + +.btn-red { + background-color: #dc2626; +} +.btn-red:hover { + background-color: #b91c1c; +} + +.btn-green { + background-color: #16a34a; +} +.btn-green:hover { + background-color: #15803d; +} + +.btn-indigo { + background-color: #4f46e5; +} +.btn-indigo:hover { + background-color: #4338ca; +} + +.search-row { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.search-row input { + flex: 1; + padding: 0.5rem 1rem; + border-radius: 0.5rem; + border: 1px solid #52525b; + background-color: #3f3f46; color: white; + font-size: 1rem; + font-family: inherit; + outline: none; } -button:hover { - border-color: #dd0031; +.search-row input:focus { + box-shadow: 0 0 0 2px #6366f1; } -button:focus, -button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; +.error-text { + color: #f87171; + margin-bottom: 1rem; } -.input-group { +.pokemon-card { + background-color: rgba(63, 63, 70, 0.5); + border-radius: 0.75rem; + padding: 1.5rem; + text-align: center; + margin-bottom: 1rem; +} + +.pokemon-sprite { + width: 8rem; + height: 8rem; + margin: 0 auto; + image-rendering: pixelated; +} + +.pokemon-sprite-sm { + width: 6rem; + height: 6rem; + image-rendering: pixelated; +} + +.pokemon-name { + font-size: 1.5rem; + font-weight: 700; + text-transform: capitalize; + margin-top: 0.5rem; +} + +.pokemon-info { + color: #d4d4d8; + margin-top: 0.5rem; +} + +.pokemon-info p { + margin: 0.25rem 0; +} + +.pokemon-info strong, +.pokemon-details strong { + color: white; +} + +.pokemon-details { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid #52525b; + text-align: left; + color: #d4d4d8; +} + +.pokemon-details p { + margin: 0.5rem 0; +} + +.sprite-row { display: flex; - flex-direction: column; + justify-content: center; gap: 1rem; + margin-bottom: 1rem; +} + +.sprite-item { + text-align: center; +} + +.sprite-label { + display: block; + font-size: 0.75rem; + color: #a1a1aa; +} + +.stats { + margin-top: 0.75rem; +} + +.stat-row { + display: flex; align-items: center; + gap: 0.5rem; + margin-top: 0.25rem; } -.input-group label { +.stat-name { + font-size: 0.75rem; + color: #a1a1aa; + width: 6rem; + text-align: right; + text-transform: capitalize; +} + +.stat-bar-bg { + flex: 1; + background-color: #52525b; + border-radius: 9999px; + height: 0.5rem; +} + +.stat-bar { + background-color: #6366f1; + height: 0.5rem; + border-radius: 9999px; +} + +.stat-value { + font-size: 0.75rem; + color: #d4d4d8; + width: 2rem; +} + +.btn-detail { + margin-top: 1rem; + padding: 0.375rem 1rem; + font-size: 0.875rem; + background-color: rgba(99, 102, 241, 0.2); + border: 1px solid rgba(99, 102, 241, 0.4); + border-radius: 0.5rem; + transition: background-color 0.15s; +} + +.btn-detail:hover { + background-color: rgba(99, 102, 241, 0.4); +} + +.suggestions { display: flex; - flex-direction: column; + flex-wrap: wrap; gap: 0.5rem; - width: 100%; - max-width: 300px; + align-items: center; + justify-content: center; +} + +.suggestions-label { + color: #a1a1aa; +} + +.btn-suggestion { + padding: 0.25rem 0.75rem; + font-size: 0.875rem; + background-color: rgba(99, 102, 241, 0.2); + border: 1px solid rgba(99, 102, 241, 0.4); + border-radius: 0.5rem; + transition: background-color 0.15s; +} + +.btn-suggestion:hover { + background-color: rgba(99, 102, 241, 0.4); +} + +button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.input-group { + display: grid; + gap: 1rem; + max-width: 24rem; + margin: 0 auto; +} + +.input-group label { + display: block; +} + +.input-group label span { + display: block; + font-size: 0.875rem; + color: #d4d4d8; + margin-bottom: 0.25rem; } .input-group input { - padding: 0.5rem; - border-radius: 4px; - border: 1px solid rgba(255, 255, 255, 0.2); - background: rgba(0, 0, 0, 0.3); + width: 100%; + padding: 0.5rem 1rem; + border-radius: 0.5rem; + border: 1px solid #52525b; + background-color: #3f3f46; color: white; - font-size: 1em; + font-size: 1rem; + font-family: inherit; + outline: none; +} + +.input-group input:focus { + box-shadow: 0 0 0 2px #6366f1; } diff --git a/apps/angular-app/src/app/app.html b/apps/angular-app/src/app/app.html index a22c29e..67e7bd4 100644 --- a/apps/angular-app/src/app/app.html +++ b/apps/angular-app/src/app/app.html @@ -1,47 +1 @@ -
-

Gunsole JS - Angular Test

-
-
-

Counter: {{ count() }}

- -
- -
-

Log Actions

-
- - - - -
-
- -
-

User & Session

-
- - -
-
- -
-

Actions

-
- - -
-
-
-
- diff --git a/apps/angular-app/src/app/app.routes.ts b/apps/angular-app/src/app/app.routes.ts index c59ecf1..3b6eefc 100644 --- a/apps/angular-app/src/app/app.routes.ts +++ b/apps/angular-app/src/app/app.routes.ts @@ -1,3 +1,8 @@ import type { Routes } from "@angular/router"; +import { Home } from "./home/home"; +import { PokemonDetail } from "./pokemon-detail/pokemon-detail"; -export const routes: Routes = []; +export const routes: Routes = [ + { path: "", component: Home }, + { path: "pokemon/:id", component: PokemonDetail }, +]; diff --git a/apps/angular-app/src/app/app.spec.ts b/apps/angular-app/src/app/app.spec.ts deleted file mode 100644 index 9bb2af0..0000000 --- a/apps/angular-app/src/app/app.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { TestBed } from "@angular/core/testing"; -import { App } from "./app"; - -describe("App", () => { - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [App], - }).compileComponents(); - }); - - it("should create the app", () => { - const fixture = TestBed.createComponent(App); - const app = fixture.componentInstance; - expect(app).toBeTruthy(); - }); - - it("should render title", async () => { - const fixture = TestBed.createComponent(App); - await fixture.whenStable(); - const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.querySelector("h1")?.textContent).toContain( - "Hello, angular-app" - ); - }); -}); diff --git a/apps/angular-app/src/app/app.ts b/apps/angular-app/src/app/app.ts index b8f9529..1ae5677 100644 --- a/apps/angular-app/src/app/app.ts +++ b/apps/angular-app/src/app/app.ts @@ -1,93 +1,9 @@ -import { Component, effect, type OnDestroy, signal } from "@angular/core"; -import { FormsModule } from "@angular/forms"; +import { Component } from "@angular/core"; import { RouterOutlet } from "@angular/router"; -import { createGunsoleClient } from "@gunsole/core"; - -const gunsole = createGunsoleClient({ - projectId: "test-project-angular", - apiKey: "test-api-key", - mode: "local", - env: "development", - appName: "Angular App", - appVersion: "1.0.0", - defaultTags: { framework: "angular" }, -}); @Component({ selector: "app-root", - imports: [RouterOutlet, FormsModule], - templateUrl: "./app.html", - styleUrl: "./app.css", + imports: [RouterOutlet], + template: "", }) -export class App implements OnDestroy { - protected readonly count = signal(0); - protected readonly userId = signal("user-123"); - protected readonly sessionId = signal("session-abc"); - - constructor() { - effect(() => { - gunsole.setUser({ id: this.userId(), email: "user@example.com" }); - gunsole.setSessionId(this.sessionId()); - }); - - gunsole.attachGlobalErrorHandlers(); - - gunsole.log({ - message: "App initialized", - bucket: "app_lifecycle", - context: { framework: "angular" }, - }); - } - - ngOnDestroy(): void { - gunsole.detachGlobalErrorHandlers(); - gunsole.flush(); - } - - protected handleLog(level: "info" | "debug" | "warn" | "error"): void { - const logOptions = { - message: `User clicked ${level} log button`, - bucket: "user_action", - context: { count: this.count(), timestamp: Date.now() }, - tags: { action: "button_click", level }, - }; - - switch (level) { - case "info": - gunsole.info(logOptions); - break; - case "debug": - gunsole.debug(logOptions); - break; - case "warn": - gunsole.warn(logOptions); - break; - case "error": - gunsole.error(logOptions); - break; - } - } - - protected handleIncrement(): void { - const newCount = this.count() + 1; - this.count.set(newCount); - gunsole.log({ - message: "Counter incremented", - bucket: "counter", - context: { count: newCount }, - }); - } - - protected handleError(): void { - gunsole.error({ - message: "Test error logged", - bucket: "test_error", - context: { error: "This is a test error", stack: "test stack" }, - }); - } - - protected async handleFlush(): Promise { - await gunsole.flush(); - alert("Logs flushed!"); - } -} +export class App {} diff --git a/apps/angular-app/src/app/home/home.html b/apps/angular-app/src/app/home/home.html new file mode 100644 index 0000000..bb4a27d --- /dev/null +++ b/apps/angular-app/src/app/home/home.html @@ -0,0 +1,87 @@ +
+
+
+ + +
+

Gunsole JS - Angular

+ +
+ +
+

Pokemon Search

+
+ + +
+ + @if (error()) { +

{{ error() }}

+ } + + @if (pokemon(); as p) { +
+ +

{{ p.name }}

+
+

ID: #{{ p.id }}

+

Height: {{ p.height / 10 }}m

+

Weight: {{ p.weight / 10 }}kg

+

Types: @for (t of p.types; track t.type.name) { {{ t.type.name }}@if (!$last) {, } }

+
+ + + More Details + +
+ } + +
+ Try: + @for (name of suggestions; track name) { + + } +
+
+ + +
+

Log Actions

+
+ + + + +
+
+ + +
+

User

+
+ +
+
+ + +
+

Actions

+
+ + +
+
+
+
+
diff --git a/apps/angular-app/src/app/home/home.ts b/apps/angular-app/src/app/home/home.ts new file mode 100644 index 0000000..98f0a7d --- /dev/null +++ b/apps/angular-app/src/app/home/home.ts @@ -0,0 +1,166 @@ +import { Component, effect, signal } from "@angular/core"; +import { RouterLink } from "@angular/router"; +import { createGunsoleClient } from "@gunsole/web"; + +const gunsole = createGunsoleClient({ + projectId: "test-project-angular", + apiKey: "test-api-key", + mode: "local", + env: "development", + appName: "Angular App", + appVersion: "1.0.0", + defaultTags: { framework: "angular" }, +}); + +interface Pokemon { + id: number; + name: string; + height: number; + weight: number; + base_experience: number; + sprites: { + front_default: string; + front_shiny: string; + }; + types: Array<{ type: { name: string } }>; + abilities: Array<{ ability: { name: string }; is_hidden: boolean }>; + stats: Array<{ base_stat: number; stat: { name: string } }>; +} + +@Component({ + selector: "app-home", + imports: [RouterLink], + templateUrl: "./home.html", + styleUrl: "../app.css", +}) +export class Home { + protected readonly userId = signal("user-123"); + protected readonly pokemonName = signal("pikachu"); + protected readonly pokemon = signal(null); + protected readonly loading = signal(false); + protected readonly error = signal(null); + protected readonly suggestions = [ + "charizard", + "mewtwo", + "gengar", + "eevee", + "snorlax", + ]; + + constructor() { + effect(() => { + gunsole.setUser({ id: this.userId(), email: "user@example.com" }); + }); + + gunsole.log({ + message: "App initialized", + bucket: "app_lifecycle", + context: { framework: "angular" }, + }); + } + + protected async fetchPokemon(): Promise { + const name = this.pokemonName(); + const traceId = `trace-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; + const startTime = performance.now(); + + this.loading.set(true); + this.error.set(null); + this.pokemon.set(null); + + gunsole.info({ + message: `Fetching Pokemon: ${name}`, + bucket: "api_request", + context: { pokemon: name }, + tags: { api: "pokeapi", action: "fetch_start" }, + traceId, + }); + + try { + const response = await fetch( + `https://pokeapi.co/api/v2/pokemon/${name.toLowerCase()}` + ); + const fetchTime = performance.now() - startTime; + + if (!response.ok) { + throw new Error(`Pokemon not found: ${name}`); + } + + const data: Pokemon = await response.json(); + const totalTime = performance.now() - startTime; + + this.pokemon.set(data); + + gunsole.info({ + message: `Pokemon fetched successfully: ${data.name}`, + bucket: "api_request", + context: { + pokemon: data.name, + pokemonId: data.id, + fetchTimeMs: Math.round(fetchTime), + totalTimeMs: Math.round(totalTime), + }, + tags: { api: "pokeapi", action: "fetch_success", status: "200" }, + traceId, + }); + } catch (err) { + const totalTime = performance.now() - startTime; + const errorMessage = err instanceof Error ? err.message : "Unknown error"; + + this.error.set(errorMessage); + + gunsole.error({ + message: `Failed to fetch Pokemon: ${name}`, + bucket: "api_request", + context: { + pokemon: name, + error: errorMessage, + totalTimeMs: Math.round(totalTime), + }, + tags: { api: "pokeapi", action: "fetch_error" }, + traceId, + }); + } finally { + this.loading.set(false); + } + } + + protected onSearchKeydown(event: KeyboardEvent): void { + if (event.key === "Enter") { + this.fetchPokemon(); + } + } + + protected handleLog(level: "info" | "debug" | "warn" | "error"): void { + const logOptions = { + message: `User clicked ${level} log button`, + bucket: "user_action", + context: { timestamp: Date.now() }, + tags: { action: "button_click", level }, + }; + + switch (level) { + case "info": + gunsole.info(logOptions); + break; + case "debug": + gunsole.debug(logOptions); + break; + case "warn": + gunsole.warn(logOptions); + break; + case "error": + gunsole.error(logOptions); + break; + } + } + + protected handleError(): void { + throw new Error("Test error triggered by user"); + } + + protected async handleFlush(): Promise { + await gunsole.flush(); + alert("Logs flushed!"); + } +} diff --git a/apps/angular-app/src/app/pokemon-detail/pokemon-detail.css b/apps/angular-app/src/app/pokemon-detail/pokemon-detail.css new file mode 100644 index 0000000..3b7731d --- /dev/null +++ b/apps/angular-app/src/app/pokemon-detail/pokemon-detail.css @@ -0,0 +1,122 @@ +.app { + min-height: 100vh; + padding: 2rem; +} + +.container { + max-width: 42rem; + margin: 0 auto; +} + +.back-link { + color: #818cf8; + text-decoration: none; + display: inline-block; + margin-bottom: 1.5rem; +} + +.back-link:hover { + color: #a5b4fc; +} + +.loading-text { + color: #a1a1aa; +} + +.error-text { + color: #f87171; +} + +.detail-card { + background-color: #27272a; + border-radius: 0.75rem; + padding: 2rem; +} + +.sprite-row { + display: flex; + justify-content: center; + gap: 1.5rem; + margin-bottom: 1.5rem; +} + +.sprite-item { + text-align: center; +} + +.pokemon-sprite { + width: 8rem; + height: 8rem; + image-rendering: pixelated; +} + +.sprite-label { + display: block; + font-size: 0.75rem; + color: #a1a1aa; +} + +.pokemon-name { + font-size: 1.875rem; + font-weight: 700; + text-transform: capitalize; + text-align: center; + margin-bottom: 1rem; + background: none; + -webkit-text-fill-color: white; +} + +.info-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + color: #d4d4d8; + margin-bottom: 1.5rem; +} + +.info-grid strong { + color: white; +} + +.stats { + margin-top: 0.75rem; +} + +.stats strong { + color: white; + font-size: 0.875rem; +} + +.stat-row { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.25rem; +} + +.stat-name { + font-size: 0.75rem; + color: #a1a1aa; + width: 6rem; + text-align: right; + text-transform: capitalize; +} + +.stat-bar-bg { + flex: 1; + background-color: #52525b; + border-radius: 9999px; + height: 0.5rem; +} + +.stat-bar { + background-color: #6366f1; + height: 0.5rem; + border-radius: 9999px; +} + +.stat-value { + font-size: 0.75rem; + color: #d4d4d8; + width: 2rem; +} diff --git a/apps/angular-app/src/app/pokemon-detail/pokemon-detail.html b/apps/angular-app/src/app/pokemon-detail/pokemon-detail.html new file mode 100644 index 0000000..bf3e205 --- /dev/null +++ b/apps/angular-app/src/app/pokemon-detail/pokemon-detail.html @@ -0,0 +1,52 @@ +
+
+ ← Back + + @if (loading()) { +

Loading...

+ } + + @if (error()) { +

{{ error() }}

+ } + + @if (pokemon(); as p) { +
+
+
+ Default + Default +
+
+ Shiny + Shiny +
+
+ +

{{ p.name }}

+ +
+

ID: #{{ p.id }}

+

Base XP: {{ p.base_experience }}

+

Height: {{ p.height / 10 }}m

+

Weight: {{ p.weight / 10 }}kg

+

Types: @for (t of p.types; track t.type.name) { {{ t.type.name }}@if (!$last) {, } }

+

Abilities: {{ getAbilitiesText(p.abilities) }}

+
+ +
+ Stats: + @for (s of p.stats; track s.stat.name) { +
+ {{ s.stat.name }} +
+
+
+ {{ s.base_stat }} +
+ } +
+
+ } +
+
diff --git a/apps/angular-app/src/app/pokemon-detail/pokemon-detail.ts b/apps/angular-app/src/app/pokemon-detail/pokemon-detail.ts new file mode 100644 index 0000000..9a94099 --- /dev/null +++ b/apps/angular-app/src/app/pokemon-detail/pokemon-detail.ts @@ -0,0 +1,106 @@ +import { Component, signal } from "@angular/core"; +// biome-ignore lint/style/useImportType: Angular DI requires value import +import { ActivatedRoute, RouterLink } from "@angular/router"; +import { createGunsoleClient } from "@gunsole/web"; + +const gunsole = createGunsoleClient({ + projectId: "test-project-angular", + apiKey: "test-api-key", + mode: "local", + env: "development", + appName: "Angular App", + appVersion: "1.0.0", + defaultTags: { framework: "angular" }, +}); + +interface Pokemon { + id: number; + name: string; + height: number; + weight: number; + base_experience: number; + sprites: { + front_default: string; + front_shiny: string; + }; + types: Array<{ type: { name: string } }>; + abilities: Array<{ ability: { name: string }; is_hidden: boolean }>; + stats: Array<{ base_stat: number; stat: { name: string } }>; +} + +@Component({ + selector: "app-pokemon-detail", + imports: [RouterLink], + templateUrl: "./pokemon-detail.html", + styleUrl: "./pokemon-detail.css", +}) +export class PokemonDetail { + protected readonly pokemon = signal(null); + protected readonly loading = signal(true); + protected readonly error = signal(null); + + constructor(private route: ActivatedRoute) { + const id = this.route.snapshot.paramMap.get("id"); + if (id) this.fetchPokemon(id); + } + + private async fetchPokemon(id: string): Promise { + const traceId = `trace-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; + const startTime = performance.now(); + + this.loading.set(true); + this.error.set(null); + + gunsole.info({ + message: `Fetching Pokemon detail: ${id}`, + bucket: "api_request", + context: { pokemonId: id }, + tags: { api: "pokeapi", action: "detail_fetch_start" }, + traceId, + }); + + try { + const response = await fetch(`https://pokeapi.co/api/v2/pokemon/${id}`); + if (!response.ok) throw new Error(`Pokemon not found: ${id}`); + + const data: Pokemon = await response.json(); + const totalTime = performance.now() - startTime; + this.pokemon.set(data); + + gunsole.info({ + message: `Pokemon detail fetched: ${data.name}`, + bucket: "api_request", + context: { + pokemon: data.name, + pokemonId: data.id, + totalTimeMs: Math.round(totalTime), + }, + tags: { api: "pokeapi", action: "detail_fetch_success" }, + traceId, + }); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "Unknown error"; + this.error.set(errorMessage); + + gunsole.error({ + message: `Failed to fetch Pokemon detail: ${id}`, + bucket: "api_request", + context: { pokemonId: id, error: errorMessage }, + tags: { api: "pokeapi", action: "detail_fetch_error" }, + traceId, + }); + } finally { + this.loading.set(false); + } + } + + protected getStatWidth(baseStat: number): string { + return `${Math.min(100, (baseStat / 255) * 100)}%`; + } + + protected getAbilitiesText(abilities: Pokemon["abilities"]): string { + return abilities + .map((a) => a.ability.name + (a.is_hidden ? " (hidden)" : "")) + .join(", "); + } +} diff --git a/apps/angular-app/src/styles.css b/apps/angular-app/src/styles.css index 90d4ee0..67e314b 100644 --- a/apps/angular-app/src/styles.css +++ b/apps/angular-app/src/styles.css @@ -1 +1,16 @@ -/* You can add global styles to this file, and also import other style files */ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + min-height: 100vh; + background-color: #18181b; + color: #fff; + font-family: system-ui, -apple-system, sans-serif; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} diff --git a/apps/log-blaster/src/format.ts b/apps/log-blaster/src/format.ts index 74df8b6..8dfe950 100644 --- a/apps/log-blaster/src/format.ts +++ b/apps/log-blaster/src/format.ts @@ -5,6 +5,7 @@ const levelColors = { DEBUG: chalk.gray, WARN: chalk.yellow, ERROR: chalk.red, + FATAL: chalk.redBright, } as const; const bucketColors = [ diff --git a/apps/log-blaster/src/index.ts b/apps/log-blaster/src/index.ts index 1d58ac5..8ed6e29 100644 --- a/apps/log-blaster/src/index.ts +++ b/apps/log-blaster/src/index.ts @@ -12,9 +12,7 @@ async function main() { console.log(); console.log(chalk.bold(" ⚡ Log Blaster")); console.log( - chalk.dim( - ` ${logs.length} logs · ${buckets.length} buckets · mode: local` - ) + chalk.dim(` ${logs.length} logs · ${buckets.length} buckets · mode: local`) ); if (delaySec > 0) { console.log(chalk.dim(` delay: ${delaySec}s between logs`)); @@ -39,6 +37,9 @@ async function main() { case "error": gunsole.error(opts); break; + case "fatal": + gunsole.fatal(opts); + break; } const levelStr = level.toUpperCase() as keyof typeof levelColors; diff --git a/apps/log-blaster/src/types.ts b/apps/log-blaster/src/types.ts index 5aa7570..1a4082e 100644 --- a/apps/log-blaster/src/types.ts +++ b/apps/log-blaster/src/types.ts @@ -1,5 +1,5 @@ interface LogEntry { - level: "info" | "debug" | "warn" | "error"; + level: "info" | "debug" | "warn" | "error" | "fatal"; bucket: string; message: string; context?: Record; diff --git a/apps/nextjs-app/package.json b/apps/nextjs-app/package.json index c701375..73751e3 100644 --- a/apps/nextjs-app/package.json +++ b/apps/nextjs-app/package.json @@ -12,7 +12,8 @@ "next": "16.0.7", "react": "19.2.0", "react-dom": "19.2.0", - "@gunsole/core": "workspace:*" + "@gunsole/core": "workspace:*", + "@gunsole/web": "workspace:*" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/apps/nextjs-app/public/gunsole.svg b/apps/nextjs-app/public/gunsole.svg new file mode 100644 index 0000000..d2e6243 --- /dev/null +++ b/apps/nextjs-app/public/gunsole.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/nextjs-app/public/next-white.svg b/apps/nextjs-app/public/next-white.svg new file mode 100644 index 0000000..aa4fefd --- /dev/null +++ b/apps/nextjs-app/public/next-white.svg @@ -0,0 +1 @@ + diff --git a/apps/nextjs-app/src/app/api/pokemon/[name]/route.ts b/apps/nextjs-app/src/app/api/pokemon/[name]/route.ts new file mode 100644 index 0000000..cf9e91f --- /dev/null +++ b/apps/nextjs-app/src/app/api/pokemon/[name]/route.ts @@ -0,0 +1,63 @@ +import { getServerGunsole } from "@/lib/gunsole-server"; +import { NextResponse } from "next/server"; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ name: string }> } +) { + const { name } = await params; + const gunsole = await getServerGunsole(); + const startTime = Date.now(); + + gunsole.info({ + message: `API request: GET /api/pokemon/${name}`, + bucket: "api", + context: { pokemon: name }, + }); + + try { + const response = await fetch( + `https://pokeapi.co/api/v2/pokemon/${name.toLowerCase()}` + ); + + if (!response.ok) { + gunsole.warn({ + message: `Pokemon not found: ${name}`, + bucket: "api", + context: { pokemon: name, status: response.status }, + }); + await gunsole.flush(); + return NextResponse.json( + { error: `Pokemon not found: ${name}` }, + { status: 404 } + ); + } + + const data = await response.json(); + const durationMs = Date.now() - startTime; + + gunsole.info({ + message: `API success: GET /api/pokemon/${name}`, + bucket: "api", + context: { pokemon: name, durationMs }, + }); + await gunsole.flush(); + + return NextResponse.json(data); + } catch (err) { + const durationMs = Date.now() - startTime; + const errorMessage = err instanceof Error ? err.message : "Unknown error"; + + gunsole.error({ + message: `API error: GET /api/pokemon/${name}`, + bucket: "api", + context: { pokemon: name, error: errorMessage, durationMs }, + }); + await gunsole.flush(); + + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/apps/nextjs-app/src/app/components/GunsoleTest.tsx b/apps/nextjs-app/src/app/components/GunsoleTest.tsx index 9247324..c9c934f 100644 --- a/apps/nextjs-app/src/app/components/GunsoleTest.tsx +++ b/apps/nextjs-app/src/app/components/GunsoleTest.tsx @@ -1,111 +1,236 @@ "use client"; -import { createGunsoleClient } from "@gunsole/core"; -import { useEffect, useState } from "react"; - -const gunsole = createGunsoleClient({ - projectId: "test-project-nextjs", - apiKey: "test-api-key", - mode: "local", - env: "development", - appName: "Next.js App", - appVersion: "1.0.0", - defaultTags: { framework: "nextjs" }, -}); +import { getClientGunsole } from "@/lib/gunsole-client"; +import Link from "next/link"; +import { useCallback, useEffect, useState } from "react"; + +interface Pokemon { + id: number; + name: string; + height: number; + weight: number; + sprites: { + front_default: string; + }; + types: Array<{ type: { name: string } }>; +} + +function generateTraceId(): string { + return `trace-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; +} export default function GunsoleTest() { - const [count, setCount] = useState(0); const [userId, setUserId] = useState("user-123"); - const [sessionId, setSessionId] = useState("session-abc"); + const [pokemonName, setPokemonName] = useState("pikachu"); + const [pokemon, setPokemon] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); useEffect(() => { - gunsole.setUser({ id: userId, email: "user@example.com" }); - gunsole.setSessionId(sessionId); - gunsole.attachGlobalErrorHandlers(); - - gunsole.log({ + getClientGunsole().log({ message: "App mounted", bucket: "app_lifecycle", context: { framework: "nextjs" }, }); + }, []); - return () => { - gunsole.detachGlobalErrorHandlers(); - gunsole.flush(); - }; - }, [userId, sessionId]); + useEffect(() => { + getClientGunsole().setUser({ id: userId, email: "user@example.com" }); + }, [userId]); + + const fetchPokemon = useCallback(async () => { + const traceId = generateTraceId(); + const startTime = performance.now(); + + setLoading(true); + setError(null); + setPokemon(null); + + getClientGunsole().info({ + message: `Fetching Pokemon: ${pokemonName}`, + bucket: "api_request", + context: { pokemon: pokemonName }, + tags: { api: "pokeapi", action: "fetch_start" }, + traceId, + }); + + try { + const response = await fetch(`/api/pokemon/${pokemonName.toLowerCase()}`); + const fetchTime = performance.now() - startTime; + + if (!response.ok) { + throw new Error(`Pokemon not found: ${pokemonName}`); + } + + const data: Pokemon = await response.json(); + const totalTime = performance.now() - startTime; + + setPokemon(data); + + getClientGunsole().info({ + message: `Pokemon fetched successfully: ${data.name}`, + bucket: "api_request", + context: { + pokemon: data.name, + pokemonId: data.id, + fetchTimeMs: Math.round(fetchTime), + totalTimeMs: Math.round(totalTime), + }, + tags: { api: "pokeapi", action: "fetch_success", status: "200" }, + traceId, + }); + } catch (err) { + const totalTime = performance.now() - startTime; + const errorMessage = err instanceof Error ? err.message : "Unknown error"; + + setError(errorMessage); + + getClientGunsole().error({ + message: `Failed to fetch Pokemon: ${pokemonName}`, + bucket: "api_request", + context: { + pokemon: pokemonName, + error: errorMessage, + totalTimeMs: Math.round(totalTime), + }, + tags: { api: "pokeapi", action: "fetch_error" }, + traceId, + }); + } finally { + setLoading(false); + } + }, [pokemonName]); const handleLog = (level: "info" | "debug" | "warn" | "error") => { const logOptions = { message: `User clicked ${level} log button`, bucket: "user_action", - context: { count, timestamp: Date.now() }, + context: { timestamp: Date.now() }, tags: { action: "button_click", level }, }; switch (level) { case "info": - gunsole.info(logOptions); + getClientGunsole().info(logOptions); break; case "debug": - gunsole.debug(logOptions); + getClientGunsole().debug(logOptions); break; case "warn": - gunsole.warn(logOptions); + getClientGunsole().warn(logOptions); break; case "error": - gunsole.error(logOptions); + getClientGunsole().error(logOptions); break; } }; - const handleIncrement = () => { - const newCount = count + 1; - setCount(newCount); - gunsole.log({ - message: "Counter incremented", - bucket: "counter", - context: { count: newCount }, - }); - }; - const handleError = () => { - gunsole.error({ - message: "Test error logged", - bucket: "test_error", - context: { error: "This is a test error", stack: "test stack" }, - }); + throw new Error("Test error triggered by user"); }; const handleFlush = async () => { - await gunsole.flush(); + await getClientGunsole().flush(); alert("Logs flushed!"); }; return ( -
+
+
+ Gunsole + Next.js +

- Gunsole JS - Next.js Test + Gunsole JS - Next.js

+ {/* Pokemon Search */}
-

Counter: {count}

- +

Pokemon Search

+
+ setPokemonName(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && fetchPokemon()} + placeholder="Enter Pokemon name..." + className="flex-1 px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500" + /> + +
+ + {error &&

{error}

} + + {pokemon && ( +
+ {pokemon.name} +

+ {pokemon.name} +

+
+

+ ID: # + {pokemon.id} +

+

+ Height:{" "} + {pokemon.height / 10}m +

+

+ Weight:{" "} + {pokemon.weight / 10}kg +

+

+ Types:{" "} + {pokemon.types.map((t) => t.type.name).join(", ")} +

+
+ + + More Details + +
+ )} + +
+ Try: + {["charizard", "mewtwo", "gengar", "eevee", "snorlax"].map( + (name) => ( + + ) + )} +
+ {/* Log Actions */}
-

Log Actions

-
+

Log Actions

+
@@ -133,44 +258,37 @@ export default function GunsoleTest() {
+ {/* User */}
-

User & Session

-
+

User

+
-
+ {/* Actions */}
-

Actions

-
+

Actions

+
diff --git a/apps/nextjs-app/src/app/error.tsx b/apps/nextjs-app/src/app/error.tsx new file mode 100644 index 0000000..7a853e9 --- /dev/null +++ b/apps/nextjs-app/src/app/error.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { getClientGunsole } from "@/lib/gunsole-client"; +import { useEffect } from "react"; + +export default function ErrorBoundary({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + getClientGunsole().fatal({ + message: error.message, + bucket: "fatal", + context: { + name: error.name, + digest: error.digest, + stack: error.stack, + }, + }); + }, [error]); + + return ( +
+
+
💥
+

+ Something went wrong +

+

{error.message}

+ +
+
+ ); +} diff --git a/apps/nextjs-app/src/app/page.tsx b/apps/nextjs-app/src/app/page.tsx index 9baeefe..00861ad 100644 --- a/apps/nextjs-app/src/app/page.tsx +++ b/apps/nextjs-app/src/app/page.tsx @@ -1,6 +1,14 @@ +import { getServerGunsole } from "@/lib/gunsole-server"; import GunsoleTest from "./components/GunsoleTest"; -export default function Home() { +export default async function Home() { + const gunsole = await getServerGunsole(); + gunsole.info({ + message: "Home page rendered", + bucket: "ssr", + }); + await gunsole.flush(); + return (
diff --git a/apps/nextjs-app/src/app/pokemon/[id]/page.tsx b/apps/nextjs-app/src/app/pokemon/[id]/page.tsx new file mode 100644 index 0000000..9d0905e --- /dev/null +++ b/apps/nextjs-app/src/app/pokemon/[id]/page.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { getClientGunsole } from "@/lib/gunsole-client"; +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { useCallback, useEffect, useState } from "react"; + +interface Pokemon { + id: number; + name: string; + height: number; + weight: number; + base_experience: number; + sprites: { + front_default: string; + front_shiny: string; + }; + types: Array<{ type: { name: string } }>; + abilities: Array<{ ability: { name: string }; is_hidden: boolean }>; + stats: Array<{ base_stat: number; stat: { name: string } }>; +} + +export default function PokemonDetailPage() { + const params = useParams<{ id: string }>(); + const [pokemon, setPokemon] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchPokemon = useCallback(async () => { + const traceId = `trace-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; + const startTime = performance.now(); + + setLoading(true); + setError(null); + + getClientGunsole().info({ + message: `Fetching Pokemon detail: ${params.id}`, + bucket: "api_request", + context: { pokemonId: params.id }, + tags: { api: "pokeapi", action: "detail_fetch_start" }, + traceId, + }); + + try { + const response = await fetch( + `https://pokeapi.co/api/v2/pokemon/${params.id}` + ); + if (!response.ok) throw new Error(`Pokemon not found: ${params.id}`); + + const data: Pokemon = await response.json(); + const totalTime = performance.now() - startTime; + setPokemon(data); + + getClientGunsole().info({ + message: `Pokemon detail fetched: ${data.name}`, + bucket: "api_request", + context: { + pokemon: data.name, + pokemonId: data.id, + totalTimeMs: Math.round(totalTime), + }, + tags: { api: "pokeapi", action: "detail_fetch_success" }, + traceId, + }); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "Unknown error"; + setError(errorMessage); + + getClientGunsole().error({ + message: `Failed to fetch Pokemon detail: ${params.id}`, + bucket: "api_request", + context: { pokemonId: params.id, error: errorMessage }, + tags: { api: "pokeapi", action: "detail_fetch_error" }, + traceId, + }); + } finally { + setLoading(false); + } + }, [params.id]); + + useEffect(() => { + fetchPokemon(); + }, [fetchPokemon]); + + return ( +
+
+ + ← Back + + + {loading &&

Loading...

} + {error &&

{error}

} + + {pokemon && ( +
+
+
+ Default + Default +
+
+ Shiny + Shiny +
+
+ +

+ {pokemon.name} +

+ +
+

+ ID: # + {pokemon.id} +

+

+ Base XP:{" "} + {pokemon.base_experience} +

+

+ Height:{" "} + {pokemon.height / 10}m +

+

+ Weight:{" "} + {pokemon.weight / 10}kg +

+

+ Types:{" "} + {pokemon.types.map((t) => t.type.name).join(", ")} +

+

+ Abilities:{" "} + {pokemon.abilities + .map((a) => a.ability.name + (a.is_hidden ? " (hidden)" : "")) + .join(", ")} +

+
+ +
+ Stats: + {pokemon.stats.map((s) => ( +
+ + {s.stat.name} + +
+
+
+ + {s.base_stat} + +
+ ))} +
+
+ )} +
+
+ ); +} diff --git a/apps/nextjs-app/src/lib/gunsole-client.ts b/apps/nextjs-app/src/lib/gunsole-client.ts new file mode 100644 index 0000000..73ff30b --- /dev/null +++ b/apps/nextjs-app/src/lib/gunsole-client.ts @@ -0,0 +1,21 @@ +"use client"; + +import { + type GunsoleClient, + createGunsoleClient, + persistSession, +} from "@gunsole/web"; +import { gunsoleConfig } from "./gunsole-config"; + +let gunsole: GunsoleClient | null = null; + +export function getClientGunsole(): GunsoleClient { + if (!gunsole) { + gunsole = createGunsoleClient({ + ...gunsoleConfig, + defaultTags: { ...gunsoleConfig.defaultTags, runtime: "client" }, + }); + persistSession(gunsole); + } + return gunsole; +} diff --git a/apps/nextjs-app/src/lib/gunsole-config.ts b/apps/nextjs-app/src/lib/gunsole-config.ts new file mode 100644 index 0000000..55184d9 --- /dev/null +++ b/apps/nextjs-app/src/lib/gunsole-config.ts @@ -0,0 +1,11 @@ +import type { GunsoleClientConfig } from "@gunsole/core"; + +export const gunsoleConfig = { + projectId: "test-project-nextjs", + apiKey: "test-api-key", + mode: "local", + env: "development", + appName: "Next.js App", + appVersion: "1.0.0", + defaultTags: { framework: "nextjs" }, +} satisfies GunsoleClientConfig; diff --git a/apps/nextjs-app/src/lib/gunsole-server.ts b/apps/nextjs-app/src/lib/gunsole-server.ts new file mode 100644 index 0000000..44402de --- /dev/null +++ b/apps/nextjs-app/src/lib/gunsole-server.ts @@ -0,0 +1,16 @@ +import { type GunsoleClient, createGunsoleClient } from "@gunsole/core"; +import { cookies } from "next/headers"; +import { gunsoleConfig } from "./gunsole-config"; + +const COOKIE_NAME = "gunsole_session"; + +export async function getServerGunsole(): Promise { + const cookieStore = await cookies(); + const sessionId = cookieStore.get(COOKIE_NAME)?.value; + + return createGunsoleClient({ + ...gunsoleConfig, + sessionId, + defaultTags: { ...gunsoleConfig.defaultTags, runtime: "server" }, + }); +} diff --git a/apps/react-vite/package.json b/apps/react-vite/package.json index 81d5608..0c4755e 100644 --- a/apps/react-vite/package.json +++ b/apps/react-vite/package.json @@ -10,7 +10,8 @@ "preview": "vite preview" }, "dependencies": { - "@gunsole/core": "workspace:*", + "@gunsole/web": "workspace:*", + "@tanstack/react-router": "^1.166.2", "react": "^19.2.0", "react-dom": "^19.2.0", "web-vitals": "^5.1.0" diff --git a/apps/react-vite/src/App.tsx b/apps/react-vite/src/App.tsx index b443df4..88e4e53 100644 --- a/apps/react-vite/src/App.tsx +++ b/apps/react-vite/src/App.tsx @@ -1,16 +1,9 @@ -import { createGunsoleClient } from "@gunsole/core"; +import { Link } from "@tanstack/react-router"; import { useCallback, useEffect, useState } from "react"; import { onCLS, onFCP, onINP, onLCP, onTTFB } from "web-vitals"; - -const gunsole = createGunsoleClient({ - projectId: "test-project-react", - apiKey: "test-api-key", - mode: "local", - env: "development", - appName: "React Vite App", - appVersion: "1.0.0", - defaultTags: { framework: "react", bundler: "vite" }, -}); +import gunsoleLogo from "./assets/gunsole.svg"; +import reactLogo from "./assets/react.svg"; +import { gunsole } from "./gunsole"; interface Pokemon { id: number; @@ -29,13 +22,11 @@ function generateTraceId(): string { function App() { const [userId, setUserId] = useState("user-123"); - const [sessionId, setSessionId] = useState("session-abc"); const [pokemonName, setPokemonName] = useState("pikachu"); const [pokemon, setPokemon] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - // Report web vitals useEffect(() => { const reportVital = (metric: { name: string; @@ -63,21 +54,16 @@ function App() { }, []); useEffect(() => { - gunsole.setUser({ id: userId, email: "user@example.com" }); - gunsole.setSessionId(sessionId); - gunsole.attachGlobalErrorHandlers(); - gunsole.log({ message: "App mounted", bucket: "app_lifecycle", context: { framework: "react" }, }); + }, []); - return () => { - gunsole.detachGlobalErrorHandlers(); - gunsole.flush(); - }; - }, [userId, sessionId]); + useEffect(() => { + gunsole.setUser({ id: userId, email: "user@example.com" }); + }, [userId]); const fetchPokemon = useCallback(async () => { const traceId = generateTraceId(); @@ -118,7 +104,6 @@ function App() { pokemonId: data.id, fetchTimeMs: Math.round(fetchTime), totalTimeMs: Math.round(totalTime), - responseSize: JSON.stringify(data).length, }, tags: { api: "pokeapi", action: "fetch_success", status: "200" }, traceId, @@ -181,6 +166,10 @@ function App() { return (
+
+ Gunsole + React +

Gunsole JS - React + Vite

@@ -238,6 +227,14 @@ function App() { {pokemon.types.map((t) => t.type.name).join(", ")}

+ + + More Details +
)} @@ -293,9 +290,9 @@ function App() {
- {/* User & Session */} + {/* User */}
-

User & Session

+

User

-
diff --git a/apps/react-vite/src/PokemonDetail.tsx b/apps/react-vite/src/PokemonDetail.tsx new file mode 100644 index 0000000..9c13125 --- /dev/null +++ b/apps/react-vite/src/PokemonDetail.tsx @@ -0,0 +1,175 @@ +import { Link, useParams } from "@tanstack/react-router"; +import { useCallback, useEffect, useState } from "react"; +import { gunsole } from "./gunsole"; + +interface Pokemon { + id: number; + name: string; + height: number; + weight: number; + base_experience: number; + sprites: { + front_default: string; + front_shiny: string; + }; + types: Array<{ type: { name: string } }>; + abilities: Array<{ ability: { name: string }; is_hidden: boolean }>; + stats: Array<{ base_stat: number; stat: { name: string } }>; +} + +export default function PokemonDetail() { + const { pokemonId } = useParams({ from: "/pokemon/$pokemonId" }); + const [pokemon, setPokemon] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchPokemon = useCallback(async () => { + const traceId = `trace-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; + const startTime = performance.now(); + + setLoading(true); + setError(null); + + gunsole.info({ + message: `Fetching Pokemon detail: ${pokemonId}`, + bucket: "api_request", + context: { pokemonId }, + tags: { api: "pokeapi", action: "detail_fetch_start" }, + traceId, + }); + + try { + const response = await fetch( + `https://pokeapi.co/api/v2/pokemon/${pokemonId}` + ); + if (!response.ok) throw new Error(`Pokemon not found: ${pokemonId}`); + + const data: Pokemon = await response.json(); + const totalTime = performance.now() - startTime; + setPokemon(data); + + gunsole.info({ + message: `Pokemon detail fetched: ${data.name}`, + bucket: "api_request", + context: { + pokemon: data.name, + pokemonId: data.id, + totalTimeMs: Math.round(totalTime), + }, + tags: { api: "pokeapi", action: "detail_fetch_success" }, + traceId, + }); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "Unknown error"; + setError(errorMessage); + + gunsole.error({ + message: `Failed to fetch Pokemon detail: ${pokemonId}`, + bucket: "api_request", + context: { pokemonId, error: errorMessage }, + tags: { api: "pokeapi", action: "detail_fetch_error" }, + traceId, + }); + } finally { + setLoading(false); + } + }, [pokemonId]); + + useEffect(() => { + fetchPokemon(); + }, [fetchPokemon]); + + return ( +
+
+ + ← Back + + + {loading &&

Loading...

} + {error &&

{error}

} + + {pokemon && ( +
+
+
+ Default + Default +
+
+ Shiny + Shiny +
+
+ +

+ {pokemon.name} +

+ +
+

+ ID: # + {pokemon.id} +

+

+ Base XP:{" "} + {pokemon.base_experience} +

+

+ Height:{" "} + {pokemon.height / 10}m +

+

+ Weight:{" "} + {pokemon.weight / 10}kg +

+

+ Types:{" "} + {pokemon.types.map((t) => t.type.name).join(", ")} +

+

+ Abilities:{" "} + {pokemon.abilities + .map((a) => a.ability.name + (a.is_hidden ? " (hidden)" : "")) + .join(", ")} +

+
+ +
+ Stats: + {pokemon.stats.map((s) => ( +
+ + {s.stat.name} + +
+
+
+ + {s.base_stat} + +
+ ))} +
+
+ )} +
+
+ ); +} diff --git a/apps/react-vite/src/assets/gunsole.svg b/apps/react-vite/src/assets/gunsole.svg new file mode 100644 index 0000000..d2e6243 --- /dev/null +++ b/apps/react-vite/src/assets/gunsole.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/react-vite/src/gunsole.ts b/apps/react-vite/src/gunsole.ts new file mode 100644 index 0000000..043f62a --- /dev/null +++ b/apps/react-vite/src/gunsole.ts @@ -0,0 +1,11 @@ +import { createGunsoleClient } from "@gunsole/web"; + +export const gunsole = createGunsoleClient({ + projectId: "test-project-react", + apiKey: "test-api-key", + mode: "local", + env: "development", + appName: "React Vite App", + appVersion: "1.0.0", + defaultTags: { framework: "react", bundler: "vite" }, +}); diff --git a/apps/react-vite/src/main.tsx b/apps/react-vite/src/main.tsx index 81535c9..f7442d9 100644 --- a/apps/react-vite/src/main.tsx +++ b/apps/react-vite/src/main.tsx @@ -1,7 +1,51 @@ +import { + ErrorComponent, + RouterProvider, + createRootRoute, + createRoute, + createRouter, +} from "@tanstack/react-router"; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import "./index.css"; -import App from "./App.tsx"; +import App from "./App"; +import PokemonDetail from "./PokemonDetail"; +import { gunsole } from "./gunsole"; + +const rootRoute = createRootRoute({ + errorComponent: ({ error }) => { + gunsole.fatal({ + message: error.message, + bucket: "fatal", + context: { + name: error.name, + stack: error.stack, + }, + }); + return ; + }, +}); + +const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/", + component: App, +}); + +const pokemonDetailRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/pokemon/$pokemonId", + component: PokemonDetail, +}); + +const routeTree = rootRoute.addChildren([indexRoute, pokemonDetailRoute]); +const router = createRouter({ routeTree }); + +declare module "@tanstack/react-router" { + interface Register { + router: typeof router; + } +} const rootElement = document.getElementById("root"); if (!rootElement) { @@ -10,6 +54,6 @@ if (!rootElement) { createRoot(rootElement).render( - + ); diff --git a/apps/solid-vite/package.json b/apps/solid-vite/package.json index e0ffed9..ff22e52 100644 --- a/apps/solid-vite/package.json +++ b/apps/solid-vite/package.json @@ -9,7 +9,8 @@ "preview": "vite preview" }, "dependencies": { - "@gunsole/core": "workspace:*", + "@gunsole/web": "workspace:*", + "@tanstack/solid-router": "^1.166.2", "solid-js": "^1.9.10", "web-vitals": "^5.1.0" }, diff --git a/apps/solid-vite/src/App.tsx b/apps/solid-vite/src/App.tsx index d3683de..81bc9a9 100644 --- a/apps/solid-vite/src/App.tsx +++ b/apps/solid-vite/src/App.tsx @@ -1,16 +1,9 @@ -import { createGunsoleClient } from "@gunsole/core"; -import { createSignal, onCleanup, onMount, Show, For } from "solid-js"; +import { Link } from "@tanstack/solid-router"; +import { For, Show, createSignal, onMount } from "solid-js"; import { onCLS, onFCP, onINP, onLCP, onTTFB } from "web-vitals"; - -const gunsole = createGunsoleClient({ - projectId: "test-project-solid", - apiKey: "test-api-key", - mode: "local", - env: "development", - appName: "Solid Vite App", - appVersion: "1.0.0", - defaultTags: { framework: "solid", bundler: "vite" }, -}); +import gunsoleLogo from "./assets/gunsole.svg"; +import solidLogo from "./assets/solid.svg"; +import { gunsole } from "./gunsole"; interface Pokemon { id: number; @@ -47,7 +40,6 @@ function getRating( function App() { const [userId, setUserId] = createSignal("user-123"); - const [sessionId, setSessionId] = createSignal("session-abc"); const [pokemonName, setPokemonName] = createSignal("pikachu"); const [pokemon, setPokemon] = createSignal(null); const [loading, setLoading] = createSignal(false); @@ -56,7 +48,6 @@ function App() { const suggestions = ["charizard", "mewtwo", "gengar", "eevee", "snorlax"]; onMount(() => { - // Report web vitals const reportVital = (metric: { name: string; value: number; @@ -82,8 +73,6 @@ function App() { onTTFB(reportVital); gunsole.setUser({ id: userId(), email: "user@example.com" }); - gunsole.setSessionId(sessionId()); - gunsole.attachGlobalErrorHandlers(); gunsole.log({ message: "App mounted", @@ -92,11 +81,6 @@ function App() { }); }); - onCleanup(() => { - gunsole.detachGlobalErrorHandlers(); - gunsole.flush(); - }); - const fetchPokemon = async () => { const traceId = generateTraceId(); const startTime = performance.now(); @@ -137,7 +121,6 @@ function App() { pokemonId: data.id, fetchTimeMs: Math.round(fetchTime), totalTimeMs: Math.round(totalTime), - responseSize: JSON.stringify(data).length, }, tags: { api: "pokeapi", action: "fetch_success", status: "200" }, traceId, @@ -200,6 +183,10 @@ function App() { return (
+
+ Gunsole + Solid +

Gunsole JS - Solid + Vite

@@ -259,6 +246,14 @@ function App() { .join(", ")}

+ + + More Details +
)} @@ -314,9 +309,9 @@ function App() {
- {/* User & Session */} + {/* User */}
-

User & Session

+

User

-
diff --git a/apps/solid-vite/src/PokemonDetail.tsx b/apps/solid-vite/src/PokemonDetail.tsx new file mode 100644 index 0000000..427dae1 --- /dev/null +++ b/apps/solid-vite/src/PokemonDetail.tsx @@ -0,0 +1,162 @@ +import { Link, useParams } from "@tanstack/solid-router"; +import { For, Show, createResource } from "solid-js"; +import { gunsole } from "./gunsole"; + +interface Pokemon { + id: number; + name: string; + height: number; + weight: number; + base_experience: number; + sprites: { + front_default: string; + front_shiny: string; + }; + types: Array<{ type: { name: string } }>; + abilities: Array<{ ability: { name: string }; is_hidden: boolean }>; + stats: Array<{ base_stat: number; stat: { name: string } }>; +} + +async function fetchPokemon(id: string): Promise { + const traceId = `trace-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; + const startTime = performance.now(); + + gunsole.info({ + message: `Fetching Pokemon detail: ${id}`, + bucket: "api_request", + context: { pokemonId: id }, + tags: { api: "pokeapi", action: "detail_fetch_start" }, + traceId, + }); + + const response = await fetch(`https://pokeapi.co/api/v2/pokemon/${id}`); + if (!response.ok) throw new Error(`Pokemon not found: ${id}`); + + const data: Pokemon = await response.json(); + const totalTime = performance.now() - startTime; + + gunsole.info({ + message: `Pokemon detail fetched: ${data.name}`, + bucket: "api_request", + context: { + pokemon: data.name, + pokemonId: data.id, + totalTimeMs: Math.round(totalTime), + }, + tags: { api: "pokeapi", action: "detail_fetch_success" }, + traceId, + }); + + return data; +} + +export default function PokemonDetail() { + const params = useParams({ from: "/pokemon/$pokemonId" }); + const [pokemon] = createResource(() => params().pokemonId, fetchPokemon); + + return ( +
+
+ + ← Back + + + +

Loading...

+
+ + +

{(pokemon.error as Error).message}

+
+ + + {(p) => ( +
+
+
+ Default + Default +
+
+ Shiny + Shiny +
+
+ +

+ {p().name} +

+ +
+

+ ID: #{p().id} +

+

+ Base XP:{" "} + {p().base_experience} +

+

+ Height:{" "} + {p().height / 10}m +

+

+ Weight:{" "} + {p().weight / 10}kg +

+

+ Types:{" "} + {p() + .types.map((t) => t.type.name) + .join(", ")} +

+

+ Abilities:{" "} + {p() + .abilities.map( + (a) => a.ability.name + (a.is_hidden ? " (hidden)" : "") + ) + .join(", ")} +

+
+ +
+ Stats: + + {(s) => ( +
+ + {s.stat.name} + +
+
+
+ + {s.base_stat} + +
+ )} + +
+
+ )} + +
+
+ ); +} diff --git a/apps/solid-vite/src/assets/gunsole.svg b/apps/solid-vite/src/assets/gunsole.svg new file mode 100644 index 0000000..d2e6243 --- /dev/null +++ b/apps/solid-vite/src/assets/gunsole.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/solid-vite/src/gunsole.ts b/apps/solid-vite/src/gunsole.ts new file mode 100644 index 0000000..3984e86 --- /dev/null +++ b/apps/solid-vite/src/gunsole.ts @@ -0,0 +1,11 @@ +import { createGunsoleClient } from "@gunsole/web"; + +export const gunsole = createGunsoleClient({ + projectId: "test-project-solid", + apiKey: "test-api-key", + mode: "local", + env: "development", + appName: "Solid Vite App", + appVersion: "1.0.0", + defaultTags: { framework: "solid", bundler: "vite" }, +}); diff --git a/apps/solid-vite/src/index.tsx b/apps/solid-vite/src/index.tsx index 88fca0c..2e5edcd 100644 --- a/apps/solid-vite/src/index.tsx +++ b/apps/solid-vite/src/index.tsx @@ -1,7 +1,51 @@ +import { + ErrorComponent, + RouterProvider, + createRootRoute, + createRoute, + createRouter, +} from "@tanstack/solid-router"; /* @refresh reload */ import { render } from "solid-js/web"; import "./index.css"; -import App from "./App.tsx"; +import App from "./App"; +import PokemonDetail from "./PokemonDetail"; +import { gunsole } from "./gunsole"; + +const rootRoute = createRootRoute({ + errorComponent: ({ error }) => { + gunsole.fatal({ + message: error.message, + bucket: "fatal", + context: { + name: error.name, + stack: error.stack, + }, + }); + return ; + }, +}); + +const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/", + component: App, +}); + +const pokemonDetailRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/pokemon/$pokemonId", + component: PokemonDetail, +}); + +const routeTree = rootRoute.addChildren([indexRoute, pokemonDetailRoute]); +const router = createRouter({ routeTree }); + +declare module "@tanstack/solid-router" { + interface Register { + router: typeof router; + } +} const root = document.getElementById("root"); @@ -9,4 +53,4 @@ if (!root) { throw new Error("Root element not found"); } -render(() => , root); +render(() => , root); diff --git a/docs/changelog-v0.1.1.md b/docs/changelog-v0.1.1.md new file mode 100644 index 0000000..ad4f3fa --- /dev/null +++ b/docs/changelog-v0.1.1.md @@ -0,0 +1,156 @@ +# Changelog — v0.1.1 Bug Fixes + +All issues addressed on branch `push1kb/bug-fixes-v01`. + +## Bug Fixes + +### #1 — Data loss on flush failure (Critical) + +**Problem:** `flush()` cleared the batch array *before* `sendBatch()` succeeded. If all retries failed, the logs in that batch were permanently lost. + +**Fix:** +- Moved `logsToSend` and the empty check before the `try` block in `client.ts:flush()`. +- On catch, failed logs are re-queued to the front of the batch via `this.batch.unshift(...logsToSend)`. +- `transport.ts:sendBatch()` now throws after all retries are exhausted (previously it silently resolved), so `flush()` can distinguish success from failure. +- 4xx client errors (except 429) return without throwing — they aren't retryable. + +**Files:** `packages/core/src/client.ts`, `packages/core/src/transport.ts` + +--- + +### #5 — No fetch timeout (High) + +**Problem:** No `AbortController` or timeout was used on fetch calls. A slow or unresponsive server would hang requests indefinitely, stalling the entire retry loop. + +**Fix:** +- Added `REQUEST_TIMEOUT_MS = 30_000` constant. +- Each fetch call is wrapped with an `AbortController` and a 30-second `setTimeout` that calls `controller.abort()`. +- `clearTimeout` in a `finally` block prevents timer leaks. +- Aborted requests are treated as network errors and trigger the existing retry logic. + +**Files:** `packages/core/src/transport.ts` + +--- + +### #12 — No destroyed state guard (Medium) + +**Problem:** After `destroy()` was called, `log()` still queued logs into the batch. These logs would never be flushed since the timer was stopped. + +**Fix:** +- Added a `destroyed` boolean flag to `GunsoleClient`. +- After `destroy()`, `log()`, `info()`, `debug()`, `warn()`, `error()`, `setUser()`, and `setSessionId()` all no-op. +- `destroy()` is idempotent — calling it multiple times is safe. +- `destroy()` clears `user` and `sessionId` internally before marking as destroyed. + +**Files:** `packages/core/src/client.ts` + +--- + +### #6 — No validation of batchSize or flushInterval (High) + +**Problem:** `normalizeConfig()` did not validate that `batchSize > 0` or `flushInterval > 0`. A `batchSize: 0` would flush on every log call, and `flushInterval: 0` would create a runaway `setInterval`. + +**Fix:** +- `normalizeConfig()` now throws if `batchSize < 1` or `flushInterval < 100ms`. + +**Files:** `packages/core/src/config.ts` + +--- + +### #4 — README log() example is incorrect (Critical) + +**Problem:** The README showed `level` as a field inside `LogOptions`, but `level` is not part of `LogOptions` — it's the first argument to `log()`. + +**Fix:** +- First example uses single-arg form: `gunsole.log({ bucket, message })` (defaults to info). +- Second example uses two-arg form: `gunsole.log("error", { bucket, message, context, tags })`. + +**Files:** `packages/core/README.md` + +--- + +## Enhancements + +### #15 — Remove unused LogEntry export + +**Problem:** `LogEntry` was exported from `index.ts` but never used anywhere in source or tests. + +**Fix:** Removed `LogEntry` interface from `types.ts` and its export from `index.ts`. + +**Files:** `packages/core/src/types.ts`, `packages/core/src/index.ts` + +--- + +### #16 — Remove dead createMockGunsoleClient helper + +**Problem:** `createMockGunsoleClient()` in `tests/mocks.ts` was never imported by any test file. + +**Fix:** Deleted `tests/mocks.ts`. + +**Files:** `packages/core/tests/mocks.ts` (deleted) + +--- + +### #13 — Hardcoded error handler bucket names undocumented + +**Problem:** Global error handlers used hardcoded bucket names (`unhandled_rejection`, `global_error`, `uncaught_exception`) that were not documented anywhere. + +**Fix:** Added documentation in the README under "Global Error Handlers" listing all three built-in bucket names and which environments they apply to. + +**Files:** `packages/core/README.md` + +--- + +### #11 — No way to clear user or session + +**Problem:** `setUser()` and `setSessionId()` existed but there was no way to clear them. + +**Fix:** `destroy()` now clears `user` and `sessionId` internally. No public clear API was added — these are implementation details that get cleaned up on teardown. + +**Files:** `packages/core/src/client.ts` + +--- + +### #10 — README missing documentation for buckets and fetch options + +**Problem:** The Options table omitted `buckets` and `fetch` config properties. Also originally mentioned `isDebug` which no longer exists. + +**Fix:** +- Added `buckets` and `fetch` to the Options list in the README. +- Updated issue title to remove `isDebug` reference (config option was removed in prior work). + +**Files:** `packages/core/README.md` + +--- + +### #8 — Add engines field to SDK package.json + +**Problem:** The SDK requires Node.js 18+ for native `fetch` and Compression Streams API, but the published package had no `engines` field. + +**Fix:** Added `"engines": { "node": ">=18.0.0" }` to `packages/core/package.json`. + +**Files:** `packages/core/package.json` + +--- + +### #9 — No tests for gzip compression path (Closed as irrelevant) + +**Problem:** All tests previously used `isDebug: true`, which disabled compression. + +**Resolution:** `isDebug` was removed from the transport in prior work on this branch. All tests now go through the gzip path and decompress with `gunzip()` to verify payloads. Closed without changes. + +--- + +## Other Changes on This Branch + +- Removed `isDebug` from `Transport` constructor — compression is always enabled. +- Switched bare `.js` import extensions to extensionless imports across source and tests. +- Used `isDev()` helper consistently for dev-only `console.warn` calls. +- Updated `LICENSE` copyright year to 2026. +- Added `publishConfig.access`, `homepage`, and TypeScript `peerDependencies` to `package.json`. + +## Test Summary + +- **49 tests passing** across 4 test files +- TypeScript typecheck clean +- New tests added for: log re-queuing on flush failure, AbortSignal on fetch, destroyed state guard, batchSize/flushInterval validation diff --git a/package.json b/package.json index d9bc506..be0a6e4 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "format": "biome format --write .", "check": "biome check .", "check:fix": "biome check --write .", - "typecheck": "pnpm -r typecheck" + "typecheck": "pnpm -r typecheck", + "dev:apps": "pnpm --filter solid-vite dev --port 4001 & pnpm --filter react-vite dev --port 4002 & pnpm --filter angular-app start --port 4003 & pnpm --filter nextjs-app dev -p 4004 & wait" }, "devDependencies": { "@biomejs/biome": "^1.9.4", diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 2a1c1f0..79e146b 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.0] - 2026-03-08 + +### Added + +- `fatal()` log level for unrecoverable errors +- `drainBatch()` method to manually flush the log batch +- `projectId`, `apiKey`, `logEndpoint` read-only accessors on the client +- `isDisabled` config option to fully noop the SDK +- `apiKey` is now optional for `local` and `desktop` modes +- `FetchFunction` type export for custom fetch consumers +- Fetch timeout to prevent hanging requests + +### Fixed + +- `sessionId` generation in SSR environments +- Data loss on flush failure — logs are re-queued on failed sends +- OOM protection with batch size cap +- Thundering herd mitigation with jittered retry backoff + ## [0.1.0] - 2026-02-23 ### Added diff --git a/packages/core/README.md b/packages/core/README.md index c0d7b81..a69e7ee 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -32,16 +32,14 @@ const gunsole = createGunsoleClient({ ### Logging ```typescript -// Simple log +// Simple log (defaults to info level) gunsole.log({ - level: "info", bucket: "user_action", message: "User clicked button", }); -// Log with context and tags -gunsole.log({ - level: "error", +// Log with explicit level +gunsole.log("error", { bucket: "api_error", message: "Failed to fetch user data", context: { @@ -86,6 +84,12 @@ gunsole.attachGlobalErrorHandlers(); gunsole.detachGlobalErrorHandlers(); ``` +The global error handlers log to the following built-in buckets: + +- `unhandled_rejection` — unhandled promise rejections (browser and Node.js) +- `global_error` — uncaught errors via `window.onerror` (browser only) +- `uncaught_exception` — uncaught exceptions via `process.on('uncaughtException')` (Node.js only) + ### Manual Flush ```typescript @@ -111,8 +115,10 @@ await gunsole.flush(); - `appName` (optional): Application name - `appVersion` (optional): Application version - `defaultTags` (optional): Default tags applied to all logs -- `batchSize` (optional): Number of logs to batch before sending (default: 10) -- `flushInterval` (optional): Auto-flush interval in ms (default: 5000) +- `batchSize` (optional): Number of logs to batch before sending (default: 10, min: 1) +- `flushInterval` (optional): Auto-flush interval in ms (default: 5000, min: 100) +- `buckets` (optional): Array of bucket names to generate typed accessor methods (e.g., `["payment", "auth"]`) +- `fetch` (optional): Custom `fetch` implementation (default: global `fetch`; requires Node.js 18+) ### Cleanup diff --git a/packages/core/package.json b/packages/core/package.json index 3faa0cb..846c613 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,16 +1,22 @@ { "name": "@gunsole/core", - "version": "0.1.0", + "version": "0.2.0", "description": "Gunsole JavaScript/TypeScript SDK for browser and Node.js", "type": "module", + "sideEffects": false, "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "require": "./dist/index.cjs" + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } } }, "files": ["dist", "LICENSE", "README.md"], @@ -43,7 +49,7 @@ "@biomejs/biome": "^1.9.4", "@types/node": "^20.11.5", "tsup": "^8.0.1", - "typescript": "^5.3.3", + "typescript": "^5.4.0", "vitest": "^1.2.1" }, "peerDependencies": { @@ -53,5 +59,8 @@ "typescript": { "optional": true } + }, + "engines": { + "node": ">=18.0.0" } } diff --git a/packages/core/src/buckets.ts b/packages/core/src/buckets.ts index 228b565..abd3009 100644 --- a/packages/core/src/buckets.ts +++ b/packages/core/src/buckets.ts @@ -19,6 +19,7 @@ export interface BucketLogger< debug(message: string, options?: BucketLogOptions): void; warn(message: string, options?: BucketLogOptions): void; error(message: string, options?: BucketLogOptions): void; + fatal(message: string, options?: BucketLogOptions): void; } /** @@ -38,10 +39,17 @@ export type ReservedBucketName = | "debug" | "warn" | "error" + | "fatal" | "setUser" | "setSessionId" + | "setDebug" | "flush" | "destroy" + | "isDestroyed" + | "drainBatch" + | "projectId" + | "apiKey" + | "logEndpoint" | "attachGlobalErrorHandlers" | "detachGlobalErrorHandlers"; @@ -58,10 +66,17 @@ const RESERVED_NAMES: Set = new Set([ "debug", "warn", "error", + "fatal", "setUser", "setSessionId", + "setDebug", "flush", "destroy", + "isDestroyed", + "drainBatch", + "projectId", + "apiKey", + "logEndpoint", "attachGlobalErrorHandlers", "detachGlobalErrorHandlers", ]); @@ -92,6 +107,7 @@ function createBucketLogger< logger.debug = (message, options?) => logAtLevel("debug", message, options); logger.warn = (message, options?) => logAtLevel("warn", message, options); logger.error = (message, options?) => logAtLevel("error", message, options); + logger.fatal = (message, options?) => logAtLevel("fatal", message, options); return logger; } @@ -112,8 +128,10 @@ export function attachBuckets< `Bucket name "${name}" conflicts with a reserved GunsoleClient method` ); } - (client as unknown as Record)[name] = - createBucketLogger(client, name); + (client as unknown as Record)[name] = createBucketLogger( + client, + name + ); } return client as GunsoleClient & WithBuckets; } diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index e425fed..74d7805 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -2,12 +2,20 @@ import { normalizeConfig } from "./config"; import { Transport } from "./transport"; import type { GunsoleClientConfig, + GunsoleHooks, InternalLogEntry, LogLevel, LogOptions, UserInfo, ValidTagSchema, } from "./types"; +import { isDev } from "./utils/env"; +import { TokenBucket } from "./utils/rate-limiter"; + +/** + * Maximum number of flush attempts before dropping a log entry + */ +const MAX_FLUSH_ATTEMPTS = 10; /** * Global error handler state @@ -24,10 +32,7 @@ interface GlobalErrorHandlers { * Gunsole client for sending logs and events */ export class GunsoleClient< - Tags extends Record & ValidTagSchema = Record< - string, - string - >, + Tags extends Record & ValidTagSchema = Record, > { private config: ReturnType; private transport: Transport; @@ -37,25 +42,87 @@ export class GunsoleClient< private sessionId: string | null = null; private globalHandlers: GlobalErrorHandlers = { attached: false }; private readonly disabled: boolean; + private destroyed = false; + #isDebug: boolean; + private rateLimiter: TokenBucket | null = null; constructor(config: GunsoleClientConfig) { this.config = normalizeConfig(config); this.disabled = config.isDisabled ?? false; + this.#isDebug = this.config.isDebug; this.transport = new Transport( this.config.endpoint, this.config.apiKey, this.config.projectId, this.config.fetch, - config.isDebug ?? false + () => this.#shouldDebug() ); if (this.disabled) { return; } + this.sessionId = config.sessionId ?? crypto.randomUUID(); + + // Set up rate limiter (0 = disabled) + if (this.config.maxLogRate > 0) { + this.rateLimiter = new TokenBucket( + this.config.maxBurst, + this.config.maxLogRate + ); + } + this.startFlushTimer(); } + /** + * Whether this client has been destroyed + */ + get isDestroyed(): boolean { + return this.destroyed; + } + + /** + * Read-only project ID + */ + get projectId(): string { + return this.config.projectId; + } + + /** + * Read-only API key + */ + get apiKey(): string { + return this.config.apiKey; + } + + /** + * Read-only log endpoint URL + */ + get logEndpoint(): string { + return `${this.config.endpoint}/logs`; + } + + /** + * Atomically drain and return all pending log entries + */ + drainBatch(): InternalLogEntry[] { + const entries = this.batch; + this.batch = []; + return entries; + } + + /** + * Enable or disable debug mode at runtime + */ + setDebug(enabled: boolean): void { + this.#isDebug = enabled; + } + + #shouldDebug(): boolean { + return this.#isDebug || isDev(); + } + /** * Log a message. Defaults to info level. */ @@ -65,22 +132,36 @@ export class GunsoleClient< levelOrOptions: LogLevel | LogOptions, maybeOptions?: LogOptions ): void { - if (this.disabled) { + if (this.disabled || this.destroyed) { return; } const level: LogLevel = typeof levelOrOptions === "string" ? levelOrOptions : "info"; - const options: LogOptions = - typeof levelOrOptions === "string" ? maybeOptions! : levelOrOptions; + const options: LogOptions | undefined = + typeof levelOrOptions === "string" ? maybeOptions : levelOrOptions; + + // ? This will never be undefined, it is just a type assertion + if (!options) { + return; + } + + // Rate limiting check + if (this.rateLimiter && !this.rateLimiter.tryConsume()) { + if (this.#shouldDebug()) { + console.warn("[Gunsole] Log dropped: rate limit exceeded"); + } + return; + } + try { - const internalEntry: InternalLogEntry = { + let internalEntry: InternalLogEntry = { level, bucket: options.bucket, message: options.message, context: options.context, timestamp: Date.now(), traceId: options.traceId, - userId: this.user?.id, + userId: options.userId ?? this.user?.id, sessionId: this.sessionId ?? undefined, env: this.config.env || undefined, appName: this.config.appName || undefined, @@ -93,17 +174,31 @@ export class GunsoleClient< }, }; + // beforeSend hook + if (this.config.beforeSend) { + try { + const result = this.config.beforeSend(internalEntry); + if (result === null) { + return; + } + internalEntry = result; + } catch { + // Keep original entry on throw + } + } + this.batch.push(internalEntry); + this.enforceQueueCap(); + + // onLog hook + this.#invokeHook("onLog", internalEntry); // Flush if batch is full if (this.batch.length >= this.config.batchSize) { this.flush(); } - } catch (error) { - // Silently swallow errors - never crash the host app - if (process.env.NODE_ENV === "development") { - console.warn("[Gunsole] Error in log():", error); - } + } catch { + // Silently swallow — never crash the host app } } @@ -126,36 +221,38 @@ export class GunsoleClient< this.log("error", options); } + /** + * Log a fatal-level message (unrecoverable errors) + */ + fatal(options: LogOptions): void { + this.log("fatal", options); + } + /** * Set user information */ setUser(user: UserInfo): void { - if (this.disabled) { + if (this.disabled || this.destroyed) { return; } - try { - this.user = user; - } catch (error) { - if (process.env.NODE_ENV === "development") { - console.warn("[Gunsole] Error in setUser():", error); - } - } + this.user = user; + } + + /** + * Get current session ID + */ + getSessionId(): string | null { + return this.sessionId; } /** * Set session ID */ setSessionId(sessionId: string): void { - if (this.disabled) { + if (this.disabled || this.destroyed) { return; } - try { - this.sessionId = sessionId; - } catch (error) { - if (process.env.NODE_ENV === "development") { - console.warn("[Gunsole] Error in setSessionId():", error); - } - } + this.sessionId = sessionId; } /** @@ -165,18 +262,40 @@ export class GunsoleClient< if (this.disabled) { return; } - try { - if (this.batch.length === 0) { - return; - } + const logsToSend = [...this.batch]; + if (logsToSend.length === 0) { + return; + } + this.batch = []; - const logsToSend = [...this.batch]; - this.batch = []; + // Increment flush attempt counter on each entry + for (const entry of logsToSend) { + entry._flushAttempts = (entry._flushAttempts ?? 0) + 1; + } + try { await this.transport.sendBatch(logsToSend); + this.#invokeHook("onFlush", logsToSend, true); } catch (error) { - // Silently swallow errors - if (process.env.NODE_ENV === "development") { + this.#invokeHook("onFlush", logsToSend, false); + this.#invokeHook("onError", error); + + // Filter out entries that have exceeded the retry cap + const retriable = logsToSend.filter( + (entry) => (entry._flushAttempts ?? 0) < MAX_FLUSH_ATTEMPTS + ); + const dropped = logsToSend.length - retriable.length; + + if (dropped > 0 && this.#shouldDebug()) { + console.warn( + `[Gunsole] Dropped ${dropped} log(s) after ${MAX_FLUSH_ATTEMPTS} flush attempts` + ); + } + + // Re-queue surviving logs so they can be retried on next flush + this.batch.unshift(...retriable); + this.enforceQueueCap(); + if (this.#shouldDebug()) { console.warn("[Gunsole] Error in flush():", error); } } @@ -198,7 +317,7 @@ export class GunsoleClient< this.globalHandlers.unhandledRejection = ( event: PromiseRejectionEvent ) => { - this.error({ + this.fatal({ message: "Unhandled promise rejection", bucket: "unhandled_rejection", context: { @@ -217,7 +336,7 @@ export class GunsoleClient< // Global errors this.globalHandlers.error = (event: ErrorEvent) => { - this.error({ + this.fatal({ message: event.message || "Global error", bucket: "global_error", context: { @@ -248,7 +367,7 @@ export class GunsoleClient< reason: unknown, _promise: Promise ) => { - this.error({ + this.fatal({ message: "Unhandled promise rejection", bucket: "unhandled_rejection", context: { @@ -266,7 +385,7 @@ export class GunsoleClient< }; this.globalHandlers.uncaughtException = (error: Error) => { - this.error({ + this.fatal({ message: error.message, bucket: "uncaught_exception", context: { @@ -285,7 +404,7 @@ export class GunsoleClient< this.globalHandlers.attached = true; } catch (error) { - if (process.env.NODE_ENV === "development") { + if (this.#shouldDebug()) { console.warn("[Gunsole] Error in attachGlobalErrorHandlers():", error); } } @@ -329,12 +448,40 @@ export class GunsoleClient< this.globalHandlers = { attached: false }; } catch (error) { - if (process.env.NODE_ENV === "development") { + if (this.#shouldDebug()) { console.warn("[Gunsole] Error in detachGlobalErrorHandlers():", error); } } } + /** + * Invoke a hook safely (never throws) + */ + #invokeHook( + name: K, + ...args: Parameters> + ): void { + try { + const hook = this.config.hooks[name]; + if (hook) { + // biome-ignore lint/suspicious/noExplicitAny: hook signatures vary + (hook as any)(...args); + } + } catch { + // Hooks must never crash the SDK + } + } + + /** + * Drop oldest entries when the queue exceeds maxQueueSize + */ + private enforceQueueCap(): void { + const overflow = this.batch.length - this.config.maxQueueSize; + if (overflow > 0) { + this.batch.splice(0, overflow); + } + } + /** * Start the automatic flush timer */ @@ -362,8 +509,14 @@ export class GunsoleClient< * Cleanup resources. Awaiting ensures remaining logs are flushed. */ async destroy(): Promise { + if (this.destroyed) { + return; + } this.stopFlushTimer(); this.detachGlobalErrorHandlers(); await this.flush(); + this.user = null; + this.sessionId = null; + this.destroyed = true; } } diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index a1e5840..5b9121a 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -15,6 +15,7 @@ const DEFAULT_ENDPOINTS: Record = { const DEFAULT_CONFIG = { batchSize: 10, flushInterval: 5000, + maxQueueSize: 1000, }; /** @@ -35,14 +36,35 @@ export function resolveEndpoint( */ export function normalizeConfig(config: GunsoleClientConfig): Omit< Required, - "fetch" + "fetch" | "beforeSend" | "sessionId" > & { endpoint: string; fetch?: GunsoleClientConfig["fetch"]; + beforeSend?: GunsoleClientConfig["beforeSend"]; + sessionId?: string; } { if (!config.projectId) { throw new Error("projectId is required"); } + if (config.batchSize !== undefined && config.batchSize < 1) { + throw new Error("batchSize must be at least 1"); + } + if (config.flushInterval !== undefined && config.flushInterval < 100) { + throw new Error("flushInterval must be at least 100ms"); + } + if (config.maxQueueSize !== undefined && config.maxQueueSize < 1) { + throw new Error("maxQueueSize must be at least 1"); + } + if ( + config.maxLogRate !== undefined && + config.maxLogRate !== 0 && + config.maxLogRate < 1 + ) { + throw new Error("maxLogRate must be 0 (disabled) or at least 1"); + } + if (config.maxBurst !== undefined && config.maxBurst < 1) { + throw new Error("maxBurst must be at least 1"); + } return { projectId: config.projectId, apiKey: config.apiKey ?? "", @@ -54,9 +76,15 @@ export function normalizeConfig(config: GunsoleClientConfig): Omit< defaultTags: config.defaultTags ?? {}, batchSize: config.batchSize ?? DEFAULT_CONFIG.batchSize, flushInterval: config.flushInterval ?? DEFAULT_CONFIG.flushInterval, + maxQueueSize: config.maxQueueSize ?? DEFAULT_CONFIG.maxQueueSize, fetch: config.fetch, - isDebug: config.isDebug ?? false, isDisabled: config.isDisabled ?? false, buckets: config.buckets ?? [], + isDebug: config.isDebug ?? false, + maxLogRate: config.maxLogRate ?? 10, + maxBurst: config.maxBurst ?? 100, + beforeSend: config.beforeSend, + hooks: config.hooks ?? {}, + sessionId: config.sessionId, }; } diff --git a/packages/core/src/factory.ts b/packages/core/src/factory.ts index eca1d39..2a5fe6e 100644 --- a/packages/core/src/factory.ts +++ b/packages/core/src/factory.ts @@ -32,16 +32,10 @@ type BaseConfig = Omit; * ``` */ export function createGunsoleClient< - Tags extends Record & ValidTagSchema = Record< - string, - string - >, + Tags extends Record & ValidTagSchema = Record, >(config: BaseConfig): GunsoleClient; export function createGunsoleClient< - Tags extends Record & ValidTagSchema = Record< - string, - string - >, + Tags extends Record & ValidTagSchema = Record, const Buckets extends readonly string[] = readonly string[], >( config: BaseConfig & { @@ -49,10 +43,7 @@ export function createGunsoleClient< } ): GunsoleClient & WithBuckets; export function createGunsoleClient< - Tags extends Record & ValidTagSchema = Record< - string, - string - >, + Tags extends Record & ValidTagSchema = Record, const Buckets extends readonly string[] = readonly string[], >( config: GunsoleClientConfig & { buckets?: Buckets } @@ -62,6 +53,5 @@ export function createGunsoleClient< if (buckets.length > 0) { return attachBuckets(client, buckets); } - return client as GunsoleClient & - WithBuckets; + return client as GunsoleClient & WithBuckets; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index cfb89dd..646fe28 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -7,7 +7,10 @@ */ export { createGunsoleClient } from "./factory"; +export { createGunsoleClient as createGunsole } from "./factory"; +export { createGunsoleClient as createGunsoleServer } from "./factory"; export { GunsoleClient } from "./client"; +export { SDK_VERSION } from "./version"; export type { BucketLogOptions, BucketLogger, @@ -19,7 +22,8 @@ export type { ClientMode, FetchFunction, GunsoleClientConfig, - LogEntry, + GunsoleHooks, + InternalLogEntry, LogLevel, LogOptions, ReservedTagKey, diff --git a/packages/core/src/transport.ts b/packages/core/src/transport.ts index da1d0c5..d7e89b4 100644 --- a/packages/core/src/transport.ts +++ b/packages/core/src/transport.ts @@ -1,5 +1,6 @@ -import type { BatchPayload, FetchFunction, InternalLogEntry } from "./types.js"; -import { getFetch } from "./utils/env.js"; +import type { BatchPayload, FetchFunction, InternalLogEntry } from "./types"; +import { getFetch } from "./utils/env"; +import { SDK_VERSION } from "./version"; /** * Maximum number of retry attempts @@ -11,11 +12,18 @@ const MAX_RETRIES = 3; */ const BASE_DELAY_MS = 1000; +/** + * Timeout for each fetch request (milliseconds) + */ +const REQUEST_TIMEOUT_MS = 30_000; + /** * Calculate exponential backoff delay */ function calculateBackoffDelay(attempt: number): number { - return BASE_DELAY_MS * 2 ** attempt; + const base = BASE_DELAY_MS * 2 ** attempt; + const jitter = 0.5 + Math.random(); + return Math.round(base * jitter); } /** @@ -36,22 +44,6 @@ async function gzipCompress(input: string): Promise { return new Uint8Array(await new Response(stream).arrayBuffer()); } -/** - * Minify and optionally compress payload - */ -async function compressPayload( - payload: BatchPayload, - isDebug: boolean -): Promise { - const minified = JSON.stringify(payload); - - if (isDebug) { - return minified; - } - - return gzipCompress(minified); -} - /** * Transport layer for sending logs to the Gunsole API */ @@ -60,20 +52,20 @@ export class Transport { private apiKey: string; private projectId: string; private fetch: FetchFunction; - private isDebug: boolean; + private isDebugFn: () => boolean; constructor( endpoint: string, apiKey: string, projectId: string, fetch?: FetchFunction, - isDebug = false + isDebugFn?: () => boolean ) { this.endpoint = endpoint; this.apiKey = apiKey; this.projectId = projectId; this.fetch = fetch ?? getFetch(); - this.isDebug = isDebug; + this.isDebugFn = isDebugFn ?? (() => false); } /** @@ -87,61 +79,88 @@ export class Transport { const payload: BatchPayload = { projectId: this.projectId, - logs, + logs: logs.map(({ _flushAttempts: _, ...rest }) => rest), }; - let lastError: Error | null = null; + const debug = this.isDebugFn(); + let lastError: unknown; for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { try { - const body = await compressPayload(payload, this.isDebug); + const json = JSON.stringify(payload, (_key, value) => + typeof value === "bigint" ? value.toString() : value + ); + + let body: BodyInit; const headers: Record = { "Content-Type": "application/json", + "X-Gunsole-SDK-Version": SDK_VERSION, }; - if (this.apiKey) { - headers["Authorization"] = `Bearer ${this.apiKey}`; + if (debug) { + body = json; + } else { + body = (await gzipCompress(json)) as BodyInit; + headers["Content-Encoding"] = "gzip"; } - // Only set Content-Encoding if not in debug mode - if (!this.isDebug) { - headers["Content-Encoding"] = "gzip"; + if (this.apiKey) { + headers.Authorization = `Bearer ${this.apiKey}`; } - const response = await this.fetch(`${this.endpoint}/logs`, { - method: "POST", - headers, - body: body as BodyInit, - }); + const controller = new AbortController(); + const timeoutId = setTimeout( + () => controller.abort(), + REQUEST_TIMEOUT_MS + ); + + let response: Response; + try { + response = await this.fetch(`${this.endpoint}/logs`, { + method: "POST", + headers, + body, + signal: controller.signal, + }); + } finally { + clearTimeout(timeoutId); + } if (response.ok) { - return; // Success + return; } - // Non-2xx response - const errorText = await response.text().catch(() => "Unknown error"); - lastError = new Error(`HTTP ${response.status}: ${errorText}`); + // HTTP 413: payload too large — split and retry + if (response.status === 413) { + if (logs.length > 1) { + const mid = Math.ceil(logs.length / 2); + await this.sendBatch(logs.slice(0, mid)); + await this.sendBatch(logs.slice(mid)); + } + // Single entry too large — silently drop + return; + } // Don't retry client errors (4xx) except 429 (rate limited) - if (response.status >= 400 && response.status < 500 && response.status !== 429) { - break; + if ( + response.status >= 400 && + response.status < 500 && + response.status !== 429 + ) { + return; } + + lastError = new Error(`HTTP ${response.status}`); } catch (error) { - lastError = error instanceof Error ? error : new Error(String(error)); + lastError = error; } - // If not the last attempt, wait before retrying if (attempt < MAX_RETRIES - 1) { const delay = calculateBackoffDelay(attempt); await sleep(delay); } } - // All retries failed - silently swallow the error - // This ensures the SDK never crashes the host application - // In production, you might want to log this to console in debug mode - if (process.env.NODE_ENV === "development") { - console.warn("[Gunsole] Failed to send logs after retries:", lastError); - } + throw lastError; } } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 5b6d6c6..0d0e3cd 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -9,7 +9,7 @@ export type FetchFunction = ( /** * Log level enumeration */ -export type LogLevel = "info" | "debug" | "warn" | "error"; +export type LogLevel = "info" | "debug" | "warn" | "error" | "fatal"; /** * Client mode determines the default endpoint @@ -41,7 +41,7 @@ export type ReservedTagKey = export type ValidTagSchema = { [K in ReservedTagKey]?: never }; /** - * Options for logging methods (log, info, debug, warn, error) + * Options for logging methods (log, info, debug, warn, error, fatal) */ export interface LogOptions< Tags extends Record = Record, @@ -56,24 +56,8 @@ export interface LogOptions< tags?: Partial | TagEntry[]; /** Trace ID for distributed tracing */ traceId?: string; -} - -/** - * Log entry structure - */ -export interface LogEntry { - /** Bucket/category for the log */ - bucket: string; - /** Human-readable message */ - message: string; - /** Additional context data */ - context?: Record; - /** Tags for filtering/grouping */ - tags?: Record; - /** Timestamp (Unix milliseconds, SDK fills if not provided) */ - timestamp?: number; - /** Trace ID for distributed tracing */ - traceId?: string; + /** Per-call user ID override (useful for server-side contexts) */ + userId?: string; } /** @@ -90,6 +74,18 @@ export interface UserInfo { traits?: Record; } +/** + * Lifecycle hooks for the Gunsole client + */ +export interface GunsoleHooks { + /** Called after a log entry is added to the batch */ + onLog?: (entry: InternalLogEntry) => void; + /** Called after a flush attempt (success or failure) */ + onFlush?: (logs: InternalLogEntry[], success: boolean) => void; + /** Called when a flush error occurs */ + onError?: (error: unknown) => void; +} + /** * Client configuration options */ @@ -116,12 +112,24 @@ export interface GunsoleClientConfig { flushInterval?: number; /** Custom fetch implementation (default: uses global fetch or throws error) */ fetch?: FetchFunction; - /** Debug mode - when true, disables gzip compression for readable network payloads */ - isDebug?: boolean; + /** Maximum number of log entries held in the queue (default: 1000). Oldest entries are dropped when exceeded. */ + maxQueueSize?: number; /** When true, all SDK methods become no-ops. Useful for disabling in specific environments. */ isDisabled?: boolean; /** Typed bucket names for bucket accessor methods */ buckets?: readonly string[]; + /** Enable debug mode (disables gzip, enables console warnings). Defaults to isDev() check. */ + isDebug?: boolean; + /** Maximum log rate per second (default: 10, 0 to disable) */ + maxLogRate?: number; + /** Maximum burst size for rate limiting (default: 100) */ + maxBurst?: number; + /** Transform or filter log entries before batching. Return null to drop. */ + beforeSend?: (entry: InternalLogEntry) => InternalLogEntry | null; + /** Lifecycle hooks */ + hooks?: GunsoleHooks; + /** Explicit session ID (overrides auto-generated UUID). Useful for server-side contexts where a session ID is read from a cookie. */ + sessionId?: string; } /** @@ -152,6 +160,8 @@ export interface InternalLogEntry { appName?: string; /** Application version */ appVersion?: string; + /** @internal */ + _flushAttempts?: number; } /** diff --git a/packages/core/src/utils/env.ts b/packages/core/src/utils/env.ts index 3fd5bfe..501c1cd 100644 --- a/packages/core/src/utils/env.ts +++ b/packages/core/src/utils/env.ts @@ -20,6 +20,15 @@ export function isNode(): boolean { ); } +/** + * Check if running in a development environment + */ +export function isDev(): boolean { + return ( + typeof process !== "undefined" && process.env?.NODE_ENV === "development" + ); +} + /** * Get fetch implementation (browser or Node.js) * If a custom fetch is provided, it will be used instead. @@ -42,5 +51,11 @@ export function getFetch(customFetch?: FetchFunction): FetchFunction { "fetch is not available. Please use Node.js 18+ or provide a custom fetch implementation in the config" ); } - throw new Error("Unsupported environment: neither browser nor Node.js"); + if (typeof globalThis.fetch !== "undefined") { + return globalThis.fetch; + } + + throw new Error( + "Unsupported environment: fetch is not available. Provide a custom fetch implementation in the config." + ); } diff --git a/packages/core/src/utils/rate-limiter.ts b/packages/core/src/utils/rate-limiter.ts new file mode 100644 index 0000000..e1f38db --- /dev/null +++ b/packages/core/src/utils/rate-limiter.ts @@ -0,0 +1,37 @@ +/** + * Token-bucket rate limiter. + * + * Tokens refill at `refillRate` per second up to `maxTokens`. + * Each `tryConsume()` call removes one token; returns false when empty. + */ +export class TokenBucket { + private tokens: number; + private lastRefill: number; + + constructor( + private readonly maxTokens: number, + private readonly refillRate: number + ) { + this.tokens = maxTokens; + this.lastRefill = Date.now(); + } + + tryConsume(): boolean { + this.refill(); + if (this.tokens >= 1) { + this.tokens -= 1; + return true; + } + return false; + } + + private refill(): void { + const now = Date.now(); + const elapsed = (now - this.lastRefill) / 1000; + this.tokens = Math.min( + this.maxTokens, + this.tokens + elapsed * this.refillRate + ); + this.lastRefill = now; + } +} diff --git a/packages/core/src/version.ts b/packages/core/src/version.ts new file mode 100644 index 0000000..3842d8b --- /dev/null +++ b/packages/core/src/version.ts @@ -0,0 +1,4 @@ +declare const __SDK_VERSION__: string; + +export const SDK_VERSION: string = + typeof __SDK_VERSION__ !== "undefined" ? __SDK_VERSION__ : "0.0.0-dev"; diff --git a/packages/core/tests/client.test.ts b/packages/core/tests/client.test.ts index 677535a..0c74db9 100644 --- a/packages/core/tests/client.test.ts +++ b/packages/core/tests/client.test.ts @@ -1,6 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { createGunsoleClient } from "../src/index.js"; -import type { GunsoleClientConfig, LogLevel } from "../src/types.js"; +import { createGunsoleClient } from "../src/index"; +import type { GunsoleClientConfig, LogLevel } from "../src/types"; + +async function gunzip(data: Uint8Array): Promise { + const stream = new Blob([data]) + .stream() + .pipeThrough(new DecompressionStream("gzip")); + return new Response(stream).text(); +} // Mock fetch global.fetch = vi.fn(); @@ -14,7 +21,6 @@ describe("GunsoleClient", () => { projectId: "test-project", apiKey: "test-api-key", mode: "cloud", - isDebug: true, }; client = createGunsoleClient(config); vi.clearAllMocks(); @@ -84,7 +90,7 @@ describe("GunsoleClient", () => { expect(mockFetch).toHaveBeenCalledTimes(1); const call = mockFetch.mock.calls[0]; - const body = JSON.parse(call[1]?.body as string); + const body = JSON.parse(await gunzip(call[1]?.body as Uint8Array)); expect(body.logs[0].userId).toBe("user-123"); expect(body.logs[0].sessionId).toBe("session-456"); }); @@ -110,7 +116,7 @@ describe("GunsoleClient", () => { await taggedClient.flush(); const call = mockFetch.mock.calls[0]; - const body = JSON.parse(call[1]?.body as string); + const body = JSON.parse(await gunzip(call[1]?.body as Uint8Array)); expect(body.logs[0].tags).toEqual({ env: "test", region: "us-east", @@ -131,7 +137,7 @@ describe("GunsoleClient", () => { it("should handle flush errors gracefully", async () => { const mockFetch = vi.mocked(global.fetch); - mockFetch.mockRejectedValueOnce(new Error("Network error")); + mockFetch.mockRejectedValue(new Error("Network error")); client.log({ message: "Test log", @@ -141,6 +147,33 @@ describe("GunsoleClient", () => { // Should not throw await expect(client.flush()).resolves.toBeUndefined(); }); + + it("should re-queue logs when flush fails", async () => { + const mockFetch = vi.mocked(global.fetch); + // First flush: all retries fail + mockFetch.mockRejectedValue(new Error("Network error")); + + client.log({ + message: "Important log", + bucket: "test", + }); + + await client.flush(); + + // Logs should be re-queued — a second flush should retry them + mockFetch.mockReset(); + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + } as Response); + + await client.flush(); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const call = mockFetch.mock.calls[0]; + const body = JSON.parse(await gunzip(call[1]?.body as Uint8Array)); + expect(body.logs[0].message).toBe("Important log"); + }); }); describe("Bucket methods", () => { @@ -151,7 +184,6 @@ describe("Bucket methods", () => { projectId: "test-project", apiKey: "test-api-key", mode: "cloud", - isDebug: true, }; vi.clearAllMocks(); }); @@ -188,7 +220,7 @@ describe("Bucket methods", () => { await client.flush(); const call = mockFetch.mock.calls[0]; - const body = JSON.parse(call[1]?.body as string); + const body = JSON.parse(await gunzip(call[1]?.body as Uint8Array)); expect(body.logs[0].bucket).toBe("payment"); expect(body.logs[0].message).toBe("User paid"); expect(body.logs[0].level).toBe("info"); @@ -204,7 +236,7 @@ describe("Bucket methods", () => { buckets: ["auth"], }); - const levels: LogLevel[] = ["info", "debug", "warn", "error"]; + const levels: LogLevel[] = ["info", "debug", "warn", "error", "fatal"]; for (const level of levels) { mockFetch.mockResolvedValueOnce({ @@ -217,7 +249,7 @@ describe("Bucket methods", () => { // biome-ignore lint/style/noNonNullAssertion: test assertion const call = mockFetch.mock.calls.at(-1)!; - const body = JSON.parse(call[1]?.body as string); + const body = JSON.parse(await gunzip(call[1]?.body as Uint8Array)); expect(body.logs[0].level).toBe(level); expect(body.logs[0].bucket).toBe("auth"); expect(body.logs[0].message).toBe(`${level} message`); @@ -246,7 +278,7 @@ describe("Bucket methods", () => { await client.flush(); const call = mockFetch.mock.calls[0]; - const body = JSON.parse(call[1]?.body as string); + const body = JSON.parse(await gunzip(call[1]?.body as Uint8Array)); expect(body.logs[0].context).toEqual({ orderId: "abc-123" }); expect(body.logs[0].traceId).toBe("trace-1"); expect(body.logs[0].tags).toEqual({ region: "us" }); @@ -265,6 +297,12 @@ describe("Bucket methods", () => { "destroy", "setUser", "setSessionId", + "setDebug", + "isDestroyed", + "drainBatch", + "projectId", + "apiKey", + "logEndpoint", "attachGlobalErrorHandlers", "detachGlobalErrorHandlers", ]; @@ -298,7 +336,6 @@ describe("isDisabled", () => { projectId: "test-project", apiKey: "test-api-key", mode: "cloud", - isDebug: true, isDisabled: true, }; vi.clearAllMocks(); @@ -363,6 +400,317 @@ describe("isDisabled", () => { }); }); +describe("Destroyed state guard", () => { + let config: GunsoleClientConfig; + + beforeEach(() => { + config = { + projectId: "test-project", + apiKey: "test-api-key", + mode: "cloud", + }; + vi.clearAllMocks(); + }); + + it("should not queue logs after destroy()", async () => { + const mockFetch = vi.mocked(global.fetch); + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + } as Response); + + const client = createGunsoleClient(config); + await client.destroy(); + + mockFetch.mockClear(); + + client.log({ message: "After destroy", bucket: "test" }); + client.info({ message: "After destroy", bucket: "test" }); + client.warn({ message: "After destroy", bucket: "test" }); + + await client.flush(); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("should not update user or session after destroy()", async () => { + const mockFetch = vi.mocked(global.fetch); + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + } as Response); + + const client = createGunsoleClient(config); + await client.destroy(); + + // Should not throw + client.setUser({ id: "user-1" }); + client.setSessionId("session-1"); + }); + + it("should be safe to call destroy() multiple times", async () => { + const client = createGunsoleClient(config); + await client.destroy(); + await expect(client.destroy()).resolves.toBeUndefined(); + }); +}); + +describe("Queue size cap", () => { + let config: GunsoleClientConfig; + + beforeEach(() => { + config = { + projectId: "test-project", + apiKey: "test-api-key", + mode: "cloud", + }; + vi.clearAllMocks(); + }); + + it("should drop oldest entries when queue exceeds maxQueueSize on push", async () => { + const mockFetch = vi.mocked(global.fetch); + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + } as Response); + + const client = createGunsoleClient({ + ...config, + maxQueueSize: 3, + batchSize: 100, // prevent auto-flush + }); + + client.log({ message: "Log 1", bucket: "test" }); + client.log({ message: "Log 2", bucket: "test" }); + client.log({ message: "Log 3", bucket: "test" }); + client.log({ message: "Log 4", bucket: "test" }); + client.log({ message: "Log 5", bucket: "test" }); + + await client.flush(); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const call = mockFetch.mock.calls[0]; + const body = JSON.parse(await gunzip(call[1]?.body as Uint8Array)); + expect(body.logs).toHaveLength(3); + // Oldest entries (Log 1, Log 2) should have been dropped + expect(body.logs[0].message).toBe("Log 3"); + expect(body.logs[1].message).toBe("Log 4"); + expect(body.logs[2].message).toBe("Log 5"); + + await client.destroy(); + }); + + it("should enforce cap when re-queuing failed logs", async () => { + const mockFetch = vi.mocked(global.fetch); + // All retries fail + mockFetch.mockRejectedValue(new Error("Network error")); + + const client = createGunsoleClient({ + ...config, + maxQueueSize: 2, + batchSize: 100, + }); + + // Fill queue with 3 logs + client.log({ message: "Log 1", bucket: "test" }); + client.log({ message: "Log 2", bucket: "test" }); + + // Flush fails → logs re-queued, then add another + await client.flush(); + + client.log({ message: "Log 3", bucket: "test" }); + + // Now try flushing again with success + mockFetch.mockReset(); + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + } as Response); + + await client.flush(); + + const call = mockFetch.mock.calls[0]; + const body = JSON.parse(await gunzip(call[1]?.body as Uint8Array)); + // Queue capped at 2, so only most recent 2 should survive + expect(body.logs.length).toBeLessThanOrEqual(2); + + await client.destroy(); + }); +}); + +describe("Retry cap on re-queued batches", () => { + let config: GunsoleClientConfig; + // biome-ignore lint/suspicious/noExplicitAny: test helper + let originalSetTimeout: any; + + beforeEach(() => { + config = { + projectId: "test-project", + apiKey: "test-api-key", + mode: "cloud", + }; + // Skip all timer delays so transport retries resolve instantly + originalSetTimeout = globalThis.setTimeout; + vi.stubGlobal( + "setTimeout", + (fn: (...args: unknown[]) => void, _ms?: number) => + originalSetTimeout(fn, 0) + ); + // resetAllMocks (not clearAllMocks) to flush leftover once-implementations + vi.resetAllMocks(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("should drop entries after 10 flush failures", async () => { + const mockFetch = vi.mocked(global.fetch); + mockFetch.mockRejectedValue(new Error("Network error")); + + const client = createGunsoleClient({ + ...config, + batchSize: 100, + }); + + client.log({ message: "Persistent failure", bucket: "test" }); + + // Flush 10 times — all fail + for (let i = 0; i < 10; i++) { + await client.flush(); + } + + // After 10 failures the entry should be dropped + mockFetch.mockReset(); + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + } as Response); + + await client.flush(); + + // Should not have sent anything — the entry was dropped + expect(mockFetch).not.toHaveBeenCalled(); + + await client.destroy(); + }); + + it("should keep entries under the retry cap alive", async () => { + const mockFetch = vi.mocked(global.fetch); + mockFetch.mockRejectedValue(new Error("Network error")); + + const client = createGunsoleClient({ + ...config, + batchSize: 100, + }); + + client.log({ message: "Will survive", bucket: "test" }); + + // Flush 5 times (under the cap of 10) + for (let i = 0; i < 5; i++) { + await client.flush(); + } + + // Now succeed + mockFetch.mockReset(); + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + } as Response); + + await client.flush(); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const call = mockFetch.mock.calls[0]; + const body = JSON.parse(await gunzip(call[1]?.body as Uint8Array)); + expect(body.logs[0].message).toBe("Will survive"); + // _flushAttempts should be stripped from the payload + expect(body.logs[0]._flushAttempts).toBeUndefined(); + + await client.destroy(); + }); +}); + +describe("drainBatch", () => { + let config: GunsoleClientConfig; + + beforeEach(() => { + config = { + projectId: "test-project", + apiKey: "test-api-key", + mode: "cloud", + }; + vi.clearAllMocks(); + }); + + it("should return and clear pending entries", () => { + const client = createGunsoleClient({ + ...config, + batchSize: 100, + }); + + client.log({ message: "Log 1", bucket: "test" }); + client.log({ message: "Log 2", bucket: "test" }); + + const drained = client.drainBatch(); + expect(drained).toHaveLength(2); + expect(drained[0].message).toBe("Log 1"); + expect(drained[1].message).toBe("Log 2"); + + // Batch should be empty after drain + const second = client.drainBatch(); + expect(second).toHaveLength(0); + + client.destroy(); + }); + + it("should return empty array when no logs", () => { + const client = createGunsoleClient(config); + expect(client.drainBatch()).toHaveLength(0); + client.destroy(); + }); +}); + +describe("Config getters", () => { + it("should expose projectId", () => { + const client = createGunsoleClient({ + projectId: "my-project", + mode: "cloud", + }); + expect(client.projectId).toBe("my-project"); + client.destroy(); + }); + + it("should expose apiKey", () => { + const client = createGunsoleClient({ + projectId: "test", + apiKey: "secret-key", + mode: "cloud", + }); + expect(client.apiKey).toBe("secret-key"); + client.destroy(); + }); + + it("should expose logEndpoint with default cloud endpoint", () => { + const client = createGunsoleClient({ + projectId: "test", + mode: "cloud", + }); + expect(client.logEndpoint).toBe("https://api.gunsole.com/logs"); + client.destroy(); + }); + + it("should expose logEndpoint with custom endpoint", () => { + const client = createGunsoleClient({ + projectId: "test", + mode: "cloud", + endpoint: "https://custom.example.com", + }); + expect(client.logEndpoint).toBe("https://custom.example.com/logs"); + client.destroy(); + }); +}); + describe("Global error handlers", () => { let config: GunsoleClientConfig; @@ -371,7 +719,6 @@ describe("Global error handlers", () => { projectId: "test-project", apiKey: "test-api-key", mode: "cloud", - isDebug: true, }; vi.clearAllMocks(); }); diff --git a/packages/core/tests/config.test.ts b/packages/core/tests/config.test.ts index 8677730..4cc073d 100644 --- a/packages/core/tests/config.test.ts +++ b/packages/core/tests/config.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { normalizeConfig, resolveEndpoint } from "../src/config.js"; +import { normalizeConfig, resolveEndpoint } from "../src/config"; describe("resolveEndpoint", () => { it("should return cloud endpoint", () => { @@ -23,9 +23,9 @@ describe("resolveEndpoint", () => { describe("normalizeConfig", () => { it("should throw if projectId is missing", () => { - expect(() => - normalizeConfig({ projectId: "", mode: "cloud" }) - ).toThrow("projectId is required"); + expect(() => normalizeConfig({ projectId: "", mode: "cloud" })).toThrow( + "projectId is required" + ); }); it("should apply default values", () => { @@ -40,7 +40,6 @@ describe("normalizeConfig", () => { expect(config.appVersion).toBe(""); expect(config.batchSize).toBe(10); expect(config.flushInterval).toBe(5000); - expect(config.isDebug).toBe(false); expect(config.defaultTags).toEqual({}); expect(config.buckets).toEqual([]); }); @@ -72,7 +71,6 @@ describe("normalizeConfig", () => { appVersion: "2.0.0", batchSize: 50, flushInterval: 10000, - isDebug: true, defaultTags: { team: "backend" }, buckets: ["auth", "payment"], }); @@ -84,8 +82,65 @@ describe("normalizeConfig", () => { expect(config.appVersion).toBe("2.0.0"); expect(config.batchSize).toBe(50); expect(config.flushInterval).toBe(10000); - expect(config.isDebug).toBe(true); expect(config.defaultTags).toEqual({ team: "backend" }); expect(config.buckets).toEqual(["auth", "payment"]); }); + + it("should throw if batchSize is less than 1", () => { + expect(() => + normalizeConfig({ projectId: "test", mode: "cloud", batchSize: 0 }) + ).toThrow("batchSize must be at least 1"); + + expect(() => + normalizeConfig({ projectId: "test", mode: "cloud", batchSize: -5 }) + ).toThrow("batchSize must be at least 1"); + }); + + it("should throw if flushInterval is less than 100ms", () => { + expect(() => + normalizeConfig({ projectId: "test", mode: "cloud", flushInterval: 0 }) + ).toThrow("flushInterval must be at least 100ms"); + + expect(() => + normalizeConfig({ projectId: "test", mode: "cloud", flushInterval: 50 }) + ).toThrow("flushInterval must be at least 100ms"); + }); + + it("should accept valid batchSize and flushInterval", () => { + const config = normalizeConfig({ + projectId: "test", + mode: "cloud", + batchSize: 1, + flushInterval: 100, + }); + expect(config.batchSize).toBe(1); + expect(config.flushInterval).toBe(100); + }); + + it("should default maxQueueSize to 1000", () => { + const config = normalizeConfig({ + projectId: "test", + mode: "cloud", + }); + expect(config.maxQueueSize).toBe(1000); + }); + + it("should pass through custom maxQueueSize", () => { + const config = normalizeConfig({ + projectId: "test", + mode: "cloud", + maxQueueSize: 500, + }); + expect(config.maxQueueSize).toBe(500); + }); + + it("should throw if maxQueueSize is less than 1", () => { + expect(() => + normalizeConfig({ projectId: "test", mode: "cloud", maxQueueSize: 0 }) + ).toThrow("maxQueueSize must be at least 1"); + + expect(() => + normalizeConfig({ projectId: "test", mode: "cloud", maxQueueSize: -5 }) + ).toThrow("maxQueueSize must be at least 1"); + }); }); diff --git a/packages/core/tests/env.test.ts b/packages/core/tests/env.test.ts index cf209fb..af800cd 100644 --- a/packages/core/tests/env.test.ts +++ b/packages/core/tests/env.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from "vitest"; -import { getFetch, isBrowser, isNode } from "../src/utils/env.js"; +import { describe, expect, it, vi } from "vitest"; +import { getFetch, isBrowser, isNode } from "../src/utils/env"; describe("isNode", () => { it("should return true in Node.js environment", () => { @@ -24,4 +24,34 @@ describe("getFetch", () => { const result = getFetch(); expect(result).toBe(globalThis.fetch); }); + + it("should fall back to globalThis.fetch in edge runtimes", async () => { + const fakeFetch = vi.fn(); + vi.stubGlobal("process", undefined); + vi.stubGlobal("window", undefined); + vi.stubGlobal("fetch", fakeFetch); + + vi.resetModules(); + const { getFetch: freshGetFetch } = await import("../src/utils/env"); + + const result = freshGetFetch(); + expect(result).toBe(fakeFetch); + + vi.unstubAllGlobals(); + }); + + it("should throw when fetch is unavailable everywhere", async () => { + vi.stubGlobal("process", undefined); + vi.stubGlobal("window", undefined); + vi.stubGlobal("fetch", undefined); + + vi.resetModules(); + const { getFetch: freshGetFetch } = await import("../src/utils/env"); + + expect(() => freshGetFetch()).toThrow( + "Unsupported environment: fetch is not available" + ); + + vi.unstubAllGlobals(); + }); }); diff --git a/packages/core/tests/mocks.ts b/packages/core/tests/mocks.ts deleted file mode 100644 index 879aa46..0000000 --- a/packages/core/tests/mocks.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Mock implementations for testing - */ - -import type { GunsoleClient } from "../src/client.js"; -import type { LogLevel, LogOptions, UserInfo } from "../src/types.js"; - -interface MockLogEntry { - level: LogLevel; - options: LogOptions; -} - -/** - * Create a mock Gunsole client for testing - */ -export function createMockGunsoleClient(): GunsoleClient & { - _getLogs: () => MockLogEntry[]; - _getUser: () => UserInfo | null; - _getSessionId: () => string | null; -} { - const logs: MockLogEntry[] = []; - let user: UserInfo | null = null; - let sessionId: string | null = null; - - return { - log( - levelOrOptions: LogLevel | LogOptions, - maybeOptions?: LogOptions - ) { - const level: LogLevel = - typeof levelOrOptions === "string" ? levelOrOptions : "info"; - const options: LogOptions = - typeof levelOrOptions === "string" ? maybeOptions! : levelOrOptions; - logs.push({ level, options }); - }, - info: (options: LogOptions) => { - logs.push({ level: "info", options }); - }, - debug: (options: LogOptions) => { - logs.push({ level: "debug", options }); - }, - warn: (options: LogOptions) => { - logs.push({ level: "warn", options }); - }, - error: (options: LogOptions) => { - logs.push({ level: "error", options }); - }, - setUser: (userInfo: UserInfo) => { - user = userInfo; - }, - setSessionId: (id: string) => { - sessionId = id; - }, - flush: async () => {}, - attachGlobalErrorHandlers: () => {}, - detachGlobalErrorHandlers: () => {}, - destroy: async () => {}, - _getLogs: () => logs, - _getUser: () => user, - _getSessionId: () => sessionId, - } as GunsoleClient & { - _getLogs: () => MockLogEntry[]; - _getUser: () => UserInfo | null; - _getSessionId: () => string | null; - }; -} diff --git a/packages/core/tests/transport.test.ts b/packages/core/tests/transport.test.ts index 03aa00f..8e03d5b 100644 --- a/packages/core/tests/transport.test.ts +++ b/packages/core/tests/transport.test.ts @@ -1,6 +1,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { Transport } from "../src/transport.js"; -import type { InternalLogEntry } from "../src/types.js"; +import { Transport } from "../src/transport"; +import type { InternalLogEntry } from "../src/types"; + +async function gunzip(data: Uint8Array): Promise { + const stream = new Blob([data]) + .stream() + .pipeThrough(new DecompressionStream("gzip")); + return new Response(stream).text(); +} // Mock fetch global.fetch = vi.fn(); @@ -12,7 +19,7 @@ describe("Transport", () => { const projectId = "test-project"; beforeEach(() => { - transport = new Transport(endpoint, apiKey, projectId, undefined, true); + transport = new Transport(endpoint, apiKey, projectId); vi.clearAllMocks(); }); @@ -40,10 +47,11 @@ describe("Transport", () => { expect(call[1]?.method).toBe("POST"); expect(call[1]?.headers).toMatchObject({ "Content-Type": "application/json", + "Content-Encoding": "gzip", Authorization: `Bearer ${apiKey}`, }); - const body = JSON.parse(call[1]?.body as string); + const body = JSON.parse(await gunzip(call[1]?.body as Uint8Array)); expect(body.projectId).toBe(projectId); expect(body.logs).toEqual(logs); }); @@ -73,8 +81,8 @@ describe("Transport", () => { // Should have retried 3 times (initial + 2 retries) expect(mockFetch).toHaveBeenCalledTimes(3); - // Should have waited for backoff (at least 1s + 2s = 3s) - expect(duration).toBeGreaterThanOrEqual(2000); + // Should have waited for backoff (at least 500ms + 1000ms = 1500ms with jitter) + expect(duration).toBeGreaterThanOrEqual(1400); }); it("should not retry on 4xx client errors", async () => { @@ -165,7 +173,7 @@ describe("Transport", () => { expect(mockFetch).not.toHaveBeenCalled(); }); - it("should eventually give up after max retries", async () => { + it("should throw after max retries are exhausted", async () => { const mockFetch = vi.mocked(global.fetch); mockFetch.mockRejectedValue(new Error("Persistent network error")); @@ -178,10 +186,114 @@ describe("Transport", () => { }, ]; - // Should not throw - await expect(transport.sendBatch(logs)).resolves.toBeUndefined(); + await expect(transport.sendBatch(logs)).rejects.toThrow( + "Persistent network error" + ); // Should have tried MAX_RETRIES times expect(mockFetch).toHaveBeenCalledTimes(3); }); + + it("should serialize BigInt values in context to strings", async () => { + const mockFetch = vi.mocked(global.fetch); + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + } as Response); + + const logs: InternalLogEntry[] = [ + { + level: "info", + bucket: "test", + message: "BigInt test", + timestamp: Date.now(), + context: { bigValue: BigInt("9007199254740993") }, + }, + ]; + + await transport.sendBatch(logs); + + const call = mockFetch.mock.calls[0]; + const body = JSON.parse(await gunzip(call[1]?.body as Uint8Array)); + expect(body.logs[0].context.bigValue).toBe("9007199254740993"); + }); + + it("should apply jitter to backoff delay", async () => { + vi.spyOn(Math, "random").mockReturnValue(0); // minimum jitter: 0.5x + + const mockFetch = vi.mocked(global.fetch); + mockFetch + .mockRejectedValueOnce(new Error("Network error")) + .mockResolvedValueOnce({ + ok: true, + status: 200, + } as Response); + + const logs: InternalLogEntry[] = [ + { + level: "info", + bucket: "test", + message: "Jitter test", + timestamp: Date.now(), + }, + ]; + + const startTime = Date.now(); + await transport.sendBatch(logs); + const duration = Date.now() - startTime; + + // With Math.random()=0, jitter=0.5, delay = 1000 * 0.5 = 500ms + expect(duration).toBeGreaterThanOrEqual(400); + expect(duration).toBeLessThan(1500); + + vi.spyOn(Math, "random").mockRestore(); + }); + + it("should strip _flushAttempts from sent payload", async () => { + const mockFetch = vi.mocked(global.fetch); + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + } as Response); + + const logs: InternalLogEntry[] = [ + { + level: "info", + bucket: "test", + message: "Test log", + timestamp: Date.now(), + _flushAttempts: 3, + }, + ]; + + await transport.sendBatch(logs); + + const call = mockFetch.mock.calls[0]; + const body = JSON.parse(await gunzip(call[1]?.body as Uint8Array)); + expect(body.logs[0]._flushAttempts).toBeUndefined(); + expect(body.logs[0].message).toBe("Test log"); + }); + + it("should pass an AbortSignal to fetch requests", async () => { + const mockFetch = vi.mocked(global.fetch); + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + } as Response); + + const logs: InternalLogEntry[] = [ + { + level: "info", + bucket: "test", + message: "Test log", + timestamp: Date.now(), + }, + ]; + + await transport.sendBatch(logs); + + const call = mockFetch.mock.calls[0]; + const init = call[1] as RequestInit; + expect(init.signal).toBeInstanceOf(AbortSignal); + }); }); diff --git a/packages/core/tsup.config.ts b/packages/core/tsup.config.ts index faeac25..585e42e 100644 --- a/packages/core/tsup.config.ts +++ b/packages/core/tsup.config.ts @@ -1,5 +1,8 @@ +import { readFileSync } from "node:fs"; import { defineConfig } from "tsup"; +const pkg = JSON.parse(readFileSync("./package.json", "utf-8")); + export default defineConfig({ entry: ["src/index.ts"], format: ["cjs", "esm"], @@ -10,4 +13,7 @@ export default defineConfig({ treeshake: true, minify: false, external: [], + define: { + __SDK_VERSION__: JSON.stringify(pkg.version), + }, }); diff --git a/packages/web/CHANGELOG.md b/packages/web/CHANGELOG.md new file mode 100644 index 0000000..d62ac95 --- /dev/null +++ b/packages/web/CHANGELOG.md @@ -0,0 +1,15 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.2.0] - 2026-03-08 + +### Added + +- Initial release of `@gunsole/web` browser lifecycle package +- `sendBeacon` transport for reliable log delivery on page unload +- Automatic `beforeunload` and `visibilitychange` flush handlers +- Seamless integration with `@gunsole/core` diff --git a/packages/web/biome.json b/packages/web/biome.json new file mode 100644 index 0000000..be1b8bf --- /dev/null +++ b/packages/web/biome.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "extends": ["../../biome.json"], + "files": { + "ignore": ["dist/**", "node_modules/**", "coverage/**"] + }, + "linter": { + "rules": { + "recommended": true, + "correctness": { + "noUnusedVariables": "warn" + }, + "suspicious": { + "noExplicitAny": "warn" + } + } + } +} diff --git a/packages/web/package.json b/packages/web/package.json new file mode 100644 index 0000000..afbb6cc --- /dev/null +++ b/packages/web/package.json @@ -0,0 +1,69 @@ +{ + "name": "@gunsole/web", + "version": "0.2.0", + "description": "Browser lifecycle utilities for @gunsole/core", + "type": "module", + "sideEffects": false, + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "files": ["dist", "LICENSE", "README.md"], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit", + "lint": "biome lint .", + "lint:fix": "biome lint --write .", + "format": "biome format --write .", + "check": "biome check .", + "check:fix": "biome check --write .", + "prepublishOnly": "pnpm build && pnpm typecheck" + }, + "keywords": ["gunsole", "logging", "browser", "lifecycle", "sendbeacon"], + "publishConfig": { + "access": "public" + }, + "homepage": "https://gunsole.com", + "author": "push1kb", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/push1kb/gunsole-js.git", + "directory": "packages/web" + }, + "peerDependencies": { + "@gunsole/core": ">=0.2.0", + "typescript": "^5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + }, + "devDependencies": { + "@biomejs/biome": "^1.9.4", + "@gunsole/core": "workspace:*", + "@types/node": "^20.11.5", + "jsdom": "^24.0.0", + "tsup": "^8.0.1", + "typescript": "^5.4.0", + "vitest": "^1.2.1" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/packages/web/src/factory.ts b/packages/web/src/factory.ts new file mode 100644 index 0000000..81a8d36 --- /dev/null +++ b/packages/web/src/factory.ts @@ -0,0 +1,37 @@ +import { + type GunsoleClient, + type GunsoleClientConfig, + createGunsoleClient as coreCreateGunsoleClient, +} from "@gunsole/core"; +import { createKeepaliveFetch } from "./keepalive"; +import { attachWebLifecycle } from "./lifecycle"; +import type { WebLifecycleOptions } from "./types"; + +/** + * Create a Gunsole client with browser lifecycle baked in. + * + * - Wraps fetch with keepalive support + * - Attaches web lifecycle handlers (sendBeacon, visibility, network, URL debug) + * - Attaches global error handlers + * - `destroy()` detaches everything automatically + */ +export function createGunsoleClient( + config: GunsoleClientConfig, + lifecycleOptions?: WebLifecycleOptions +): GunsoleClient { + const client = coreCreateGunsoleClient({ + ...config, + fetch: createKeepaliveFetch(config.fetch), + }); + + const detachLifecycle = attachWebLifecycle(client, lifecycleOptions); + client.attachGlobalErrorHandlers(); + + const originalDestroy = client.destroy.bind(client); + client.destroy = async () => { + detachLifecycle(); + await originalDestroy(); + }; + + return client; +} diff --git a/packages/web/src/index.ts b/packages/web/src/index.ts new file mode 100644 index 0000000..808736e --- /dev/null +++ b/packages/web/src/index.ts @@ -0,0 +1,33 @@ +/** + * Gunsole Web + * + * Browser-optimised wrapper around @gunsole/core with lifecycle baked in. + * + * @packageDocumentation + */ + +export { createGunsoleClient } from "./factory"; +export { createGunsoleClient as createGunsole } from "./factory"; +export { createGunsoleClient as createGunsoleWeb } from "./factory"; + +// Session persistence +export { persistSession, GUNSOLE_SESSION_COOKIE } from "./session"; + +// Low-level primitives for advanced users +export { attachWebLifecycle } from "./lifecycle"; +export { createKeepaliveFetch } from "./keepalive"; +export type { DetachFunction, WebLifecycleOptions } from "./types"; + +// Re-export key types from core so consumers only need @gunsole/web +export type { + ClientMode, + FetchFunction, + GunsoleClientConfig, + GunsoleHooks, + LogLevel, + LogOptions, + TagEntry, + UserInfo, + ValidTagSchema, +} from "@gunsole/core"; +export { GunsoleClient, SDK_VERSION } from "@gunsole/core"; diff --git a/packages/web/src/keepalive.ts b/packages/web/src/keepalive.ts new file mode 100644 index 0000000..dc61272 --- /dev/null +++ b/packages/web/src/keepalive.ts @@ -0,0 +1,47 @@ +import type { FetchFunction } from "@gunsole/core"; + +/** + * Maximum body size (in bytes) for keepalive requests. + * Browsers cap keepalive payloads at 64 KB; we use 80% as a safety margin. + */ +const MAX_KEEPALIVE_BYTES = 51 * 1024; + +/** + * Get byte length of a fetch body. + * Returns -1 if the size cannot be determined synchronously. + */ +function getBodySize(body: BodyInit | null | undefined): number { + if (body == null) { + return -1; + } + if (typeof body === "string") { + return new TextEncoder().encode(body).byteLength; + } + if (body instanceof Uint8Array) { + return body.byteLength; + } + if (body instanceof ArrayBuffer) { + return body.byteLength; + } + if (body instanceof Blob) { + return body.size; + } + return -1; +} + +/** + * Create a fetch wrapper that adds `keepalive: true` when the body + * is small enough (< 51 KB). Pass the result as the `fetch` config + * option to `createGunsoleClient()`. + */ +export function createKeepaliveFetch(baseFetch?: FetchFunction): FetchFunction { + const fetchFn = baseFetch ?? globalThis.fetch.bind(globalThis); + + return (input: RequestInfo | URL, init?: RequestInit): Promise => { + const size = getBodySize(init?.body); + if (size >= 0 && size < MAX_KEEPALIVE_BYTES) { + return fetchFn(input, { ...init, keepalive: true }); + } + return fetchFn(input, init); + }; +} diff --git a/packages/web/src/lifecycle.ts b/packages/web/src/lifecycle.ts new file mode 100644 index 0000000..0146b1e --- /dev/null +++ b/packages/web/src/lifecycle.ts @@ -0,0 +1,113 @@ +import type { GunsoleClient } from "@gunsole/core"; +import type { DetachFunction, WebLifecycleOptions } from "./types"; + +const DEBUG_KEY = "__gunsole_debug"; + +/** WeakSet to track attached clients and prevent double-attach */ +const attachedClients = new WeakSet(); + +/** + * Attach browser lifecycle handlers to a GunsoleClient. + * + * - sendBeacon on pagehide (#1) + * - Online/offline awareness (#4) + * - URL/localStorage debug activation (#6) + * - Visibility-based flushing (#10) + * - Re-init guard (#18) + * + * Returns a detach function that removes all listeners. + */ +export function attachWebLifecycle( + client: GunsoleClient, + options?: WebLifecycleOptions +): DetachFunction { + // Re-init guard (#18) + if (attachedClients.has(client)) { + console.warn( + "[Gunsole] Web lifecycle already attached to this client. " + + "Call detach() first to re-attach." + ); + return () => {}; + } + + const opts: Required = { + sendBeacon: options?.sendBeacon ?? true, + networkAware: options?.networkAware ?? true, + visibilityAware: options?.visibilityAware ?? true, + urlDebug: options?.urlDebug ?? true, + }; + + const cleanups: (() => void)[] = []; + + // URL/localStorage debug (#6) + if (opts.urlDebug) { + try { + const params = new URLSearchParams(window.location.search); + const debugParam = params.get(DEBUG_KEY); + + if (debugParam === "true") { + localStorage.setItem(DEBUG_KEY, "true"); + client.setDebug(true); + } else if (debugParam === "false") { + localStorage.removeItem(DEBUG_KEY); + } else if (localStorage.getItem(DEBUG_KEY) === "true") { + client.setDebug(true); + } + } catch { + // localStorage or URL access may throw in restricted contexts + } + } + + // sendBeacon on pagehide (#1) + if (opts.sendBeacon) { + const onPageHide = (): void => { + const logs = client.drainBatch(); + if (logs.length === 0) { + return; + } + + const payload = JSON.stringify({ + projectId: client.projectId, + apiKey: client.apiKey, + logs, + }); + + const blob = new Blob([payload], { type: "application/json" }); + navigator.sendBeacon(client.logEndpoint, blob); + }; + + window.addEventListener("pagehide", onPageHide); + cleanups.push(() => window.removeEventListener("pagehide", onPageHide)); + } + + // Online/offline (#4) + if (opts.networkAware) { + const onOnline = (): void => { + client.flush(); + }; + + window.addEventListener("online", onOnline); + cleanups.push(() => window.removeEventListener("online", onOnline)); + } + + // Visibility (#10) + if (opts.visibilityAware) { + const onVisibilityChange = (): void => { + client.flush(); + }; + + document.addEventListener("visibilitychange", onVisibilityChange); + cleanups.push(() => + document.removeEventListener("visibilitychange", onVisibilityChange) + ); + } + + attachedClients.add(client); + + return () => { + for (const cleanup of cleanups) { + cleanup(); + } + attachedClients.delete(client); + }; +} diff --git a/packages/web/src/session.ts b/packages/web/src/session.ts new file mode 100644 index 0000000..424194e --- /dev/null +++ b/packages/web/src/session.ts @@ -0,0 +1,33 @@ +import type { GunsoleClient } from "@gunsole/core"; + +export const GUNSOLE_SESSION_COOKIE = "gunsole_session"; + +/** + * Persist session ID in a cookie. Reads an existing cookie if present, + * otherwise writes the client's auto-generated session ID to the cookie. + * Returns the session ID. + */ +export function persistSession( + client: GunsoleClient, + cookieName: string = GUNSOLE_SESSION_COOKIE +): string { + if (typeof document === "undefined") { + return client.getSessionId() ?? ""; + } + + const match = document.cookie + .split("; ") + .find((c) => c.startsWith(`${cookieName}=`)); + + if (match) { + const value = match.split("=")[1]; + client.setSessionId(value); + return value; + } + + const sessionId = client.getSessionId() ?? ""; + if (sessionId) { + document.cookie = `${cookieName}=${sessionId}; path=/; SameSite=Lax`; + } + return sessionId; +} diff --git a/packages/web/src/types.ts b/packages/web/src/types.ts new file mode 100644 index 0000000..a832c7f --- /dev/null +++ b/packages/web/src/types.ts @@ -0,0 +1,18 @@ +/** + * Options for attachWebLifecycle + */ +export interface WebLifecycleOptions { + /** Use sendBeacon on pagehide to flush remaining logs (default: true) */ + sendBeacon?: boolean; + /** Flush logs when browser comes back online (default: true) */ + networkAware?: boolean; + /** Flush logs on visibility changes (default: true) */ + visibilityAware?: boolean; + /** Enable debug mode via URL param or localStorage (default: true) */ + urlDebug?: boolean; +} + +/** + * Function that removes all attached listeners + */ +export type DetachFunction = () => void; diff --git a/packages/web/tests/factory.test.ts b/packages/web/tests/factory.test.ts new file mode 100644 index 0000000..20f2cfd --- /dev/null +++ b/packages/web/tests/factory.test.ts @@ -0,0 +1,118 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as keepalive from "../src/keepalive"; +import * as lifecycle from "../src/lifecycle"; + +// Spy on lifecycle and keepalive instead of mocking @gunsole/core +vi.mock("@gunsole/core", async (importOriginal) => { + const orig = await importOriginal(); + return { + ...orig, + createGunsoleClient: vi.fn((config: Record) => { + // Return a minimal mock client that tracks calls + const destroyMock = vi.fn().mockResolvedValue(undefined); + return { + destroy: destroyMock, + attachGlobalErrorHandlers: vi.fn(), + detachGlobalErrorHandlers: vi.fn(), + flush: vi.fn().mockResolvedValue(undefined), + drainBatch: vi.fn().mockReturnValue([]), + setDebug: vi.fn(), + projectId: config.projectId, + apiKey: config.apiKey, + logEndpoint: "http://localhost:17655/logs", + _config: config, + }; + }), + }; +}); + +describe("createGunsoleClient (web factory)", () => { + let attachSpy: ReturnType; + let detachFn: ReturnType; + + beforeEach(() => { + detachFn = vi.fn(); + attachSpy = vi + .spyOn(lifecycle, "attachWebLifecycle") + .mockReturnValue(detachFn); + vi.spyOn(keepalive, "createKeepaliveFetch").mockReturnValue( + vi.fn() as unknown as ReturnType + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // Import lazily so mocks are in place + async function getFactory() { + const mod = await import("../src/factory"); + return mod.createGunsoleClient; + } + + it("should attach web lifecycle on creation", async () => { + const create = await getFactory(); + const client = create({ + projectId: "test", + apiKey: "key", + mode: "local", + }); + + expect(attachSpy).toHaveBeenCalledOnce(); + expect(attachSpy).toHaveBeenCalledWith(client, undefined); + }); + + it("should pass lifecycle options through", async () => { + const create = await getFactory(); + const opts = { sendBeacon: false, networkAware: false }; + const client = create( + { projectId: "test", apiKey: "key", mode: "local" }, + opts + ); + + expect(attachSpy).toHaveBeenCalledWith(client, opts); + }); + + it("should attach global error handlers on creation", async () => { + const create = await getFactory(); + const client = create({ + projectId: "test", + apiKey: "key", + mode: "local", + }); + + // biome-ignore lint/suspicious/noExplicitAny: mock access + expect((client as any).attachGlobalErrorHandlers).toHaveBeenCalledOnce(); + }); + + it("should wrap fetch with keepalive", async () => { + const { createGunsoleClient: coreFn } = await import("@gunsole/core"); + const create = await getFactory(); + const customFetch = vi.fn(); + + create({ + projectId: "test", + apiKey: "key", + mode: "local", + fetch: customFetch, + }); + + // Core should have received the keepalive-wrapped fetch, not the original + // biome-ignore lint/suspicious/noExplicitAny: mock access + const passedConfig = (coreFn as any).mock.calls.at(-1)[0]; + expect(passedConfig.fetch).not.toBe(customFetch); + }); + + it("should detach lifecycle before calling original destroy", async () => { + const create = await getFactory(); + const client = create({ + projectId: "test", + apiKey: "key", + mode: "local", + }); + + await client.destroy(); + + expect(detachFn).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/web/tests/keepalive.test.ts b/packages/web/tests/keepalive.test.ts new file mode 100644 index 0000000..de3747a --- /dev/null +++ b/packages/web/tests/keepalive.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, vi } from "vitest"; +import { createKeepaliveFetch } from "../src/keepalive"; + +describe("createKeepaliveFetch", () => { + it("should add keepalive for small string body", async () => { + const baseFetch = vi.fn().mockResolvedValue(new Response("ok")); + const fetchFn = createKeepaliveFetch(baseFetch); + + await fetchFn("https://example.com", { + method: "POST", + body: "small payload", + }); + + expect(baseFetch).toHaveBeenCalledWith("https://example.com", { + method: "POST", + body: "small payload", + keepalive: true, + }); + }); + + it("should add keepalive for small Uint8Array body", async () => { + const baseFetch = vi.fn().mockResolvedValue(new Response("ok")); + const fetchFn = createKeepaliveFetch(baseFetch); + + const body = new Uint8Array(100); + await fetchFn("https://example.com", { method: "POST", body }); + + expect(baseFetch).toHaveBeenCalledWith("https://example.com", { + method: "POST", + body, + keepalive: true, + }); + }); + + it("should not add keepalive for body > 51 KB", async () => { + const baseFetch = vi.fn().mockResolvedValue(new Response("ok")); + const fetchFn = createKeepaliveFetch(baseFetch); + + const body = new Uint8Array(52 * 1024); + await fetchFn("https://example.com", { method: "POST", body }); + + expect(baseFetch).toHaveBeenCalledWith("https://example.com", { + method: "POST", + body, + }); + }); + + it("should not add keepalive when no body", async () => { + const baseFetch = vi.fn().mockResolvedValue(new Response("ok")); + const fetchFn = createKeepaliveFetch(baseFetch); + + await fetchFn("https://example.com", { method: "GET" }); + + expect(baseFetch).toHaveBeenCalledWith("https://example.com", { + method: "GET", + }); + }); + + it("should add keepalive for small Blob body", async () => { + const baseFetch = vi.fn().mockResolvedValue(new Response("ok")); + const fetchFn = createKeepaliveFetch(baseFetch); + + const body = new Blob(["small"]); + await fetchFn("https://example.com", { method: "POST", body }); + + expect(baseFetch).toHaveBeenCalledWith("https://example.com", { + method: "POST", + body, + keepalive: true, + }); + }); + + it("should use globalThis.fetch when no base provided", async () => { + const mockGlobalFetch = vi.fn().mockResolvedValue(new Response("ok")); + vi.stubGlobal("fetch", mockGlobalFetch); + + const fetchFn = createKeepaliveFetch(); + await fetchFn("https://example.com", { + method: "POST", + body: "test", + }); + + expect(mockGlobalFetch).toHaveBeenCalledOnce(); + + vi.unstubAllGlobals(); + }); +}); diff --git a/packages/web/tests/lifecycle.test.ts b/packages/web/tests/lifecycle.test.ts new file mode 100644 index 0000000..37dda51 --- /dev/null +++ b/packages/web/tests/lifecycle.test.ts @@ -0,0 +1,290 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { attachWebLifecycle } from "../src/lifecycle"; + +function createMockClient() { + return { + drainBatch: vi.fn().mockReturnValue([]), + flush: vi.fn().mockResolvedValue(undefined), + setDebug: vi.fn(), + projectId: "test-project", + apiKey: "test-key", + logEndpoint: "https://api.gunsole.com/logs", + // biome-ignore lint/suspicious/noExplicitAny: mock client + } as any; +} + +describe("attachWebLifecycle", () => { + let mockClient: ReturnType; + + beforeEach(() => { + mockClient = createMockClient(); + vi.restoreAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("sendBeacon on pagehide (#1)", () => { + it("should send remaining logs via sendBeacon on pagehide", () => { + const mockBeacon = vi.fn().mockReturnValue(true); + vi.stubGlobal("navigator", { sendBeacon: mockBeacon }); + + mockClient.drainBatch.mockReturnValue([ + { message: "log 1", bucket: "test", level: "info", timestamp: 1 }, + ]); + + const detach = attachWebLifecycle(mockClient); + window.dispatchEvent(new Event("pagehide")); + + expect(mockBeacon).toHaveBeenCalledOnce(); + expect(mockBeacon).toHaveBeenCalledWith( + "https://api.gunsole.com/logs", + expect.any(Blob) + ); + + detach(); + }); + + it("should not call sendBeacon when batch is empty", () => { + const mockBeacon = vi.fn().mockReturnValue(true); + vi.stubGlobal("navigator", { sendBeacon: mockBeacon }); + + mockClient.drainBatch.mockReturnValue([]); + + const detach = attachWebLifecycle(mockClient); + window.dispatchEvent(new Event("pagehide")); + + expect(mockBeacon).not.toHaveBeenCalled(); + + detach(); + }); + + it("should include projectId and apiKey in payload", () => { + // Capture the raw string passed to the Blob constructor + let capturedPayload = ""; + const OrigBlob = globalThis.Blob; + vi.stubGlobal( + "Blob", + class extends OrigBlob { + constructor(parts: BlobPart[], options?: BlobPropertyBag) { + super(parts, options); + capturedPayload = parts[0] as string; + } + } + ); + + const mockBeacon = vi.fn().mockReturnValue(true); + vi.stubGlobal("navigator", { sendBeacon: mockBeacon }); + + mockClient.drainBatch.mockReturnValue([ + { message: "log 1", bucket: "test", level: "info", timestamp: 1 }, + ]); + + const detach = attachWebLifecycle(mockClient); + window.dispatchEvent(new Event("pagehide")); + + const payload = JSON.parse(capturedPayload); + expect(payload.projectId).toBe("test-project"); + expect(payload.apiKey).toBe("test-key"); + expect(payload.logs).toHaveLength(1); + + detach(); + }); + + it("should not attach pagehide listener when disabled", () => { + const addSpy = vi.spyOn(window, "addEventListener"); + + const detach = attachWebLifecycle(mockClient, { sendBeacon: false }); + + const pagehideCalls = addSpy.mock.calls.filter( + (c) => c[0] === "pagehide" + ); + expect(pagehideCalls).toHaveLength(0); + + detach(); + }); + }); + + describe("online flush (#4)", () => { + it("should flush on online event", () => { + const detach = attachWebLifecycle(mockClient); + window.dispatchEvent(new Event("online")); + + expect(mockClient.flush).toHaveBeenCalledOnce(); + + detach(); + }); + + it("should not attach online listener when disabled", () => { + const addSpy = vi.spyOn(window, "addEventListener"); + + const detach = attachWebLifecycle(mockClient, { networkAware: false }); + + const onlineCalls = addSpy.mock.calls.filter((c) => c[0] === "online"); + expect(onlineCalls).toHaveLength(0); + + detach(); + }); + }); + + describe("visibility flush (#10)", () => { + it("should flush on visibilitychange (hidden)", () => { + const detach = attachWebLifecycle(mockClient); + + Object.defineProperty(document, "visibilityState", { + value: "hidden", + writable: true, + configurable: true, + }); + document.dispatchEvent(new Event("visibilitychange")); + + expect(mockClient.flush).toHaveBeenCalledOnce(); + + detach(); + }); + + it("should flush on visibilitychange (visible)", () => { + const detach = attachWebLifecycle(mockClient); + + Object.defineProperty(document, "visibilityState", { + value: "visible", + writable: true, + configurable: true, + }); + document.dispatchEvent(new Event("visibilitychange")); + + expect(mockClient.flush).toHaveBeenCalledOnce(); + + detach(); + }); + + it("should not attach visibilitychange listener when disabled", () => { + const addSpy = vi.spyOn(document, "addEventListener"); + + const detach = attachWebLifecycle(mockClient, { + visibilityAware: false, + }); + + const visCalls = addSpy.mock.calls.filter( + (c) => c[0] === "visibilitychange" + ); + expect(visCalls).toHaveLength(0); + + detach(); + }); + }); + + describe("URL debug (#6)", () => { + it("should enable debug from URL param", () => { + Object.defineProperty(window, "location", { + value: { search: "?__gunsole_debug=true" }, + writable: true, + configurable: true, + }); + + const detach = attachWebLifecycle(mockClient); + + expect(mockClient.setDebug).toHaveBeenCalledWith(true); + expect(localStorage.getItem("__gunsole_debug")).toBe("true"); + + localStorage.removeItem("__gunsole_debug"); + detach(); + }); + + it("should enable debug from localStorage", () => { + Object.defineProperty(window, "location", { + value: { search: "" }, + writable: true, + configurable: true, + }); + localStorage.setItem("__gunsole_debug", "true"); + + const detach = attachWebLifecycle(mockClient); + + expect(mockClient.setDebug).toHaveBeenCalledWith(true); + + localStorage.removeItem("__gunsole_debug"); + detach(); + }); + + it("should clear debug with ?__gunsole_debug=false", () => { + localStorage.setItem("__gunsole_debug", "true"); + Object.defineProperty(window, "location", { + value: { search: "?__gunsole_debug=false" }, + writable: true, + configurable: true, + }); + + const detach = attachWebLifecycle(mockClient); + + expect(localStorage.getItem("__gunsole_debug")).toBeNull(); + // setDebug should NOT have been called with true + expect(mockClient.setDebug).not.toHaveBeenCalled(); + + detach(); + }); + + it("should not check URL when disabled", () => { + Object.defineProperty(window, "location", { + value: { search: "?__gunsole_debug=true" }, + writable: true, + configurable: true, + }); + + const detach = attachWebLifecycle(mockClient, { urlDebug: false }); + + expect(mockClient.setDebug).not.toHaveBeenCalled(); + + detach(); + }); + }); + + describe("re-init guard (#18)", () => { + it("should warn and return no-op on duplicate attach", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const detach1 = attachWebLifecycle(mockClient); + const detach2 = attachWebLifecycle(mockClient); + + expect(warnSpy).toHaveBeenCalledOnce(); + expect(warnSpy.mock.calls[0][0]).toContain("already attached"); + + // Second detach should be a no-op + detach2(); + + // First detach should still work and allow re-attach + detach1(); + }); + + it("should allow re-attach after detach", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const detach1 = attachWebLifecycle(mockClient); + detach1(); + + // Should not warn on re-attach + const detach2 = attachWebLifecycle(mockClient); + expect(warnSpy).not.toHaveBeenCalled(); + + detach2(); + }); + }); + + describe("detach", () => { + it("should remove all event listeners", () => { + const removeSpy = vi.spyOn(window, "removeEventListener"); + const removeDocSpy = vi.spyOn(document, "removeEventListener"); + + const detach = attachWebLifecycle(mockClient); + detach(); + + const removedWindowEvents = removeSpy.mock.calls.map((c) => c[0]); + const removedDocEvents = removeDocSpy.mock.calls.map((c) => c[0]); + + expect(removedWindowEvents).toContain("pagehide"); + expect(removedWindowEvents).toContain("online"); + expect(removedDocEvents).toContain("visibilitychange"); + }); + }); +}); diff --git a/packages/web/tsconfig.json b/packages/web/tsconfig.json new file mode 100644 index 0000000..39f3f6b --- /dev/null +++ b/packages/web/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "lib": ["ES2020", "DOM"], + "moduleResolution": "bundler", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/packages/web/tsup.config.ts b/packages/web/tsup.config.ts new file mode 100644 index 0000000..51861de --- /dev/null +++ b/packages/web/tsup.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["cjs", "esm"], + dts: true, + splitting: false, + sourcemap: true, + clean: true, + treeshake: true, + minify: false, + external: ["@gunsole/core"], +}); diff --git a/packages/web/vitest.config.ts b/packages/web/vitest.config.ts new file mode 100644 index 0000000..117744e --- /dev/null +++ b/packages/web/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "jsdom", + coverage: { + provider: "v8", + reporter: ["text", "json", "html"], + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 29a2953..4195796 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,9 +35,9 @@ importers: '@angular/router': specifier: ^21.0.0 version: 21.0.3(@angular/common@21.0.3(@angular/core@21.0.3(@angular/compiler@21.0.3)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@21.0.3(@angular/compiler@21.0.3)(rxjs@7.8.2))(@angular/platform-browser@21.0.3(@angular/common@21.0.3(@angular/core@21.0.3(@angular/compiler@21.0.3)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@21.0.3(@angular/compiler@21.0.3)(rxjs@7.8.2)))(rxjs@7.8.2) - '@gunsole/core': + '@gunsole/web': specifier: workspace:* - version: link:../../packages/core + version: link:../../packages/web rxjs: specifier: ~7.8.0 version: 7.8.2 @@ -88,6 +88,9 @@ importers: '@gunsole/core': specifier: workspace:* version: link:../../packages/core + '@gunsole/web': + specifier: workspace:* + version: link:../../packages/web next: specifier: 16.0.7 version: 16.0.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(sass@1.93.2) @@ -125,9 +128,12 @@ importers: apps/react-vite: dependencies: - '@gunsole/core': + '@gunsole/web': specifier: workspace:* - version: link:../../packages/core + version: link:../../packages/web + '@tanstack/react-router': + specifier: ^1.166.2 + version: 1.166.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react: specifier: ^19.2.0 version: 19.2.0 @@ -183,9 +189,12 @@ importers: apps/solid-vite: dependencies: - '@gunsole/core': + '@gunsole/web': specifier: workspace:* - version: link:../../packages/core + version: link:../../packages/web + '@tanstack/solid-router': + specifier: ^1.166.2 + version: 1.166.2(solid-js@1.9.10) solid-js: specifier: ^1.9.10 version: 1.9.10 @@ -224,12 +233,36 @@ importers: specifier: ^8.0.1 version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) typescript: - specifier: ^5.3.3 + specifier: ^5.4.0 version: 5.9.3 vitest: specifier: ^1.2.1 version: 1.6.1(@types/node@20.19.25)(jsdom@27.2.0)(lightningcss@1.30.2)(sass@1.93.2) + packages/web: + devDependencies: + '@biomejs/biome': + specifier: ^1.9.4 + version: 1.9.4 + '@gunsole/core': + specifier: workspace:* + version: link:../core + '@types/node': + specifier: ^20.11.5 + version: 20.19.25 + jsdom: + specifier: ^24.0.0 + version: 24.1.3 + tsup: + specifier: ^8.0.1 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) + typescript: + specifier: ^5.4.0 + version: 5.9.3 + vitest: + specifier: ^1.2.1 + version: 1.6.1(@types/node@20.19.25)(jsdom@24.1.3)(lightningcss@1.30.2)(sass@1.93.2) + packages: '@acemir/cssom@0.9.27': @@ -432,6 +465,9 @@ packages: '@angular/platform-browser': 21.0.3 rxjs: ^6.5.3 || ^7.4.0 + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + '@asamuzakjp/css-color@4.1.0': resolution: {integrity: sha512-9xiBAtLn4aNsa4mDnpovJvBn72tNEIACyvlqaNJ+ADemR+yeMJWnBudOi2qGDviJa7SwcDOU/TRh5dnET7qk0w==} @@ -1880,6 +1916,9 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@nothing-but/utils@0.17.0': + resolution: {integrity: sha512-TuCHcHLOqDL0SnaAxACfuRHBNRgNJcNn9X0GiH5H3YSDBVquCr3qEIG3FOQAuMyZCbu9w8nk2CHhOsn7IvhIwQ==} + '@npmcli/agent@4.0.0': resolution: {integrity: sha512-kAQTcEN9E8ERLVg5AsGwLNoFb+oEG6engbqAU2P43gD4JEIkNGMHdVQ096FsOAAYpZPB0RSt0zgInKIAS1l5QA==} engines: {node: ^20.17.0 || >=22.9.0} @@ -2236,6 +2275,81 @@ packages: '@sinclair/typebox@0.27.8': resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} + '@solid-devtools/debugger@0.28.1': + resolution: {integrity: sha512-6qIUI6VYkXoRnL8oF5bvh2KgH71qlJ18hNw/mwSyY6v48eb80ZR48/5PDXufUa3q+MBSuYa1uqTMwLewpay9eg==} + peerDependencies: + solid-js: ^1.9.0 + + '@solid-devtools/logger@0.9.11': + resolution: {integrity: sha512-THbiY1iQlieL6vdgJc4FIsLe7V8a57hod/Thm8zdKrTkWL88UPZjkBBfM+mVNGusd4OCnAN20tIFBhNnuT1Dew==} + peerDependencies: + solid-js: ^1.9.0 + + '@solid-devtools/shared@0.20.0': + resolution: {integrity: sha512-o5TACmUOQsxpzpOKCjbQqGk8wL8PMi+frXG9WNu4Lh3PQVUB6hs95Kl/S8xc++zwcMguUKZJn8h5URUiMOca6Q==} + peerDependencies: + solid-js: ^1.9.0 + + '@solid-primitives/bounds@0.1.5': + resolution: {integrity: sha512-JFym8zijMfWp1FaAmJlH3xMfenCuhjaUsoBn3kt9FtoWwLj+yt+EGYt+p3SkOKwF7h4gaGtZ5PIdSbSNVWkRmg==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/event-listener@2.4.5': + resolution: {integrity: sha512-nwRV558mIabl4yVAhZKY8cb6G+O1F0M6Z75ttTu5hk+SxdOnKSGj+eetDIu7Oax1P138ZdUU01qnBPR8rnxaEA==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/keyboard@1.3.5': + resolution: {integrity: sha512-sav+l+PL+74z3yaftVs7qd8c2SXkqzuxPOVibUe5wYMt+U5Hxp3V3XCPgBPN2I6cANjvoFtz0NiU8uHVLdi9FQ==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/media@2.3.5': + resolution: {integrity: sha512-LX9fB5WDaK87FMDtUB1qokBOfT2et9Uobv/zZaKLH9caFSz4+P70MBKEIBHcZQy+9MV5M2XvGYLTbLskjkzMjA==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/refs@1.1.3': + resolution: {integrity: sha512-aam02fjNKpBteewF/UliPSQCVJsIIGOLEWQOh+ll6R/QePzBOOBMcC4G+5jTaO75JuUS1d/14Q1YXT3X0Ow6iA==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/resize-observer@2.1.5': + resolution: {integrity: sha512-AiyTknKcNBaKHbcSMuxtSNM8FjIuiSuFyFghdD0TcCMU9hKi9EmsC5pjfjDwxE+5EueB1a+T/34PLRI5vbBbKw==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/rootless@1.5.3': + resolution: {integrity: sha512-N8cIDAHbWcLahNRLr0knAAQvXyEdEMoAZvIMZKmhNb1mlx9e2UOv9BRD5YNwQUJwbNoYVhhLwFOEOcVXFx0HqA==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/scheduled@1.5.3': + resolution: {integrity: sha512-oNwLE6E6lxJAWrc8QXuwM0k2oU1BnANnkChwMw82aK1j3+mWGJkG1IFe5gCwbV+afYmjI76t9JJV3md/8tLw+g==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/static-store@0.1.3': + resolution: {integrity: sha512-uxez7SXnr5GiRnzqO2IEDjOJRIXaG+0LZLBizmUA1FwSi+hrpuMzVBwyk70m4prcl8X6FDDXUl9O8hSq8wHbBQ==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/styles@0.1.3': + resolution: {integrity: sha512-7YdA21prMeCX+oOF/1RAn02+cGz/pG4dyPWtHBC2H8aZvnC7IfThBt80mP+TioejrdfE7Lc54Uh18f7Pig+gRQ==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/utils@6.4.0': + resolution: {integrity: sha512-AeGTBg8Wtkh/0s+evyLtP8piQoS4wyqqQaAFs2HJcFMMjYAtUgo+ZPduRXLjPlqKVc2ejeR544oeqpbn8Egn8A==} + peerDependencies: + solid-js: ^1.6.12 + + '@solidjs/meta@0.29.4': + resolution: {integrity: sha512-zdIWBGpR9zGx1p1bzIPqF5Gs+Ks/BH8R6fWhmUa/dcK1L2rUC8BAcZJzNRYBQv74kScf1TSOs0EY//Vd/I0V8g==} + peerDependencies: + solid-js: '>=1.8.4' + '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} @@ -2420,6 +2534,41 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 + '@tanstack/history@1.161.4': + resolution: {integrity: sha512-Kp/WSt411ZWYvgXy6uiv5RmhHrz9cAml05AQPrtdAp7eUqvIDbMGPnML25OKbzR3RJ1q4wgENxDTvlGPa9+Mww==} + engines: {node: '>=20.19'} + + '@tanstack/react-router@1.166.2': + resolution: {integrity: sha512-pKhUtrvVLlhjWhsHkJSuIzh1J4LcP+8ErbIqRLORX9Js8dUFMKoT0+8oFpi+P8QRpuhm/7rzjYiWfcyTsqQZtA==} + engines: {node: '>=20.19'} + peerDependencies: + react: '>=18.0.0 || >=19.0.0' + react-dom: '>=18.0.0 || >=19.0.0' + + '@tanstack/react-store@0.9.1': + resolution: {integrity: sha512-YzJLnRvy5lIEFTLWBAZmcOjK3+2AepnBv/sr6NZmiqJvq7zTQggyK99Gw8fqYdMdHPQWXjz0epFKJXC+9V2xDA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/router-core@1.166.2': + resolution: {integrity: sha512-zn3NhENOAX9ToQiX077UV2OH3aJKOvV2ZMNZZxZ3gDG3i3WqL8NfWfEgetEAfMN37/Mnt90PpotYgf7IyuoKqQ==} + engines: {node: '>=20.19'} + + '@tanstack/solid-router@1.166.2': + resolution: {integrity: sha512-S0SwU+uiKNdEBdap7D0AWpRaJQyCDQWeXVstY/lfM37Yrp4i1kxmZG5hRmQWVSimD8dDFxJfIVKUWRtwtItwcg==} + engines: {node: '>=20.19'} + peerDependencies: + solid-js: ^1.9.10 + + '@tanstack/solid-store@0.9.1': + resolution: {integrity: sha512-gx7ToM+Yrkui36NIj0HjAufzv1Dg8usjtVFy5H3Ll52Xjuz+eliIJL+ihAr4LRuWh3nDPBR+nCLW0ShFrbE5yw==} + peerDependencies: + solid-js: ^1.6.0 + + '@tanstack/store@0.9.1': + resolution: {integrity: sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg==} + '@tufjs/canonical-json@2.0.0': resolution: {integrity: sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA==} engines: {node: ^16.14.0 || >=18.0.0} @@ -2812,6 +2961,9 @@ packages: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -2977,6 +3129,10 @@ packages: colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -3005,6 +3161,9 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-es@2.0.0: + resolution: {integrity: sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg==} + cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -3032,6 +3191,10 @@ packages: resolution: {integrity: sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==} engines: {node: '>= 6'} + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + cssstyle@5.3.3: resolution: {integrity: sha512-OytmFH+13/QXONJcC75QNdMtKpceNk3u8ThBjyyYjkEcy/ekBwR1mMAuNvi3gdBPW3N5TlCzQ0WZw8H0lN/bDw==} engines: {node: '>=20'} @@ -3042,6 +3205,10 @@ packages: damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + data-urls@6.0.0: resolution: {integrity: sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==} engines: {node: '>=20'} @@ -3093,6 +3260,10 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -3460,6 +3631,10 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -3822,6 +3997,10 @@ packages: isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + isbot@5.1.35: + resolution: {integrity: sha512-waFfC72ZNfwLLuJ2iLaoVaqcNo+CAaLR7xCpAn0Y5WfGzkNHv7ZN39Vbi1y+kb+Zs46XHOX3tZNExroFUPX+Kg==} + engines: {node: '>=18'} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -3862,6 +4041,15 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jsdom@24.1.3: + resolution: {integrity: sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^2.11.2 + peerDependenciesMeta: + canvas: + optional: true + jsdom@27.2.0: resolution: {integrity: sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -4041,6 +4229,9 @@ packages: loupe@2.3.7: resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.2.4: resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==} engines: {node: 20 || >=22} @@ -4088,10 +4279,18 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + mime-db@1.54.0: resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} engines: {node: '>= 0.6'} + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + mime-types@3.0.2: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} @@ -4275,6 +4474,9 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + nwsapi@2.2.23: + resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -4498,6 +4700,9 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + psl@1.15.0: + resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -4506,6 +4711,9 @@ packages: resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} engines: {node: '>=0.6'} + querystringify@2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -4555,6 +4763,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -4604,6 +4815,12 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} + rrweb-cssom@0.7.1: + resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} + + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -4656,10 +4873,20 @@ packages: peerDependencies: seroval: ^1.0 + seroval-plugins@1.5.0: + resolution: {integrity: sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + seroval@1.3.2: resolution: {integrity: sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==} engines: {node: '>=10'} + seroval@1.5.0: + resolution: {integrity: sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==} + engines: {node: '>=10'} + serve-static@2.2.0: resolution: {integrity: sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==} engines: {node: '>= 18'} @@ -4907,6 +5134,12 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + + tiny-warning@1.0.3: + resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -4948,10 +5181,18 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + tough-cookie@4.1.4: + resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} + engines: {node: '>=6'} + tough-cookie@6.0.0: resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} engines: {node: '>=16'} + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + tr46@6.0.0: resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} engines: {node: '>=20'} @@ -5071,6 +5312,10 @@ packages: resolution: {integrity: sha512-4Lup7Ezn8W3d52/xBhZBVdx323ckxa7DEvd9kPQHppTkLoJXw6ltrBCyj5pnrxj0qKDxYMJ56CoxNuFCscdTiw==} engines: {node: ^20.17.0 || >=22.9.0} + universalify@0.2.0: + resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} + engines: {node: '>= 4.0.0'} + unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} @@ -5087,6 +5332,14 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + url-parse@1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} @@ -5305,6 +5558,10 @@ packages: web-vitals@5.1.0: resolution: {integrity: sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==} + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + webidl-conversions@8.0.0: resolution: {integrity: sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==} engines: {node: '>=20'} @@ -5318,6 +5575,10 @@ packages: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + whatwg-url@15.1.0: resolution: {integrity: sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==} engines: {node: '>=20'} @@ -5703,6 +5964,14 @@ snapshots: rxjs: 7.8.2 tslib: 2.8.1 + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + '@asamuzakjp/css-color@4.1.0': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) @@ -6772,6 +7041,8 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@nothing-but/utils@0.17.0': {} + '@npmcli/agent@4.0.0': dependencies: agent-base: 7.1.4 @@ -7054,6 +7325,109 @@ snapshots: '@sinclair/typebox@0.27.8': {} + '@solid-devtools/debugger@0.28.1(solid-js@1.9.10)': + dependencies: + '@nothing-but/utils': 0.17.0 + '@solid-devtools/shared': 0.20.0(solid-js@1.9.10) + '@solid-primitives/bounds': 0.1.5(solid-js@1.9.10) + '@solid-primitives/event-listener': 2.4.5(solid-js@1.9.10) + '@solid-primitives/keyboard': 1.3.5(solid-js@1.9.10) + '@solid-primitives/rootless': 1.5.3(solid-js@1.9.10) + '@solid-primitives/scheduled': 1.5.3(solid-js@1.9.10) + '@solid-primitives/static-store': 0.1.3(solid-js@1.9.10) + '@solid-primitives/utils': 6.4.0(solid-js@1.9.10) + solid-js: 1.9.10 + + '@solid-devtools/logger@0.9.11(solid-js@1.9.10)': + dependencies: + '@nothing-but/utils': 0.17.0 + '@solid-devtools/debugger': 0.28.1(solid-js@1.9.10) + '@solid-devtools/shared': 0.20.0(solid-js@1.9.10) + '@solid-primitives/utils': 6.4.0(solid-js@1.9.10) + solid-js: 1.9.10 + + '@solid-devtools/shared@0.20.0(solid-js@1.9.10)': + dependencies: + '@nothing-but/utils': 0.17.0 + '@solid-primitives/event-listener': 2.4.5(solid-js@1.9.10) + '@solid-primitives/media': 2.3.5(solid-js@1.9.10) + '@solid-primitives/refs': 1.1.3(solid-js@1.9.10) + '@solid-primitives/rootless': 1.5.3(solid-js@1.9.10) + '@solid-primitives/scheduled': 1.5.3(solid-js@1.9.10) + '@solid-primitives/static-store': 0.1.3(solid-js@1.9.10) + '@solid-primitives/styles': 0.1.3(solid-js@1.9.10) + '@solid-primitives/utils': 6.4.0(solid-js@1.9.10) + solid-js: 1.9.10 + + '@solid-primitives/bounds@0.1.5(solid-js@1.9.10)': + dependencies: + '@solid-primitives/event-listener': 2.4.5(solid-js@1.9.10) + '@solid-primitives/resize-observer': 2.1.5(solid-js@1.9.10) + '@solid-primitives/static-store': 0.1.3(solid-js@1.9.10) + '@solid-primitives/utils': 6.4.0(solid-js@1.9.10) + solid-js: 1.9.10 + + '@solid-primitives/event-listener@2.4.5(solid-js@1.9.10)': + dependencies: + '@solid-primitives/utils': 6.4.0(solid-js@1.9.10) + solid-js: 1.9.10 + + '@solid-primitives/keyboard@1.3.5(solid-js@1.9.10)': + dependencies: + '@solid-primitives/event-listener': 2.4.5(solid-js@1.9.10) + '@solid-primitives/rootless': 1.5.3(solid-js@1.9.10) + '@solid-primitives/utils': 6.4.0(solid-js@1.9.10) + solid-js: 1.9.10 + + '@solid-primitives/media@2.3.5(solid-js@1.9.10)': + dependencies: + '@solid-primitives/event-listener': 2.4.5(solid-js@1.9.10) + '@solid-primitives/rootless': 1.5.3(solid-js@1.9.10) + '@solid-primitives/static-store': 0.1.3(solid-js@1.9.10) + '@solid-primitives/utils': 6.4.0(solid-js@1.9.10) + solid-js: 1.9.10 + + '@solid-primitives/refs@1.1.3(solid-js@1.9.10)': + dependencies: + '@solid-primitives/utils': 6.4.0(solid-js@1.9.10) + solid-js: 1.9.10 + + '@solid-primitives/resize-observer@2.1.5(solid-js@1.9.10)': + dependencies: + '@solid-primitives/event-listener': 2.4.5(solid-js@1.9.10) + '@solid-primitives/rootless': 1.5.3(solid-js@1.9.10) + '@solid-primitives/static-store': 0.1.3(solid-js@1.9.10) + '@solid-primitives/utils': 6.4.0(solid-js@1.9.10) + solid-js: 1.9.10 + + '@solid-primitives/rootless@1.5.3(solid-js@1.9.10)': + dependencies: + '@solid-primitives/utils': 6.4.0(solid-js@1.9.10) + solid-js: 1.9.10 + + '@solid-primitives/scheduled@1.5.3(solid-js@1.9.10)': + dependencies: + solid-js: 1.9.10 + + '@solid-primitives/static-store@0.1.3(solid-js@1.9.10)': + dependencies: + '@solid-primitives/utils': 6.4.0(solid-js@1.9.10) + solid-js: 1.9.10 + + '@solid-primitives/styles@0.1.3(solid-js@1.9.10)': + dependencies: + '@solid-primitives/rootless': 1.5.3(solid-js@1.9.10) + '@solid-primitives/utils': 6.4.0(solid-js@1.9.10) + solid-js: 1.9.10 + + '@solid-primitives/utils@6.4.0(solid-js@1.9.10)': + dependencies: + solid-js: 1.9.10 + + '@solidjs/meta@0.29.4(solid-js@1.9.10)': + dependencies: + solid-js: 1.9.10 + '@standard-schema/spec@1.0.0': {} '@swc/helpers@0.5.15': @@ -7197,6 +7571,56 @@ snapshots: tailwindcss: 4.1.18 vite: 7.2.6(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.93.2)(tsx@4.21.0) + '@tanstack/history@1.161.4': {} + + '@tanstack/react-router@1.166.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@tanstack/history': 1.161.4 + '@tanstack/react-store': 0.9.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@tanstack/router-core': 1.166.2 + isbot: 5.1.35 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + tiny-invariant: 1.3.3 + tiny-warning: 1.0.3 + + '@tanstack/react-store@0.9.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@tanstack/store': 0.9.1 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + use-sync-external-store: 1.6.0(react@19.2.0) + + '@tanstack/router-core@1.166.2': + dependencies: + '@tanstack/history': 1.161.4 + '@tanstack/store': 0.9.1 + cookie-es: 2.0.0 + seroval: 1.5.0 + seroval-plugins: 1.5.0(seroval@1.5.0) + tiny-invariant: 1.3.3 + tiny-warning: 1.0.3 + + '@tanstack/solid-router@1.166.2(solid-js@1.9.10)': + dependencies: + '@solid-devtools/logger': 0.9.11(solid-js@1.9.10) + '@solid-primitives/refs': 1.1.3(solid-js@1.9.10) + '@solidjs/meta': 0.29.4(solid-js@1.9.10) + '@tanstack/history': 1.161.4 + '@tanstack/router-core': 1.166.2 + '@tanstack/solid-store': 0.9.1(solid-js@1.9.10) + isbot: 5.1.35 + solid-js: 1.9.10 + tiny-invariant: 1.3.3 + tiny-warning: 1.0.3 + + '@tanstack/solid-store@0.9.1(solid-js@1.9.10)': + dependencies: + '@tanstack/store': 0.9.1 + solid-js: 1.9.10 + + '@tanstack/store@0.9.1': {} + '@tufjs/canonical-json@2.0.0': {} '@tufjs/models@4.0.0': @@ -7651,6 +8075,8 @@ snapshots: async-function@1.0.0: {} + asynckit@0.4.0: {} + available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 @@ -7837,6 +8263,10 @@ snapshots: colorette@2.0.20: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + commander@4.1.1: {} concat-map@0.0.1: {} @@ -7853,6 +8283,8 @@ snapshots: convert-source-map@2.0.0: {} + cookie-es@2.0.0: {} + cookie-signature@1.2.2: {} cookie@0.7.2: {} @@ -7883,6 +8315,11 @@ snapshots: css-what@7.0.0: {} + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + cssstyle@5.3.3: dependencies: '@asamuzakjp/css-color': 4.1.0 @@ -7893,6 +8330,11 @@ snapshots: damerau-levenshtein@1.0.8: {} + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + data-urls@6.0.0: dependencies: whatwg-mimetype: 4.0.0 @@ -7944,6 +8386,8 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + delayed-stream@1.0.0: {} + depd@2.0.0: {} detect-libc@1.0.3: @@ -8241,7 +8685,7 @@ snapshots: eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@9.39.1(jiti@2.6.1)) @@ -8274,7 +8718,7 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -8288,7 +8732,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)): + eslint-plugin-import@2.32.0(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -8571,6 +9015,14 @@ snapshots: dependencies: is-callable: 1.2.7 + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + forwarded@0.2.0: {} fresh@2.0.0: {} @@ -8912,6 +9364,8 @@ snapshots: isarray@2.0.5: {} + isbot@5.1.35: {} + isexe@2.0.0: {} isexe@3.1.1: {} @@ -8951,6 +9405,34 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@24.1.3: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + form-data: 4.0.5 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.23 + parse5: 7.3.0 + rrweb-cssom: 0.7.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 4.1.4 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.18.3 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + jsdom@27.2.0: dependencies: '@acemir/cssom': 0.9.27 @@ -9135,6 +9617,8 @@ snapshots: dependencies: get-func-name: 2.0.2 + lru-cache@10.4.3: {} + lru-cache@11.2.4: {} lru-cache@5.1.1: @@ -9186,8 +9670,14 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + mime-db@1.52.0: {} + mime-db@1.54.0: {} + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + mime-types@3.0.2: dependencies: mime-db: 1.54.0 @@ -9396,6 +9886,8 @@ snapshots: dependencies: boolbase: 1.0.0 + nwsapi@2.2.23: {} + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -9639,12 +10131,18 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + psl@1.15.0: + dependencies: + punycode: 2.3.1 + punycode@2.3.1: {} qs@6.14.0: dependencies: side-channel: 1.1.0 + querystringify@2.2.0: {} + queue-microtask@1.2.3: {} range-parser@1.2.1: {} @@ -9695,6 +10193,8 @@ snapshots: require-from-string@2.0.2: {} + requires-port@1.0.0: {} + resolve-from@4.0.0: {} resolve-from@5.0.0: {} @@ -9782,6 +10282,10 @@ snapshots: transitivePeerDependencies: - supports-color + rrweb-cssom@0.7.1: {} + + rrweb-cssom@0.8.0: {} + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -9849,8 +10353,14 @@ snapshots: dependencies: seroval: 1.3.2 + seroval-plugins@1.5.0(seroval@1.5.0): + dependencies: + seroval: 1.5.0 + seroval@1.3.2: {} + seroval@1.5.0: {} + serve-static@2.2.0: dependencies: encodeurl: 2.0.0 @@ -10178,6 +10688,10 @@ snapshots: dependencies: any-promise: 1.3.0 + tiny-invariant@1.3.3: {} + + tiny-warning@1.0.3: {} + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -10207,10 +10721,21 @@ snapshots: toidentifier@1.0.1: {} + tough-cookie@4.1.4: + dependencies: + psl: 1.15.0 + punycode: 2.3.1 + universalify: 0.2.0 + url-parse: 1.5.10 + tough-cookie@6.0.0: dependencies: tldts: 7.0.19 + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + tr46@6.0.0: dependencies: punycode: 2.3.1 @@ -10358,6 +10883,8 @@ snapshots: dependencies: imurmurhash: 0.1.4 + universalify@0.2.0: {} + unpipe@1.0.0: {} unrs-resolver@1.11.1: @@ -10394,6 +10921,15 @@ snapshots: dependencies: punycode: 2.3.1 + url-parse@1.5.10: + dependencies: + querystringify: 2.2.0 + requires-port: 1.0.0 + + use-sync-external-store@1.6.0(react@19.2.0): + dependencies: + react: 19.2.0 + validate-npm-package-license@3.0.4: dependencies: spdx-correct: 3.2.0 @@ -10497,6 +11033,41 @@ snapshots: optionalDependencies: vite: 7.2.6(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.93.2)(tsx@4.21.0) + vitest@1.6.1(@types/node@20.19.25)(jsdom@24.1.3)(lightningcss@1.30.2)(sass@1.93.2): + dependencies: + '@vitest/expect': 1.6.1 + '@vitest/runner': 1.6.1 + '@vitest/snapshot': 1.6.1 + '@vitest/spy': 1.6.1 + '@vitest/utils': 1.6.1 + acorn-walk: 8.3.4 + chai: 4.5.0 + debug: 4.4.3 + execa: 8.0.1 + local-pkg: 0.5.1 + magic-string: 0.30.21 + pathe: 1.1.2 + picocolors: 1.1.1 + std-env: 3.10.0 + strip-literal: 2.1.1 + tinybench: 2.9.0 + tinypool: 0.8.4 + vite: 5.4.21(@types/node@20.19.25)(lightningcss@1.30.2)(sass@1.93.2) + vite-node: 1.6.1(@types/node@20.19.25)(lightningcss@1.30.2)(sass@1.93.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 20.19.25 + jsdom: 24.1.3 + transitivePeerDependencies: + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vitest@1.6.1(@types/node@20.19.25)(jsdom@27.2.0)(lightningcss@1.30.2)(sass@1.93.2): dependencies: '@vitest/expect': 1.6.1 @@ -10584,6 +11155,8 @@ snapshots: web-vitals@5.1.0: {} + webidl-conversions@7.0.0: {} + webidl-conversions@8.0.0: {} whatwg-encoding@3.1.1: @@ -10592,6 +11165,11 @@ snapshots: whatwg-mimetype@4.0.0: {} + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + whatwg-url@15.1.0: dependencies: tr46: 6.0.0